feat(planner): add a long-press touch drag bridge

A finger never starts an HTML5 drag on Android, and the answer used to be
drag-drop-touch: a document-wide polyfill that turned every touch over a
draggable element into a drag. That cost the places list its scrolling
(#1432) and the map its one-finger pan (#1440), so it now only loads on
hybrid laptops.

This is the narrow version. It only watches touches that begin on a
draggable row inside a container that opted in, and it waits out a long
press before claiming the gesture, so a swipe is still a swipe. Once armed
it replays the real drag sequence on the element under the finger, which
leaves every existing drop handler working as it is.

Nothing is wired up to it yet.
This commit is contained in:
Maurice
2026-07-29 18:06:12 +02:00
committed by Maurice
parent 98ad2450a2
commit e7ccbcdf86
2 changed files with 629 additions and 0 deletions
+327
View File
@@ -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 = `
<div data-touch-drag id="places"><div id="source" draggable="true">Place</div></div>
<div data-touch-drag id="plan"><div id="zone">Day 1</div><div id="zone2">Day 2</div></div>
<div id="loose" draggable="true">Outside any pane</div>
`
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')
})
})
+302
View File
@@ -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<Event>()
/**
* 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<string, string>()
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)
}
}