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 = `
+
+
+ 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)
+ }
+}