From 2e74e79e2c3b57c019bcd7ff958b9b5ae8fbd282 Mon Sep 17 00:00:00 2001 From: Richard R Date: Thu, 4 Jun 2026 18:09:36 -0600 Subject: [PATCH 1/2] 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. --- .../doclist/dnd/DocumentDndProvider.tsx | 69 ++++++----------- .../doclist/dnd/DocumentDragLayer.tsx | 74 +++++++++++++++++++ src/components/doclist/views/DocumentTile.tsx | 17 +++++ src/components/doclist/views/GalleryView.tsx | 19 ++++- src/components/doclist/views/ListView.tsx | 18 ++++- tests/folders.spec.ts | 12 +-- tests/helpers.ts | 54 +++++++------- 7 files changed, 182 insertions(+), 81 deletions(-) create mode 100644 src/components/doclist/dnd/DocumentDragLayer.tsx diff --git a/src/components/doclist/dnd/DocumentDndProvider.tsx b/src/components/doclist/dnd/DocumentDndProvider.tsx index 5f5796a..1886633 100644 --- a/src/components/doclist/dnd/DocumentDndProvider.tsx +++ b/src/components/doclist/dnd/DocumentDndProvider.tsx @@ -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(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 ( - - {children} - - ); - } - return ( - + {children} + ); } diff --git a/src/components/doclist/dnd/DocumentDragLayer.tsx b/src/components/doclist/dnd/DocumentDragLayer.tsx new file mode 100644 index 0000000..d17a189 --- /dev/null +++ b/src/components/doclist/dnd/DocumentDragLayer.tsx @@ -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 ; + if (doc.type === 'epub') return ; + return ; +} + +/** + * 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(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 ( +
+
+ + {lead.name} + {extra > 0 && ( + + +{extra} + + )} +
+
+ ); +} diff --git a/src/components/doclist/views/DocumentTile.tsx b/src/components/doclist/views/DocumentTile.tsx index e74cabc..953bc43 100644 --- a/src/components/doclist/views/DocumentTile.tsx +++ b/src/components/doclist/views/DocumentTile.tsx @@ -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') + diff --git a/src/components/doclist/views/GalleryView.tsx b/src/components/doclist/views/GalleryView.tsx index a047b63..0bc609e 100644 --- a/src/components/doclist/views/GalleryView.tsx +++ b/src/components/doclist/views/GalleryView.tsx @@ -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(() => ({ 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({
{ + 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') + diff --git a/src/components/doclist/views/ListView.tsx b/src/components/doclist/views/ListView.tsx index d729b72..ec88e11 100644 --- a/src/components/doclist/views/ListView.tsx +++ b/src/components/doclist/views/ListView.tsx @@ -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(() => ({ 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') + diff --git a/tests/folders.spec.ts b/tests/folders.spec.ts index 7e36725..b88c6f9 100644 --- a/tests/folders.spec.ts +++ b/tests/folders.spec.ts @@ -4,7 +4,7 @@ import { uploadFiles, ensureDocumentsListed, waitForDocumentListHintPersist, - dispatchHtml5DragAndDrop, + dragAndDrop, expectDocumentListed, expectNoDocumentLink, } from './helpers'; @@ -14,11 +14,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]'); + // 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 +35,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 +55,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'); diff --git a/tests/helpers.ts b/tests/helpers.ts index 44c8bd8..617227a 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -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 { - 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 { + 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((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(); } From 9da21a80048674c84c2c4aed795e54ff9523e304 Mon Sep 17 00:00:00 2001 From: Richard R Date: Thu, 4 Jun 2026 18:21:40 -0600 Subject: [PATCH 2/2] test(folders): use escaped regex for file name matching in drag-and-drop tests Update folder tests to use an exported escapeRegExp helper when constructing regex patterns for file name matching. This prevents false positives or errors when file names contain special regex characters, improving test reliability. --- tests/folders.spec.ts | 3 ++- tests/helpers.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/folders.spec.ts b/tests/folders.spec.ts index b88c6f9..47035a2 100644 --- a/tests/folders.spec.ts +++ b/tests/folders.spec.ts @@ -7,6 +7,7 @@ import { dragAndDrop, expectDocumentListed, expectNoDocumentLink, + escapeRegExp, } from './helpers'; test.describe('Document folders and hint persistence', () => { @@ -16,7 +17,7 @@ test.describe('Document folders and hint persistence', () => { // 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(); + 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]'); }; diff --git a/tests/helpers.ts b/tests/helpers.ts index 617227a..9d280f7 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -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, '\\$&'); }