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..47035a2 100644 --- a/tests/folders.spec.ts +++ b/tests/folders.spec.ts @@ -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'); diff --git a/tests/helpers.ts b/tests/helpers.ts index 44c8bd8..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, '\\$&'); } @@ -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(); }