RFC-TIMETABLE v0.2 Session 4 — Frontend Timetable + Test Coverage Closure #18
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