feat(timetable): usePointerDrag + useDragOrClick composables (Session 4 step 9)
usePointerDrag — PointerEvents primitive with capture, escape-cancel, keyboard-cancel, and onBeforeUnmount cleanup. Replaces the legacy mousedown stack the prototype used. useDragOrClick — threshold-based drag/click disambiguation (4px Manhattan, matches prototype audit §4.1). Emits onClick when the pointer never crossed the threshold; otherwise enters drag mode and emits onDragStart / onDragMove / onDragEnd. Installs the one-shot capture-phase click suppressor on drag-end so the synthetic click never opens the popover. RFC v0.2 D7 — implemented once instead of three times like the prototype. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
89
apps/app/src/composables/timetable/useDragOrClick.ts
Normal file
89
apps/app/src/composables/timetable/useDragOrClick.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { type PointerDragState, usePointerDrag } from './usePointerDrag'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Threshold-based "drag-or-click" disambiguation. Listens to a single
|
||||||
|
* `pointerdown`; if the pointer travels < `thresholdPx` before release,
|
||||||
|
* fires `onClick`; otherwise enters drag mode and fires `onDragStart`
|
||||||
|
* once + `onDragMove` per pointermove + `onDragEnd` on release.
|
||||||
|
*
|
||||||
|
* Per RFC v0.2 D7 — replaces the prototype's three duplicated
|
||||||
|
* mousedown stacks (timetable.jsx:544-546, 632-634, 677-679) with one
|
||||||
|
* deterministic primitive + the click-suppression listener that catches
|
||||||
|
* the synthetic click after a drag-mouseup.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface UseDragOrClickOptions {
|
||||||
|
|
||||||
|
/** Manhattan threshold in pixels. Default 4 (matches prototype audit §4.1). */
|
||||||
|
thresholdPx?: number
|
||||||
|
onClick?: (event: PointerEvent) => void
|
||||||
|
onDragStart?: (state: PointerDragState) => void
|
||||||
|
onDragMove?: (state: PointerDragState) => void
|
||||||
|
onDragEnd?: (state: PointerDragState, cancelled: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDragOrClick(options: UseDragOrClickOptions) {
|
||||||
|
const threshold = options.thresholdPx ?? 4
|
||||||
|
const dragMode = ref(false)
|
||||||
|
let pendingClickEvent: PointerEvent | null = null
|
||||||
|
|
||||||
|
const drag = usePointerDrag({
|
||||||
|
onStart: state => {
|
||||||
|
dragMode.value = false
|
||||||
|
pendingClickEvent = state.startEvent
|
||||||
|
},
|
||||||
|
onMove: state => {
|
||||||
|
if (!dragMode.value && (Math.abs(state.deltaX) > threshold || Math.abs(state.deltaY) > threshold)) {
|
||||||
|
dragMode.value = true
|
||||||
|
pendingClickEvent = null
|
||||||
|
options.onDragStart?.(state)
|
||||||
|
}
|
||||||
|
else if (dragMode.value) {
|
||||||
|
options.onDragMove?.(state)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onEnd: (state, cancelled) => {
|
||||||
|
if (dragMode.value) {
|
||||||
|
options.onDragEnd?.(state, cancelled)
|
||||||
|
installClickSuppressor()
|
||||||
|
}
|
||||||
|
else if (!cancelled && pendingClickEvent && options.onClick) {
|
||||||
|
options.onClick(pendingClickEvent)
|
||||||
|
}
|
||||||
|
pendingClickEvent = null
|
||||||
|
dragMode.value = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function begin(event: PointerEvent): void {
|
||||||
|
drag.begin(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel(): void {
|
||||||
|
drag.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browser fires a synthetic click after pointerup that completed a
|
||||||
|
* drag — suppress it once so a drag never opens the popover.
|
||||||
|
*/
|
||||||
|
function installClickSuppressor(): void {
|
||||||
|
const suppress = (event: MouseEvent): void => {
|
||||||
|
event.stopPropagation()
|
||||||
|
event.preventDefault()
|
||||||
|
window.removeEventListener('click', suppress, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('click', suppress, true)
|
||||||
|
setTimeout(() => window.removeEventListener('click', suppress, true), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
begin,
|
||||||
|
cancel,
|
||||||
|
isDragging: drag.isDragging,
|
||||||
|
isDragMode: dragMode,
|
||||||
|
state: drag.state,
|
||||||
|
}
|
||||||
|
}
|
||||||
148
apps/app/src/composables/timetable/usePointerDrag.ts
Normal file
148
apps/app/src/composables/timetable/usePointerDrag.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { type Ref, onBeforeUnmount, ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modern PointerEvents-based drag primitive that replaces legacy
|
||||||
|
* mousedown stacks. Captures the pointer on `pointerdown`, tracks
|
||||||
|
* deltas through `pointermove`, releases on `pointerup`/`pointercancel`.
|
||||||
|
*
|
||||||
|
* Pure mechanics — domain code (lane math, mutation calls) lives in
|
||||||
|
* the page entry / mutation composables.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PointerDragState {
|
||||||
|
pointerId: number
|
||||||
|
startEvent: PointerEvent
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
currentX: number
|
||||||
|
currentY: number
|
||||||
|
deltaX: number
|
||||||
|
deltaY: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePointerDragOptions {
|
||||||
|
|
||||||
|
/** Optional cursor swap during drag. */
|
||||||
|
cursor?: string
|
||||||
|
onStart?: (state: PointerDragState) => void
|
||||||
|
onMove?: (state: PointerDragState) => void
|
||||||
|
onEnd?: (state: PointerDragState, cancelled: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePointerDrag(options: UsePointerDragOptions = {}): {
|
||||||
|
isDragging: Ref<boolean>
|
||||||
|
state: Ref<PointerDragState | null>
|
||||||
|
begin: (event: PointerEvent) => void
|
||||||
|
cancel: () => void
|
||||||
|
} {
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const state = ref<PointerDragState | null>(null)
|
||||||
|
let activeTarget: Element | null = null
|
||||||
|
|
||||||
|
function begin(event: PointerEvent): void {
|
||||||
|
if (isDragging.value)
|
||||||
|
return
|
||||||
|
activeTarget = event.currentTarget as Element | null
|
||||||
|
if (activeTarget && 'setPointerCapture' in activeTarget) {
|
||||||
|
try {
|
||||||
|
(activeTarget as Element & { setPointerCapture: (id: number) => void }).setPointerCapture(event.pointerId)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// some targets disallow capture (e.g. detached nodes); harmless.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.value = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startEvent: event,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
currentX: event.clientX,
|
||||||
|
currentY: event.clientY,
|
||||||
|
deltaX: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
}
|
||||||
|
isDragging.value = true
|
||||||
|
|
||||||
|
if (options.cursor)
|
||||||
|
document.body.style.cursor = options.cursor
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', onPointerMove)
|
||||||
|
window.addEventListener('pointerup', onPointerUp)
|
||||||
|
window.addEventListener('pointercancel', onPointerCancel)
|
||||||
|
window.addEventListener('keydown', onEscape)
|
||||||
|
|
||||||
|
options.onStart?.(state.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(event: PointerEvent): void {
|
||||||
|
if (!state.value || event.pointerId !== state.value.pointerId)
|
||||||
|
return
|
||||||
|
state.value = {
|
||||||
|
...state.value,
|
||||||
|
currentX: event.clientX,
|
||||||
|
currentY: event.clientY,
|
||||||
|
deltaX: event.clientX - state.value.startX,
|
||||||
|
deltaY: event.clientY - state.value.startY,
|
||||||
|
}
|
||||||
|
options.onMove?.(state.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(event: PointerEvent): void {
|
||||||
|
if (!state.value || event.pointerId !== state.value.pointerId)
|
||||||
|
return
|
||||||
|
finish(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerCancel(): void {
|
||||||
|
if (!state.value)
|
||||||
|
return
|
||||||
|
finish(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEscape(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Escape' && isDragging.value)
|
||||||
|
finish(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(cancelled: boolean): void {
|
||||||
|
if (!state.value)
|
||||||
|
return
|
||||||
|
const last = state.value
|
||||||
|
|
||||||
|
options.onEnd?.(last, cancelled)
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel(): void {
|
||||||
|
if (isDragging.value)
|
||||||
|
finish(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup(): void {
|
||||||
|
window.removeEventListener('pointermove', onPointerMove)
|
||||||
|
window.removeEventListener('pointerup', onPointerUp)
|
||||||
|
window.removeEventListener('pointercancel', onPointerCancel)
|
||||||
|
window.removeEventListener('keydown', onEscape)
|
||||||
|
if (options.cursor)
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
if (activeTarget && 'releasePointerCapture' in activeTarget && state.value) {
|
||||||
|
try {
|
||||||
|
(activeTarget as Element & { releasePointerCapture: (id: number) => void }).releasePointerCapture(state.value.pointerId)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// ignore — capture may have been released by the browser already.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeTarget = null
|
||||||
|
state.value = null
|
||||||
|
isDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (isDragging.value)
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
return { isDragging, state, begin, cancel }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user