Merge pull request #100 from richardr1126/fix/document-dnd
fix(dnd): unify document drag-and-drop on a single touch backend and fix mobile DnD
This commit is contained in:
commit
833f38059c
7 changed files with 185 additions and 83 deletions
|
|
@ -1,60 +1,37 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { TouchBackend } from 'react-dnd-touch-backend';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
import { DocumentDragLayer } from './DocumentDragLayer';
|
||||
|
||||
/**
|
||||
* DnD provider that swaps between HTML5 (mouse/keyboard) and Touch backends
|
||||
* based on the primary input device. Long-press activates a drag on touch.
|
||||
* Single DnD backend for every input type. HTML5 drag-and-drop doesn't fire
|
||||
* 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
|
||||
* remounted with a `key` when the media query changes. That happens at most
|
||||
* when the user docks/undocks a tablet — fine in practice.
|
||||
* The touch backend renders no native drag image, so {@link DocumentDragLayer}
|
||||
* supplies the preview (a lightweight card) on all platforms.
|
||||
*/
|
||||
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 (
|
||||
<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}
|
||||
<DocumentDragLayer />
|
||||
</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';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRef } from 'react';
|
||||
import { useDrag, useDrop, type DragSourceMonitor } from 'react-dnd';
|
||||
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
||||
import type { DocumentListDocument, IconSize } from '@/types/documents';
|
||||
|
|
@ -73,6 +74,7 @@ export function DocumentTile({
|
|||
const showDeleteButton = true;
|
||||
const isSelected = selection.isSelected(doc);
|
||||
const isInFolder = Boolean(doc.folderId);
|
||||
const didDragRef = useRef(false);
|
||||
|
||||
const [{ isDragging }, dragRef, previewRef] = useDrag<
|
||||
DocumentDragItem,
|
||||
|
|
@ -95,6 +97,13 @@ export function DocumentTile({
|
|||
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() }),
|
||||
};
|
||||
}, [doc, isSelected, selection]);
|
||||
|
|
@ -126,6 +135,11 @@ export function DocumentTile({
|
|||
};
|
||||
|
||||
const handleClick: React.MouseEventHandler = (e) => {
|
||||
if (didDragRef.current) {
|
||||
didDragRef.current = false;
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey || e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
selection.select(doc, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey });
|
||||
|
|
@ -139,6 +153,9 @@ export function DocumentTile({
|
|||
aria-selected={isSelected}
|
||||
className={
|
||||
'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
|
||||
? 'border-accent-line bg-surface-sunken'
|
||||
: 'border-line bg-surface hover:bg-accent-wash hover:border-accent-line') +
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ function GalleryThumb({
|
|||
const selection = useDocumentSelection();
|
||||
const isSelected = selection.isSelected(doc);
|
||||
const isInFolder = Boolean(doc.folderId);
|
||||
const didDragRef = useRef(false);
|
||||
|
||||
const [{ isDragging }, dragRef] = useDrag<DocumentDragItem, void, { isDragging: boolean }>(() => ({
|
||||
type: DND_DOCUMENT,
|
||||
|
|
@ -69,6 +70,13 @@ function GalleryThumb({
|
|||
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() }),
|
||||
}), [doc, isSelected, selection]);
|
||||
|
||||
|
|
@ -88,10 +96,19 @@ function GalleryThumb({
|
|||
<div
|
||||
ref={setRefs}
|
||||
data-doc-tile
|
||||
onClick={onClick}
|
||||
onClick={() => {
|
||||
if (didDragRef.current) {
|
||||
didDragRef.current = false;
|
||||
return;
|
||||
}
|
||||
onClick();
|
||||
}}
|
||||
aria-current={active ? 'true' : undefined}
|
||||
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 ' +
|
||||
// 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
|
||||
? 'border-accent-line shadow-elev-2 -translate-y-px'
|
||||
: 'border-line hover:border-accent-line hover:-translate-y-px hover:shadow-elev-2') +
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import type {
|
||||
DocumentListDocument,
|
||||
|
|
@ -88,6 +88,7 @@ function DocRow({
|
|||
const isSelected = selection.isSelected(doc);
|
||||
const isInFolder = Boolean(doc.folderId);
|
||||
const href = `/${doc.type}/${encodeURIComponent(doc.id)}`;
|
||||
const didDragRef = useRef(false);
|
||||
|
||||
const [{ isDragging }, dragRef] = useDrag<DocumentDragItem, void, { isDragging: boolean }>(() => ({
|
||||
type: DND_DOCUMENT,
|
||||
|
|
@ -101,6 +102,13 @@ function DocRow({
|
|||
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() }),
|
||||
}), [doc, isSelected, selection]);
|
||||
|
||||
|
|
@ -119,6 +127,11 @@ function DocRow({
|
|||
const isTarget = isOver && canDrop;
|
||||
|
||||
const handleClick: React.MouseEventHandler = (e) => {
|
||||
if (didDragRef.current) {
|
||||
didDragRef.current = false;
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey || e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
selection.select(doc, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey });
|
||||
|
|
@ -132,6 +145,9 @@ function DocRow({
|
|||
aria-selected={isSelected}
|
||||
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 ' +
|
||||
// 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
|
||||
? 'bg-surface-sunken text-accent'
|
||||
: 'text-foreground hover:bg-accent-wash') +
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@ import {
|
|||
uploadFiles,
|
||||
ensureDocumentsListed,
|
||||
waitForDocumentListHintPersist,
|
||||
dispatchHtml5DragAndDrop,
|
||||
dragAndDrop,
|
||||
expectDocumentListed,
|
||||
expectNoDocumentLink,
|
||||
escapeRegExp,
|
||||
} from './helpers';
|
||||
|
||||
test.describe('Document folders and hint persistence', () => {
|
||||
|
|
@ -14,11 +15,11 @@ test.describe('Document folders and hint persistence', () => {
|
|||
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 link = page.getByRole('link', { name: new RegExp(fileName, 'i') }).first();
|
||||
// The draggable attribute lives on the row container ancestor
|
||||
return link.locator('xpath=ancestor::*[@draggable="true"][1]');
|
||||
const link = page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') }).first();
|
||||
// The drag source is the tile container ancestor, marked with data-doc-tile.
|
||||
return link.locator('xpath=ancestor::*[@data-doc-tile][1]');
|
||||
};
|
||||
|
||||
const folderRow = (page: Page, folderName: string) =>
|
||||
|
|
@ -35,7 +36,7 @@ test.describe('Document folders and hint persistence', () => {
|
|||
// Drag PDF onto EPUB to create a folder
|
||||
const pdfRow = rowFor(page, 'sample.pdf');
|
||||
const epubRow = rowFor(page, 'sample.epub');
|
||||
await dispatchHtml5DragAndDrop(page, pdfRow, epubRow);
|
||||
await dragAndDrop(page, pdfRow, epubRow);
|
||||
|
||||
// Folder name dialog appears
|
||||
await expect(page.getByRole('heading', { name: 'Create New Folder' })).toBeVisible();
|
||||
|
|
@ -55,7 +56,7 @@ test.describe('Document folders and hint persistence', () => {
|
|||
// Switch to all documents and drag TXT into sidebar folder row
|
||||
await allDocumentsRow(page).click();
|
||||
const txtRow = rowFor(page, 'sample.txt');
|
||||
await dispatchHtml5DragAndDrop(page, txtRow, myFolderRow);
|
||||
await dragAndDrop(page, txtRow, myFolderRow);
|
||||
await expectDocumentListed(page, 'sample.txt');
|
||||
await expectNoDocumentLink(page, 'sample.md');
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { createHash } from 'crypto';
|
|||
const DIR = './tests/files/';
|
||||
|
||||
// Small util to safely use filenames inside regex patterns
|
||||
function escapeRegExp(input: string) {
|
||||
export function escapeRegExp(input: string) {
|
||||
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
* (which can intercept pointer events mid-gesture and cause flakiness).
|
||||
* Simulate a pointer drag-and-drop for the doc list's touch DnD backend.
|
||||
*
|
||||
* This uses DOM drag events directly; our app's doc list DnD logic only needs the events,
|
||||
* not a real OS-level drag interaction.
|
||||
* The list uses react-dnd's TouchBackend (with `enableMouseEvents`), which
|
||||
* 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> {
|
||||
const sourceHandle = await source.elementHandle();
|
||||
const targetHandle = await target.elementHandle();
|
||||
if (!sourceHandle) throw new Error('drag source element not found');
|
||||
if (!targetHandle) throw new Error('drag target element not found');
|
||||
export async function dragAndDrop(page: Page, source: Locator, target: Locator): Promise<void> {
|
||||
const src = await source.boundingBox();
|
||||
const dst = await target.boundingBox();
|
||||
if (!src) throw new Error('drag source element not found');
|
||||
if (!dst) throw new Error('drag target element not found');
|
||||
|
||||
await page.evaluate(
|
||||
async ([src, dst]) => {
|
||||
const dt = typeof DataTransfer !== 'undefined' ? new DataTransfer() : ({} as DataTransfer);
|
||||
const fire = (el: Element, type: string) => {
|
||||
const event = new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: dt });
|
||||
el.dispatchEvent(event);
|
||||
};
|
||||
const sx = src.x + src.width / 2;
|
||||
const sy = src.y + src.height / 2;
|
||||
const tx = dst.x + dst.width / 2;
|
||||
const ty = dst.y + dst.height / 2;
|
||||
|
||||
fire(src, 'dragstart');
|
||||
// Let React flush state updates (draggedDoc) before dispatching drop events.
|
||||
await Promise.resolve();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fire(dst, 'dragenter');
|
||||
fire(dst, 'dragover');
|
||||
fire(dst, 'drop');
|
||||
fire(src, 'dragend');
|
||||
},
|
||||
[sourceHandle, targetHandle],
|
||||
);
|
||||
await page.mouse.move(sx, sy);
|
||||
await page.mouse.down();
|
||||
// The touch backend arms a mouse drag on a delayed-start timer; give it a beat
|
||||
// to fire before moving, otherwise the first move cancels it un-armed.
|
||||
await page.waitForTimeout(60);
|
||||
// Move clear of the touchSlop threshold to begin the drag, then travel to the
|
||||
// target in steps and settle on it so its hover (drop target) registers.
|
||||
await page.mouse.move(sx + 16, sy + 16, { steps: 8 });
|
||||
await page.mouse.move(tx, ty, { steps: 20 });
|
||||
await page.mouse.move(tx, ty);
|
||||
await page.waitForTimeout(60);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue