diff --git a/client/src/utils/touchDragBridge.test.ts b/client/src/utils/touchDragBridge.test.ts new file mode 100644 index 000000000..7a22beb69 --- /dev/null +++ b/client/src/utils/touchDragBridge.test.ts @@ -0,0 +1,327 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { fireEvent } from '@testing-library/react' +import { installTouchDragBridge } from './touchDragBridge' + +/** Longer than the bridge's long press, short enough to stay readable. */ +const PRESS = 400 + +type Point = { clientX: number; clientY: number; identifier?: number } + +function touchStart(el: Element, points: Point[]) { + fireEvent.touchStart(el, { touches: points.map(p => ({ identifier: 1, ...p })) }) +} +function touchMove(points: Point[]) { + return fireEvent.touchMove(document, { touches: points.map(p => ({ identifier: 1, ...p })) }) +} +function touchEnd(type: 'touchend' | 'touchcancel' = 'touchend', remaining: Point[] = []) { + const event = new Event(type, { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'touches', { value: remaining.map(p => ({ identifier: 1, ...p })) }) + document.dispatchEvent(event) +} + +describe('touchDragBridge (#1616)', () => { + let teardown: () => void + let source: HTMLElement + let dropZone: HTMLElement + let outside: HTMLElement + let seen: string[] + + /** Records every drag event that reaches the document. */ + const record = (e: Event) => { seen.push(`${e.type}@${(e.target as HTMLElement).id}`) } + + beforeEach(() => { + vi.useFakeTimers() + document.body.innerHTML = ` +
Place
+
Day 1
Day 2
+
Outside any pane
+ ` + source = document.getElementById('source')! + dropZone = document.getElementById('zone')! + outside = document.getElementById('loose')! + seen = [] + for (const type of ['dragstart', 'dragenter', 'dragover', 'dragleave', 'drop', 'dragend']) { + document.addEventListener(type, record) + } + // jsdom has no layout, so the element under the finger has to be declared. + document.elementFromPoint = () => dropZone + teardown = installTouchDragBridge() + }) + + afterEach(() => { + teardown() + for (const type of ['dragstart', 'dragenter', 'dragover', 'dragleave', 'drop', 'dragend']) { + document.removeEventListener(type, record) + } + vi.useRealTimers() + document.body.innerHTML = '' + }) + + /** Presses on the source long enough for the bridge to claim the gesture. */ + function longPress(el: Element = source) { + touchStart(el, [{ clientX: 10, clientY: 10 }]) + vi.advanceTimersByTime(PRESS) + } + + it('FE-TOUCHDRAG-001: a swipe before the press lands scrolls instead of dragging', () => { + touchStart(source, [{ clientX: 10, clientY: 10 }]) + const notPrevented = touchMove([{ clientX: 10, clientY: 60 }]) + vi.advanceTimersByTime(PRESS) + expect(seen).toEqual([]) + // Not calling preventDefault is what leaves the scroll to the browser (#1432). + expect(notPrevented).toBe(true) + }) + + it('FE-TOUCHDRAG-002: a long press starts the drag and tracks the element under the finger', () => { + longPress() + expect(seen).toContain('dragstart@source') + touchMove([{ clientX: 200, clientY: 200 }]) + expect(seen).toContain('dragenter@zone') + expect(seen).toContain('dragover@zone') + }) + + it('FE-TOUCHDRAG-003: once armed the move is consumed so the pane does not scroll', () => { + longPress() + expect(touchMove([{ clientX: 200, clientY: 200 }])).toBe(false) + }) + + it('FE-TOUCHDRAG-004: releasing over a zone that accepted the drag drops on it', () => { + dropZone.addEventListener('dragover', e => e.preventDefault()) + longPress() + touchMove([{ clientX: 200, clientY: 200 }]) + touchEnd() + expect(seen).toContain('drop@zone') + expect(seen).toContain('dragend@source') + }) + + it('FE-TOUCHDRAG-005: a zone that never accepts the drag gets no drop', () => { + longPress() + touchMove([{ clientX: 200, clientY: 200 }]) + touchEnd() + expect(seen).not.toContain('drop@zone') + expect(seen).toContain('dragleave@zone') + expect(seen).toContain('dragend@source') + }) + + it('FE-TOUCHDRAG-006: leaving a zone for another one hands over enter and leave', () => { + const other = document.getElementById('zone2')! + longPress() + touchMove([{ clientX: 200, clientY: 200 }]) + document.elementFromPoint = () => other + touchMove([{ clientX: 200, clientY: 260 }]) + expect(seen).toContain('dragleave@zone') + expect(seen).toContain('dragenter@zone2') + }) + + it('FE-TOUCHDRAG-007: touchcancel ends the drag without dropping', () => { + dropZone.addEventListener('dragover', e => e.preventDefault()) + longPress() + touchMove([{ clientX: 200, clientY: 200 }]) + touchEnd('touchcancel') + expect(seen).not.toContain('drop@zone') + expect(seen).toContain('dragend@source') + }) + + it('FE-TOUCHDRAG-008: a row outside an opted-in pane is left alone', () => { + longPress(outside) + expect(seen).toEqual([]) + }) + + it('FE-TOUCHDRAG-009: a browser-driven drag stands the bridge down (iPadOS)', () => { + touchStart(source, [{ clientX: 10, clientY: 10 }]) + fireEvent.dragStart(source) + seen = [] + vi.advanceTimersByTime(PRESS) + // Silent: the browser's own sequence is the only one the handlers should see. + expect(seen).toEqual([]) + touchEnd() + expect(seen).toEqual([]) + }) + + it('FE-TOUCHDRAG-010: a second finger cancels the pending press', () => { + touchStart(source, [{ clientX: 10, clientY: 10 }]) + touchStart(source, [{ clientX: 10, clientY: 10 }, { clientX: 90, clientY: 90, identifier: 2 }]) + vi.advanceTimersByTime(PRESS) + expect(seen).toEqual([]) + }) + + it('FE-TOUCHDRAG-011: the long-press context menu is suppressed while a drag is pending', () => { + touchStart(source, [{ clientX: 10, clientY: 10 }]) + expect(fireEvent.contextMenu(source)).toBe(false) + }) + + it('FE-TOUCHDRAG-012: the context menu is left alone when no drag is pending', () => { + expect(fireEvent.contextMenu(source)).toBe(true) + }) + + it('FE-TOUCHDRAG-013: a source that vetoes its own dragstart gets the gesture back', () => { + source.addEventListener('dragstart', e => e.preventDefault()) + longPress() + expect(seen).toContain('dragend@source') + // The veto ends the session, so the next move is the browser's again. + expect(touchMove([{ clientX: 200, clientY: 200 }])).toBe(true) + }) + + it('FE-TOUCHDRAG-014: the click that follows a drag is swallowed', () => { + const onClick = vi.fn() + source.addEventListener('click', onClick) + longPress() + touchEnd() + fireEvent.click(source) + expect(onClick).not.toHaveBeenCalled() + }) + + it('FE-TOUCHDRAG-015: a drag carries data between the source and the drop zone', () => { + let carried = '' + source.addEventListener('dragstart', e => (e as DragEvent).dataTransfer?.setData('placeId', '42')) + dropZone.addEventListener('dragover', e => e.preventDefault()) + dropZone.addEventListener('drop', e => { carried = (e as DragEvent).dataTransfer?.getData('placeId') ?? '' }) + longPress() + touchMove([{ clientX: 200, clientY: 200 }]) + touchEnd() + expect(carried).toBe('42') + }) + + it('FE-TOUCHDRAG-016: teardown stops watching for new gestures', () => { + teardown() + longPress() + expect(seen).toEqual([]) + }) + + it('FE-TOUCHDRAG-017: a drag near the bottom edge scrolls the pane it is over', () => { + const pane = document.getElementById('plan')! + Object.defineProperty(pane, 'scrollHeight', { value: 2000, configurable: true }) + Object.defineProperty(pane, 'clientHeight', { value: 400, configurable: true }) + pane.style.overflowY = 'auto' + pane.getBoundingClientRect = () => ({ top: 0, bottom: 400, left: 0, right: 300, width: 300, height: 400, x: 0, y: 0, toJSON: () => ({}) }) + longPress() + touchMove([{ clientX: 200, clientY: 390 }]) + const before = pane.scrollTop + vi.advanceTimersByTime(64) + expect(pane.scrollTop).toBeGreaterThan(before) + }) + + it('FE-TOUCHDRAG-018: a drag near the top edge scrolls the pane back up', () => { + const pane = document.getElementById('plan')! + Object.defineProperty(pane, 'scrollHeight', { value: 2000, configurable: true }) + Object.defineProperty(pane, 'clientHeight', { value: 400, configurable: true }) + pane.style.overflowY = 'scroll' + pane.getBoundingClientRect = () => ({ top: 0, bottom: 400, left: 0, right: 300, width: 300, height: 400, x: 0, y: 0, toJSON: () => ({}) }) + pane.scrollTop = 500 + longPress() + touchMove([{ clientX: 200, clientY: 10 }]) + vi.advanceTimersByTime(64) + expect(pane.scrollTop).toBeLessThan(500) + }) + + it('FE-TOUCHDRAG-019: staying on the same zone keeps asking it, without re-entering', () => { + longPress() + touchMove([{ clientX: 200, clientY: 200 }]) + const enters = seen.filter(e => e === 'dragenter@zone').length + touchMove([{ clientX: 200, clientY: 210 }]) + expect(seen.filter(e => e === 'dragenter@zone')).toHaveLength(enters) + expect(seen.filter(e => e === 'dragover@zone').length).toBeGreaterThan(1) + }) + + it('FE-TOUCHDRAG-020: dragging over empty space leaves the last zone and finds no target', () => { + longPress() + touchMove([{ clientX: 200, clientY: 200 }]) + document.elementFromPoint = () => null + touchMove([{ clientX: 999, clientY: 999 }]) + expect(seen).toContain('dragleave@zone') + touchEnd() + expect(seen).not.toContain('drop@zone') + }) + + it('FE-TOUCHDRAG-021: another finger moving is not mistaken for the dragging one', () => { + longPress() + const before = seen.length + touchMove([{ clientX: 400, clientY: 400, identifier: 9 }]) + expect(seen).toHaveLength(before) + }) + + it('FE-TOUCHDRAG-022: lifting one of two fingers does not end the drag', () => { + dropZone.addEventListener('dragover', e => e.preventDefault()) + longPress() + touchMove([{ clientX: 200, clientY: 200 }]) + touchEnd('touchend', [{ clientX: 200, clientY: 200 }]) + expect(seen).not.toContain('dragend@source') + touchEnd() + expect(seen).toContain('drop@zone') + }) + + it('FE-TOUCHDRAG-023: the carried data behaves like a DataTransfer', () => { + let types: readonly string[] = [] + let cleared = '' + let missing = 'unset' + source.addEventListener('dragstart', e => { + const dt = (e as DragEvent).dataTransfer! + dt.setData('placeId', '7') + types = dt.types + missing = dt.getData('nothing') + dt.clearData('placeId') + cleared = dt.getData('placeId') + }) + longPress() + expect(types).toEqual(['placeId']) + expect(missing).toBe('') + expect(cleared).toBe('') + }) + + it('FE-TOUCHDRAG-024: clearing every format empties the carried data', () => { + let left: readonly string[] = ['stale'] + source.addEventListener('dragstart', e => { + const dt = (e as DragEvent).dataTransfer! + dt.setData('placeId', '7') + dt.clearData() + left = dt.types + }) + longPress() + expect(left).toEqual([]) + }) + + it('FE-TOUCHDRAG-025: a finger that only jitters still completes the press', () => { + touchStart(source, [{ clientX: 10, clientY: 10 }]) + // Holding a row steady is never perfectly steady; a few pixels is not a scroll. + touchMove([{ clientX: 13, clientY: 14 }]) + vi.advanceTimersByTime(PRESS) + expect(seen).toContain('dragstart@source') + }) + + it('FE-TOUCHDRAG-026: a drag in the middle of a pane leaves the scroll alone', () => { + const pane = document.getElementById('plan')! + Object.defineProperty(pane, 'scrollHeight', { value: 2000, configurable: true }) + Object.defineProperty(pane, 'clientHeight', { value: 400, configurable: true }) + pane.style.overflowY = 'auto' + pane.getBoundingClientRect = () => ({ top: 0, bottom: 400, left: 0, right: 300, width: 300, height: 400, x: 0, y: 0, toJSON: () => ({}) }) + pane.scrollTop = 200 + longPress() + touchMove([{ clientX: 200, clientY: 200 }]) + vi.advanceTimersByTime(64) + expect(pane.scrollTop).toBe(200) + }) + + it('FE-TOUCHDRAG-027: a two-finger gesture never becomes a drag', () => { + touchStart(source, [{ clientX: 10, clientY: 10 }, { clientX: 90, clientY: 90, identifier: 2 }]) + vi.advanceTimersByTime(PRESS) + expect(seen).toEqual([]) + }) + + it('FE-TOUCHDRAG-028: holding at the edge re-tests what the scroll brought under the finger', () => { + const pane = document.getElementById('plan')! + const other = document.getElementById('zone2')! + other.addEventListener('dragover', e => e.preventDefault()) + Object.defineProperty(pane, 'scrollHeight', { value: 2000, configurable: true }) + Object.defineProperty(pane, 'clientHeight', { value: 400, configurable: true }) + pane.style.overflowY = 'auto' + pane.getBoundingClientRect = () => ({ top: 0, bottom: 400, left: 0, right: 300, width: 300, height: 400, x: 0, y: 0, toJSON: () => ({}) }) + longPress() + touchMove([{ clientX: 200, clientY: 390 }]) + // The finger stays put; the scroll is what changes the row beneath it. + document.elementFromPoint = () => other + vi.advanceTimersByTime(32) + touchEnd() + expect(seen).toContain('dragenter@zone2') + expect(seen).toContain('drop@zone2') + }) +}) diff --git a/client/src/utils/touchDragBridge.ts b/client/src/utils/touchDragBridge.ts new file mode 100644 index 000000000..54ea57519 --- /dev/null +++ b/client/src/utils/touchDragBridge.ts @@ -0,0 +1,302 @@ +/** + * Long-press touch drag for the three-column planner (#1616). + * + * Tablets run the desktop planner but have a coarse primary pointer, and a + * finger never starts an HTML5 drag on Android (#1265). The old answer was + * `drag-drop-touch`, a document-wide polyfill that turned every touch over a + * draggable element into a drag: the places list stopped scrolling (#1432) and + * the dblclick it synthesised zoomed the map (#1440). That polyfill is now + * confined to hybrid laptops (see touchDragPolyfill.ts) and drag was switched + * off outright wherever the pointer is coarse — which took tablets with it. + * + * This bridge takes the narrow path instead. It only watches touches that begin + * on a draggable row inside an opted-in `[data-touch-drag]` container, it waits + * out a long press before claiming the gesture — a swipe inside that window is + * a scroll and is left alone — and it synthesises nothing but the drag events + * themselves. Once armed it replays the real sequence (dragstart → + * dragenter/dragover/dragleave → drop → dragend) on the element under the + * finger, so every drop handler already in the planner keeps working unchanged. + * + * If the browser starts a drag of its own first — iPadOS does — the bridge + * stands down the moment it sees a dragstart it did not fire itself. + */ + +/** How long a finger must rest on a row before the gesture becomes a drag. */ +const LONG_PRESS_MS = 320 +/** Moving further than this before the press lands means the user is scrolling. */ +const MOVE_TOLERANCE = 10 +/** Distance from a scroll container's edge at which dragging scrolls it. */ +const EDGE_ZONE = 56 +/** Pixels per frame the edge scroll moves at full strength. */ +const EDGE_SPEED = 14 + +interface Session { + source: HTMLElement + touchId: number + startX: number + startY: number + x: number + y: number + armed: boolean + pressTimer: number + ghost: HTMLElement | null + ghostDX: number + ghostDY: number + target: Element | null + canDrop: boolean + scroller: Element | null + frame: number + transfer: DataTransfer +} + +let session: Session | null = null + +/** Events this module dispatched, so a browser-driven drag stays recognisable. */ +const ours = new WeakSet() + +/** + * A stand-in for the browsers that refuse `new DataTransfer()`. Only the parts + * the planner's handlers reach for are implemented. + */ +function createTransfer(): DataTransfer { + try { + return new DataTransfer() + } catch { + const store = new Map() + return { + dropEffect: 'move', + effectAllowed: 'all', + files: [] as unknown as FileList, + items: [] as unknown as DataTransferItemList, + get types() { return Array.from(store.keys()) }, + setData: (format: string, data: string) => { store.set(format, String(data)) }, + getData: (format: string) => store.get(format) ?? '', + clearData: (format?: string) => { if (format) store.delete(format); else store.clear() }, + setDragImage: () => {}, + } as unknown as DataTransfer + } +} + +/** Dispatches one drag event and reports whether the target accepted it. */ +function fire(type: string, target: EventTarget, s: Session): boolean { + let event: Event + try { + event = new DragEvent(type, { + bubbles: true, cancelable: true, composed: true, + clientX: s.x, clientY: s.y, dataTransfer: s.transfer, + }) + } catch { + event = new Event(type, { bubbles: true, cancelable: true, composed: true }) + } + // Safari builds the event but drops the dataTransfer, and the Event fallback + // never had one. An own property shadows the prototype's read-only getter. + if ((event as DragEvent).dataTransfer !== s.transfer) { + Object.defineProperty(event, 'dataTransfer', { value: s.transfer, configurable: true }) + } + for (const [key, value] of [['clientX', s.x], ['clientY', s.y]] as const) { + if ((event as DragEvent)[key] !== value) { + Object.defineProperty(event, key, { value, configurable: true }) + } + } + ours.add(event) + // preventDefault on dragover is how a drop zone says yes, and dispatchEvent + // reports that as false. + return !target.dispatchEvent(event) +} + +/** The nearest ancestor that actually scrolls vertically, if there is one. */ +function scrollerFor(element: Element | null): Element | null { + for (let node = element; node && node !== document.body; node = node.parentElement) { + const overflow = getComputedStyle(node).overflowY + if ((overflow === 'auto' || overflow === 'scroll') && node.scrollHeight > node.clientHeight) return node + } + return null +} + +function buildGhost(s: Session): void { + const box = s.source.getBoundingClientRect() + const ghost = s.source.cloneNode(true) as HTMLElement + ghost.removeAttribute('id') + ghost.setAttribute('aria-hidden', 'true') + Object.assign(ghost.style, { + position: 'fixed', left: '0', top: '0', margin: '0', + width: `${box.width}px`, height: `${box.height}px`, + pointerEvents: 'none', opacity: '0.92', zIndex: '10000', + borderRadius: '8px', background: 'var(--bg-elevated, var(--bg-panel, #fff))', + boxShadow: '0 10px 28px rgba(0,0,0,0.3)', + transform: `translate3d(${box.left}px, ${box.top}px, 0)`, + }) + s.ghostDX = box.left - s.x + s.ghostDY = box.top - s.y + document.body.appendChild(ghost) + s.ghost = ghost +} + +function moveGhost(s: Session): void { + if (!s.ghost) return + s.ghost.style.transform = `translate3d(${s.x + s.ghostDX}px, ${s.y + s.ghostDY}px, 0)` +} + +/** Keeps a long list reachable by scrolling once the finger nears an edge. */ +function edgeScroll(): void { + const s = session + if (!s || !s.armed) return + s.frame = requestAnimationFrame(edgeScroll) + if (!s.scroller) return + const box = s.scroller.getBoundingClientRect() + const fromTop = s.y - box.top + const fromBottom = box.bottom - s.y + const before = s.scroller.scrollTop + if (fromTop < EDGE_ZONE) { + s.scroller.scrollTop -= EDGE_SPEED * Math.max(0.2, 1 - fromTop / EDGE_ZONE) + } else if (fromBottom < EDGE_ZONE) { + s.scroller.scrollTop += EDGE_SPEED * Math.max(0.2, 1 - fromBottom / EDGE_ZONE) + } + // The rows moved under a finger that did not, so whatever sits beneath it has + // changed. Without this, holding at the edge drops onto a stale row. + if (s.scroller.scrollTop !== before) retarget(s) +} + +function arm(): void { + const s = session + if (!s) return + s.armed = true + buildGhost(s) + // A source that vetoes its own dragstart gets the gesture handed back. + if (fire('dragstart', s.source, s)) { endSession(false); return } + s.frame = requestAnimationFrame(edgeScroll) +} + +/** Reports the element under the finger, with the ghost kept out of the way. */ +function targetAt(s: Session): Element | null { + return document.elementFromPoint(s.x, s.y) +} + +function retarget(s: Session): void { + const next = targetAt(s) + if (next === s.target) { + if (next) s.canDrop = fire('dragover', next, s) + return + } + if (s.target) fire('dragleave', s.target, s) + s.target = next + s.canDrop = false + if (next) { + fire('dragenter', next, s) + s.canDrop = fire('dragover', next, s) + s.scroller = scrollerFor(next) + } +} + +/** Swallows the click the browser sends after a drag so the row is not opened. */ +function swallowNextClick(): void { + const stop = (e: Event) => { e.stopPropagation(); e.preventDefault() } + document.addEventListener('click', stop, { capture: true, once: true }) + setTimeout(() => document.removeEventListener('click', stop, { capture: true }), 400) +} + +/** + * Ends the gesture. `silent` drops the session without replaying the tail of the + * sequence — for when the browser has taken the drag over and its own events are + * the ones the handlers should see. + */ +function endSession(drop: boolean, silent = false): void { + const s = session + if (!s) return + session = null + clearTimeout(s.pressTimer) + cancelAnimationFrame(s.frame) + s.ghost?.remove() + detachGestureListeners() + if (!s.armed || silent) return + if (drop && s.target && s.canDrop) fire('drop', s.target, s) + else if (s.target) fire('dragleave', s.target, s) + fire('dragend', s.source, s) + swallowNextClick() +} + +function onTouchMove(e: TouchEvent): void { + const s = session + if (!s) return + const touch = Array.from(e.touches).find(t => t.identifier === s.touchId) + if (!touch) return + s.x = touch.clientX + s.y = touch.clientY + if (!s.armed) { + if (Math.hypot(s.x - s.startX, s.y - s.startY) > MOVE_TOLERANCE) endSession(false) + return + } + // Armed: the gesture is a drag, so the page must not scroll under it. + e.preventDefault() + moveGhost(s) + retarget(s) +} + +function onTouchEnd(e: TouchEvent): void { + const s = session + if (!s) return + if (Array.from(e.touches).some(t => t.identifier === s.touchId)) return + endSession(e.type === 'touchend') +} + +function onNativeDragStart(e: Event): void { + // iPadOS starts a drag of its own from the same long press. Let it have the + // gesture rather than running two sequences over one finger. + if (!ours.has(e)) endSession(false, true) +} + +function onContextMenu(e: Event): void { + // Android raises this on the same long press that arms the drag. + if (session) e.preventDefault() +} + +function attachGestureListeners(): void { + document.addEventListener('touchmove', onTouchMove, { passive: false }) + document.addEventListener('touchend', onTouchEnd) + document.addEventListener('touchcancel', onTouchEnd) + document.addEventListener('contextmenu', onContextMenu) + document.addEventListener('dragstart', onNativeDragStart, true) +} + +function detachGestureListeners(): void { + document.removeEventListener('touchmove', onTouchMove) + document.removeEventListener('touchend', onTouchEnd) + document.removeEventListener('touchcancel', onTouchEnd) + document.removeEventListener('contextmenu', onContextMenu) + document.removeEventListener('dragstart', onNativeDragStart, true) +} + +function onTouchStart(e: TouchEvent): void { + if (session) { endSession(false); return } + if (e.touches.length !== 1) return + const start = e.target as Element | null + const source = start?.closest?.('[draggable="true"]') as HTMLElement | null + if (!source || !source.closest('[data-touch-drag]')) return + const touch = e.touches[0] + session = { + source, + touchId: touch.identifier, + startX: touch.clientX, startY: touch.clientY, + x: touch.clientX, y: touch.clientY, + armed: false, + pressTimer: window.setTimeout(arm, LONG_PRESS_MS), + ghost: null, ghostDX: 0, ghostDY: 0, + target: null, canDrop: false, + scroller: scrollerFor(source), + frame: 0, + transfer: createTransfer(), + } + attachGestureListeners() +} + +/** + * Starts watching for long-press drags. Returns the teardown, which also ends + * any drag still in flight. + */ +export function installTouchDragBridge(): () => void { + document.addEventListener('touchstart', onTouchStart, { passive: true }) + return () => { + document.removeEventListener('touchstart', onTouchStart) + endSession(false) + } +}