import type { Locator, Page } from "playwright"; /** Options for pw_drag_drop. */ export interface PwDragDropOptions { /** Selector string or Playwright Locator for the element to drag from. */ source: string | Locator; /** Selector string or Playwright Locator for the drop target. */ target: string | Locator; /** Number of intermediate mousemove steps from source to target. Default: 20. */ steps?: number; /** Pause between each step in ms. Default: 16. */ delayMs?: number; /** Pause over the target before releasing in ms. Useful for time-based dropzones. Default: 0. */ hoverMs?: number; /** Initial offset after mousedown to cross dnd-kit's activation threshold (default 8px). Default: {x: 12, y: 0}. */ activateOffset?: { x: number; y: number }; } /** * pw_drag_drop — simulates a human pointer drag compatible with dnd-kit. * * Playwright's built-in dragTo uses HTML5 drag events which dnd-kit ignores * (it listens to PointerEvents). This helper uses raw mouse API with a * small activation move to cross dnd-kit's default 8px threshold, then * interpolates in configurable steps toward the target. */ export async function pw_drag_drop(page: Page, opts: PwDragDropOptions): Promise { const steps = opts.steps ?? 20; const delayMs = opts.delayMs ?? 16; const hoverMs = opts.hoverMs ?? 0; const activateOffset = opts.activateOffset ?? { x: 12, y: 0 }; // Resolve source and target to Locators. const sourceLocator: Locator = typeof opts.source === "string" ? page.locator(opts.source).first() : opts.source; const targetLocator: Locator = typeof opts.target === "string" ? page.locator(opts.target).first() : opts.target; // Get bounding boxes. const sourceBbox = await sourceLocator.boundingBox(); if (sourceBbox === null) { throw new Error("pw_drag_drop: source element has no bounding box (not visible or detached)"); } const targetBbox = await targetLocator.boundingBox(); if (targetBbox === null) { throw new Error("pw_drag_drop: target element has no bounding box (not visible or detached)"); } // Compute centers. const sx = sourceBbox.x + sourceBbox.width / 2; const sy = sourceBbox.y + sourceBbox.height / 2; const tx = targetBbox.x + targetBbox.width / 2; const ty = targetBbox.y + targetBbox.height / 2; // Move to source and press down. await page.mouse.move(sx, sy); await page.mouse.down(); // Small move to cross dnd-kit activation threshold (default 8px). await page.mouse.move(sx + activateOffset.x, sy + activateOffset.y, { steps: 2 }); // Interpolate from activation position toward target. const startX = sx + activateOffset.x; const startY = sy + activateOffset.y; for (let i = 1; i <= steps; i++) { const t = i / steps; const xi = startX + (tx - startX) * t; const yi = startY + (ty - startY) * t; await page.mouse.move(xi, yi); await page.waitForTimeout(delayMs); } // Optional hover pause before release (useful for sidebar auto-open, etc.). if (hoverMs > 0) { await page.waitForTimeout(hoverMs); } // Release. await page.mouse.up(); }