mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
/**
|
|
* 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 })
|
|
// The fallback timer can outlive a torn-down jsdom in tests, where `document`
|
|
// is gone by the time it fires — hence the guard.
|
|
setTimeout(() => {
|
|
if (typeof document !== 'undefined') 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)
|
|
}
|
|
}
|