refactor(dnd): unify drag-and-drop backend and add custom drag layer for documents
Replace dual HTML5/Touch backend logic with a single TouchBackend configured to handle both mouse and touch events, ensuring consistent drag-and-drop behavior across all devices. Introduce a custom DocumentDragLayer to render drag previews universally, as the touch backend does not provide a native drag image. Update document tile, gallery, and list views to suppress native long-press previews on iOS and prevent accidental document opening after drag actions. Revise drag-and-drop test helpers to simulate pointer gestures compatible with the new backend, improving reliability of automated tests.
This commit is contained in:
parent
c59052a03e
commit
2e74e79e2c
7 changed files with 182 additions and 81 deletions
|
|
@ -1,60 +1,37 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, type ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { DndProvider } from 'react-dnd';
|
import { DndProvider } from 'react-dnd';
|
||||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
|
||||||
import { TouchBackend } from 'react-dnd-touch-backend';
|
import { TouchBackend } from 'react-dnd-touch-backend';
|
||||||
|
import { DocumentDragLayer } from './DocumentDragLayer';
|
||||||
const TOUCH_QUERY = '(hover: none) and (pointer: coarse)';
|
|
||||||
|
|
||||||
function detectTouchInitial(): boolean {
|
|
||||||
if (typeof window === 'undefined') return false;
|
|
||||||
try {
|
|
||||||
return window.matchMedia(TOUCH_QUERY).matches;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DnD provider that swaps between HTML5 (mouse/keyboard) and Touch backends
|
* Single DnD backend for every input type. HTML5 drag-and-drop doesn't fire
|
||||||
* based on the primary input device. Long-press activates a drag on touch.
|
* from touch input, so rather than maintaining two backends and swapping per
|
||||||
|
* device, we run the touch backend everywhere with `enableMouseEvents` so mouse
|
||||||
|
* drags work too. One code path, identical behavior across devices.
|
||||||
*
|
*
|
||||||
* react-dnd doesn't allow swapping backends after mount, so the subtree is
|
* The touch backend renders no native drag image, so {@link DocumentDragLayer}
|
||||||
* remounted with a `key` when the media query changes. That happens at most
|
* supplies the preview (a lightweight card) on all platforms.
|
||||||
* when the user docks/undocks a tablet — fine in practice.
|
|
||||||
*/
|
*/
|
||||||
export function DocumentDndProvider({ children }: { children: ReactNode }) {
|
export function DocumentDndProvider({ children }: { children: ReactNode }) {
|
||||||
const [isTouch, setIsTouch] = useState<boolean>(detectTouchInitial);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
const mq = window.matchMedia(TOUCH_QUERY);
|
|
||||||
const handler = (e: MediaQueryListEvent) => setIsTouch(e.matches);
|
|
||||||
mq.addEventListener('change', handler);
|
|
||||||
return () => mq.removeEventListener('change', handler);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (isTouch) {
|
|
||||||
return (
|
|
||||||
<DndProvider
|
|
||||||
key="touch"
|
|
||||||
backend={TouchBackend}
|
|
||||||
options={{
|
|
||||||
enableMouseEvents: false,
|
|
||||||
enableTouchEvents: true,
|
|
||||||
delayTouchStart: 220,
|
|
||||||
ignoreContextMenu: true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</DndProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndProvider key="html5" backend={HTML5Backend}>
|
<DndProvider
|
||||||
|
backend={TouchBackend}
|
||||||
|
options={{
|
||||||
|
enableMouseEvents: true,
|
||||||
|
enableTouchEvents: true,
|
||||||
|
// Long-press to start a touch drag; mouse drags start immediately.
|
||||||
|
delayTouchStart: 220,
|
||||||
|
delayMouseStart: 0,
|
||||||
|
// Require real movement before a drag begins, so a plain click (mouse or
|
||||||
|
// tap) can never accidentally arm a drag and swallow the click/navigation.
|
||||||
|
touchSlop: 10,
|
||||||
|
ignoreContextMenu: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
|
<DocumentDragLayer />
|
||||||
</DndProvider>
|
</DndProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
74
src/components/doclist/dnd/DocumentDragLayer.tsx
Normal file
74
src/components/doclist/dnd/DocumentDragLayer.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useDragLayer, type XYCoord } from 'react-dnd';
|
||||||
|
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
||||||
|
import type { DocumentListDocument } from '@/types/documents';
|
||||||
|
import { DND_DOCUMENT, type DocumentDragItem } from './dndTypes';
|
||||||
|
|
||||||
|
function KindIcon({ doc }: { doc: DocumentListDocument }) {
|
||||||
|
if (doc.type === 'pdf') return <PDFIcon className="w-4 h-4 shrink-0 text-danger" />;
|
||||||
|
if (doc.type === 'epub') return <EPUBIcon className="w-4 h-4 shrink-0 text-accent" />;
|
||||||
|
return <FileIcon className="w-4 h-4 shrink-0 text-soft" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag preview for the touch backend, which renders no native drag image.
|
||||||
|
* A single lightweight card (icon + name + count) shown for every view, so the
|
||||||
|
* preview stays uniform across viewing modes and carries no thumbnail image.
|
||||||
|
*/
|
||||||
|
export function DocumentDragLayer() {
|
||||||
|
const { isDragging, item, itemType, offset } = useDragLayer((monitor) => ({
|
||||||
|
isDragging: monitor.isDragging(),
|
||||||
|
item: monitor.getItem() as DocumentDragItem | null,
|
||||||
|
itemType: monitor.getItemType(),
|
||||||
|
offset: monitor.getClientOffset(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// A mouse drag on a non-anchor element (e.g. gallery thumbnails) otherwise
|
||||||
|
// starts a text selection that smears across whatever it passes over. Lock
|
||||||
|
// selection on the body for the duration of the drag and clear what's there.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const style = document.body.style;
|
||||||
|
const prev = style.userSelect;
|
||||||
|
const prevWebkit = style.webkitUserSelect;
|
||||||
|
style.userSelect = 'none';
|
||||||
|
style.webkitUserSelect = 'none';
|
||||||
|
window.getSelection?.()?.removeAllRanges();
|
||||||
|
return () => {
|
||||||
|
style.userSelect = prev;
|
||||||
|
style.webkitUserSelect = prevWebkit;
|
||||||
|
};
|
||||||
|
}, [isDragging]);
|
||||||
|
|
||||||
|
// The touch backend reports a null offset between some move frames; hold the
|
||||||
|
// last known position so the card doesn't blink out and back mid-drag.
|
||||||
|
const lastOffset = useRef<XYCoord | null>(null);
|
||||||
|
if (!isDragging) lastOffset.current = null;
|
||||||
|
else if (offset) lastOffset.current = offset;
|
||||||
|
const pos = isDragging ? offset ?? lastOffset.current : null;
|
||||||
|
|
||||||
|
const docs = item?.docs ?? [];
|
||||||
|
if (!isDragging || itemType !== DND_DOCUMENT || !pos || docs.length === 0) return null;
|
||||||
|
|
||||||
|
const lead = docs[0];
|
||||||
|
const extra = docs.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed inset-0 z-[100]">
|
||||||
|
<div
|
||||||
|
className="absolute flex items-center gap-2 max-w-[220px] rounded-md border border-accent-line bg-surface px-2.5 py-1.5 shadow-elev-2"
|
||||||
|
style={{ transform: `translate(${pos.x + 12}px, ${pos.y - 28}px)`, willChange: 'transform' }}
|
||||||
|
>
|
||||||
|
<KindIcon doc={lead} />
|
||||||
|
<span className="truncate text-[12px] font-medium text-foreground">{lead.name}</span>
|
||||||
|
{extra > 0 && (
|
||||||
|
<span className="shrink-0 rounded-full bg-accent px-1.5 py-0.5 text-[10px] font-semibold leading-none text-background">
|
||||||
|
+{extra}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useRef } from 'react';
|
||||||
import { useDrag, useDrop, type DragSourceMonitor } from 'react-dnd';
|
import { useDrag, useDrop, type DragSourceMonitor } from 'react-dnd';
|
||||||
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
||||||
import type { DocumentListDocument, IconSize } from '@/types/documents';
|
import type { DocumentListDocument, IconSize } from '@/types/documents';
|
||||||
|
|
@ -73,6 +74,7 @@ export function DocumentTile({
|
||||||
const showDeleteButton = true;
|
const showDeleteButton = true;
|
||||||
const isSelected = selection.isSelected(doc);
|
const isSelected = selection.isSelected(doc);
|
||||||
const isInFolder = Boolean(doc.folderId);
|
const isInFolder = Boolean(doc.folderId);
|
||||||
|
const didDragRef = useRef(false);
|
||||||
|
|
||||||
const [{ isDragging }, dragRef, previewRef] = useDrag<
|
const [{ isDragging }, dragRef, previewRef] = useDrag<
|
||||||
DocumentDragItem,
|
DocumentDragItem,
|
||||||
|
|
@ -95,6 +97,13 @@ export function DocumentTile({
|
||||||
fromFolderId: doc.folderId,
|
fromFolderId: doc.folderId,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
// A mouse drag ending on the same tile is followed by a click that would
|
||||||
|
// open the doc. Flag the drag so handleClick can swallow that click; clear
|
||||||
|
// on the next macrotask in case the drag ended elsewhere (no click fires).
|
||||||
|
end: () => {
|
||||||
|
didDragRef.current = true;
|
||||||
|
setTimeout(() => { didDragRef.current = false; }, 0);
|
||||||
|
},
|
||||||
collect: (monitor: DragSourceMonitor) => ({ isDragging: monitor.isDragging() }),
|
collect: (monitor: DragSourceMonitor) => ({ isDragging: monitor.isDragging() }),
|
||||||
};
|
};
|
||||||
}, [doc, isSelected, selection]);
|
}, [doc, isSelected, selection]);
|
||||||
|
|
@ -126,6 +135,11 @@ export function DocumentTile({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick: React.MouseEventHandler = (e) => {
|
const handleClick: React.MouseEventHandler = (e) => {
|
||||||
|
if (didDragRef.current) {
|
||||||
|
didDragRef.current = false;
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.shiftKey || e.metaKey || e.ctrlKey) {
|
if (e.shiftKey || e.metaKey || e.ctrlKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
selection.select(doc, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey });
|
selection.select(doc, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey });
|
||||||
|
|
@ -139,6 +153,9 @@ export function DocumentTile({
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
className={
|
className={
|
||||||
'group relative flex flex-col rounded-md overflow-hidden border transition duration-base ease-standard ' +
|
'group relative flex flex-col rounded-md overflow-hidden border transition duration-base ease-standard ' +
|
||||||
|
// iOS: suppress the long-press link preview/callout and selection magnifier so the
|
||||||
|
// long-press is handed to the touch DnD backend instead of the native preview.
|
||||||
|
'select-none [-webkit-touch-callout:none] ' +
|
||||||
(isSelected
|
(isSelected
|
||||||
? 'border-accent-line bg-surface-sunken'
|
? 'border-accent-line bg-surface-sunken'
|
||||||
: 'border-line bg-surface hover:bg-accent-wash hover:border-accent-line') +
|
: 'border-line bg-surface hover:bg-accent-wash hover:border-accent-line') +
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ function GalleryThumb({
|
||||||
const selection = useDocumentSelection();
|
const selection = useDocumentSelection();
|
||||||
const isSelected = selection.isSelected(doc);
|
const isSelected = selection.isSelected(doc);
|
||||||
const isInFolder = Boolean(doc.folderId);
|
const isInFolder = Boolean(doc.folderId);
|
||||||
|
const didDragRef = useRef(false);
|
||||||
|
|
||||||
const [{ isDragging }, dragRef] = useDrag<DocumentDragItem, void, { isDragging: boolean }>(() => ({
|
const [{ isDragging }, dragRef] = useDrag<DocumentDragItem, void, { isDragging: boolean }>(() => ({
|
||||||
type: DND_DOCUMENT,
|
type: DND_DOCUMENT,
|
||||||
|
|
@ -69,6 +70,13 @@ function GalleryThumb({
|
||||||
fromFolderId: doc.folderId,
|
fromFolderId: doc.folderId,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
// A mouse drag ending on the same thumb is followed by a click. Flag the drag
|
||||||
|
// so the click handler can ignore it; clear on the next macrotask in case the
|
||||||
|
// drag ended elsewhere (no click fires).
|
||||||
|
end: () => {
|
||||||
|
didDragRef.current = true;
|
||||||
|
setTimeout(() => { didDragRef.current = false; }, 0);
|
||||||
|
},
|
||||||
collect: (m) => ({ isDragging: m.isDragging() }),
|
collect: (m) => ({ isDragging: m.isDragging() }),
|
||||||
}), [doc, isSelected, selection]);
|
}), [doc, isSelected, selection]);
|
||||||
|
|
||||||
|
|
@ -88,10 +96,19 @@ function GalleryThumb({
|
||||||
<div
|
<div
|
||||||
ref={setRefs}
|
ref={setRefs}
|
||||||
data-doc-tile
|
data-doc-tile
|
||||||
onClick={onClick}
|
onClick={() => {
|
||||||
|
if (didDragRef.current) {
|
||||||
|
didDragRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
aria-current={active ? 'true' : undefined}
|
aria-current={active ? 'true' : undefined}
|
||||||
className={
|
className={
|
||||||
'group relative w-[98px] sm:w-[110px] shrink-0 cursor-pointer rounded-lg overflow-hidden border bg-surface snap-start transition duration-base ease-standard ' +
|
'group relative w-[98px] sm:w-[110px] shrink-0 cursor-pointer rounded-lg overflow-hidden border bg-surface snap-start transition duration-base ease-standard ' +
|
||||||
|
// iOS: suppress the long-press link preview/callout and selection magnifier so the
|
||||||
|
// long-press is handed to the touch DnD backend instead of the native preview.
|
||||||
|
'select-none [-webkit-touch-callout:none] ' +
|
||||||
(active
|
(active
|
||||||
? 'border-accent-line shadow-elev-2 -translate-y-px'
|
? 'border-accent-line shadow-elev-2 -translate-y-px'
|
||||||
: 'border-line hover:border-accent-line hover:-translate-y-px hover:shadow-elev-2') +
|
: 'border-line hover:border-accent-line hover:-translate-y-px hover:shadow-elev-2') +
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useDrag, useDrop } from 'react-dnd';
|
import { useDrag, useDrop } from 'react-dnd';
|
||||||
import type {
|
import type {
|
||||||
DocumentListDocument,
|
DocumentListDocument,
|
||||||
|
|
@ -88,6 +88,7 @@ function DocRow({
|
||||||
const isSelected = selection.isSelected(doc);
|
const isSelected = selection.isSelected(doc);
|
||||||
const isInFolder = Boolean(doc.folderId);
|
const isInFolder = Boolean(doc.folderId);
|
||||||
const href = `/${doc.type}/${encodeURIComponent(doc.id)}`;
|
const href = `/${doc.type}/${encodeURIComponent(doc.id)}`;
|
||||||
|
const didDragRef = useRef(false);
|
||||||
|
|
||||||
const [{ isDragging }, dragRef] = useDrag<DocumentDragItem, void, { isDragging: boolean }>(() => ({
|
const [{ isDragging }, dragRef] = useDrag<DocumentDragItem, void, { isDragging: boolean }>(() => ({
|
||||||
type: DND_DOCUMENT,
|
type: DND_DOCUMENT,
|
||||||
|
|
@ -101,6 +102,13 @@ function DocRow({
|
||||||
fromFolderId: doc.folderId,
|
fromFolderId: doc.folderId,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
// A mouse drag ending on the same row is followed by a click that would open
|
||||||
|
// the doc. Flag the drag so handleClick can swallow that click; clear on the
|
||||||
|
// next macrotask in case the drag ended elsewhere (no click fires).
|
||||||
|
end: () => {
|
||||||
|
didDragRef.current = true;
|
||||||
|
setTimeout(() => { didDragRef.current = false; }, 0);
|
||||||
|
},
|
||||||
collect: (m) => ({ isDragging: m.isDragging() }),
|
collect: (m) => ({ isDragging: m.isDragging() }),
|
||||||
}), [doc, isSelected, selection]);
|
}), [doc, isSelected, selection]);
|
||||||
|
|
||||||
|
|
@ -119,6 +127,11 @@ function DocRow({
|
||||||
const isTarget = isOver && canDrop;
|
const isTarget = isOver && canDrop;
|
||||||
|
|
||||||
const handleClick: React.MouseEventHandler = (e) => {
|
const handleClick: React.MouseEventHandler = (e) => {
|
||||||
|
if (didDragRef.current) {
|
||||||
|
didDragRef.current = false;
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.shiftKey || e.metaKey || e.ctrlKey) {
|
if (e.shiftKey || e.metaKey || e.ctrlKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
selection.select(doc, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey });
|
selection.select(doc, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey });
|
||||||
|
|
@ -132,6 +145,9 @@ function DocRow({
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
className={
|
className={
|
||||||
'grid grid-cols-[minmax(0,1fr)_44px_72px_104px_28px] sm:grid-cols-[minmax(0,1fr)_56px_96px_140px_32px] items-center text-[12px] border-b border-line-soft transition-colors duration-base ease-standard ' +
|
'grid grid-cols-[minmax(0,1fr)_44px_72px_104px_28px] sm:grid-cols-[minmax(0,1fr)_56px_96px_140px_32px] items-center text-[12px] border-b border-line-soft transition-colors duration-base ease-standard ' +
|
||||||
|
// iOS: suppress the long-press link preview/callout and selection magnifier so the
|
||||||
|
// long-press is handed to the touch DnD backend instead of the native preview.
|
||||||
|
'select-none [-webkit-touch-callout:none] ' +
|
||||||
(isSelected
|
(isSelected
|
||||||
? 'bg-surface-sunken text-accent'
|
? 'bg-surface-sunken text-accent'
|
||||||
: 'text-foreground hover:bg-accent-wash') +
|
: 'text-foreground hover:bg-accent-wash') +
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import {
|
||||||
uploadFiles,
|
uploadFiles,
|
||||||
ensureDocumentsListed,
|
ensureDocumentsListed,
|
||||||
waitForDocumentListHintPersist,
|
waitForDocumentListHintPersist,
|
||||||
dispatchHtml5DragAndDrop,
|
dragAndDrop,
|
||||||
expectDocumentListed,
|
expectDocumentListed,
|
||||||
expectNoDocumentLink,
|
expectNoDocumentLink,
|
||||||
} from './helpers';
|
} from './helpers';
|
||||||
|
|
@ -14,11 +14,11 @@ test.describe('Document folders and hint persistence', () => {
|
||||||
await setupTest(page, testInfo);
|
await setupTest(page, testInfo);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Utility to get the draggable row for a given filename (by link)
|
// Utility to get the draggable tile for a given filename (by link).
|
||||||
const rowFor = (page: Page, fileName: string) => {
|
const rowFor = (page: Page, fileName: string) => {
|
||||||
const link = page.getByRole('link', { name: new RegExp(fileName, 'i') }).first();
|
const link = page.getByRole('link', { name: new RegExp(fileName, 'i') }).first();
|
||||||
// The draggable attribute lives on the row container ancestor
|
// The drag source is the tile container ancestor, marked with data-doc-tile.
|
||||||
return link.locator('xpath=ancestor::*[@draggable="true"][1]');
|
return link.locator('xpath=ancestor::*[@data-doc-tile][1]');
|
||||||
};
|
};
|
||||||
|
|
||||||
const folderRow = (page: Page, folderName: string) =>
|
const folderRow = (page: Page, folderName: string) =>
|
||||||
|
|
@ -35,7 +35,7 @@ test.describe('Document folders and hint persistence', () => {
|
||||||
// Drag PDF onto EPUB to create a folder
|
// Drag PDF onto EPUB to create a folder
|
||||||
const pdfRow = rowFor(page, 'sample.pdf');
|
const pdfRow = rowFor(page, 'sample.pdf');
|
||||||
const epubRow = rowFor(page, 'sample.epub');
|
const epubRow = rowFor(page, 'sample.epub');
|
||||||
await dispatchHtml5DragAndDrop(page, pdfRow, epubRow);
|
await dragAndDrop(page, pdfRow, epubRow);
|
||||||
|
|
||||||
// Folder name dialog appears
|
// Folder name dialog appears
|
||||||
await expect(page.getByRole('heading', { name: 'Create New Folder' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Create New Folder' })).toBeVisible();
|
||||||
|
|
@ -55,7 +55,7 @@ test.describe('Document folders and hint persistence', () => {
|
||||||
// Switch to all documents and drag TXT into sidebar folder row
|
// Switch to all documents and drag TXT into sidebar folder row
|
||||||
await allDocumentsRow(page).click();
|
await allDocumentsRow(page).click();
|
||||||
const txtRow = rowFor(page, 'sample.txt');
|
const txtRow = rowFor(page, 'sample.txt');
|
||||||
await dispatchHtml5DragAndDrop(page, txtRow, myFolderRow);
|
await dragAndDrop(page, txtRow, myFolderRow);
|
||||||
await expectDocumentListed(page, 'sample.txt');
|
await expectDocumentListed(page, 'sample.txt');
|
||||||
await expectNoDocumentLink(page, 'sample.md');
|
await expectNoDocumentLink(page, 'sample.md');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -265,37 +265,37 @@ export async function setupTest(page: Page, testInfo?: TestInfo) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* More reliable than Playwright's `locator.dragTo` when a drop immediately opens a modal
|
* Simulate a pointer drag-and-drop for the doc list's touch DnD backend.
|
||||||
* (which can intercept pointer events mid-gesture and cause flakiness).
|
|
||||||
*
|
*
|
||||||
* This uses DOM drag events directly; our app's doc list DnD logic only needs the events,
|
* The list uses react-dnd's TouchBackend (with `enableMouseEvents`), which
|
||||||
* not a real OS-level drag interaction.
|
* listens to mouse/touch events rather than the HTML5 DragEvent API — so we
|
||||||
|
* drive it with a real pointer gesture: press the source, move past the drag
|
||||||
|
* threshold to the target in steps (so hover/drop hit-testing registers), and
|
||||||
|
* release.
|
||||||
*/
|
*/
|
||||||
export async function dispatchHtml5DragAndDrop(page: Page, source: Locator, target: Locator): Promise<void> {
|
export async function dragAndDrop(page: Page, source: Locator, target: Locator): Promise<void> {
|
||||||
const sourceHandle = await source.elementHandle();
|
const src = await source.boundingBox();
|
||||||
const targetHandle = await target.elementHandle();
|
const dst = await target.boundingBox();
|
||||||
if (!sourceHandle) throw new Error('drag source element not found');
|
if (!src) throw new Error('drag source element not found');
|
||||||
if (!targetHandle) throw new Error('drag target element not found');
|
if (!dst) throw new Error('drag target element not found');
|
||||||
|
|
||||||
await page.evaluate(
|
const sx = src.x + src.width / 2;
|
||||||
async ([src, dst]) => {
|
const sy = src.y + src.height / 2;
|
||||||
const dt = typeof DataTransfer !== 'undefined' ? new DataTransfer() : ({} as DataTransfer);
|
const tx = dst.x + dst.width / 2;
|
||||||
const fire = (el: Element, type: string) => {
|
const ty = dst.y + dst.height / 2;
|
||||||
const event = new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: dt });
|
|
||||||
el.dispatchEvent(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
fire(src, 'dragstart');
|
await page.mouse.move(sx, sy);
|
||||||
// Let React flush state updates (draggedDoc) before dispatching drop events.
|
await page.mouse.down();
|
||||||
await Promise.resolve();
|
// The touch backend arms a mouse drag on a delayed-start timer; give it a beat
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
// to fire before moving, otherwise the first move cancels it un-armed.
|
||||||
fire(dst, 'dragenter');
|
await page.waitForTimeout(60);
|
||||||
fire(dst, 'dragover');
|
// Move clear of the touchSlop threshold to begin the drag, then travel to the
|
||||||
fire(dst, 'drop');
|
// target in steps and settle on it so its hover (drop target) registers.
|
||||||
fire(src, 'dragend');
|
await page.mouse.move(sx + 16, sy + 16, { steps: 8 });
|
||||||
},
|
await page.mouse.move(tx, ty, { steps: 20 });
|
||||||
[sourceHandle, targetHandle],
|
await page.mouse.move(tx, ty);
|
||||||
);
|
await page.waitForTimeout(60);
|
||||||
|
await page.mouse.up();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue