diff --git a/apps/app/src/composables/timetable/useDragOrClick.ts b/apps/app/src/composables/timetable/useDragOrClick.ts new file mode 100644 index 00000000..7f495fef --- /dev/null +++ b/apps/app/src/composables/timetable/useDragOrClick.ts @@ -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, + } +} diff --git a/apps/app/src/composables/timetable/usePointerDrag.ts b/apps/app/src/composables/timetable/usePointerDrag.ts new file mode 100644 index 00000000..92e302d6 --- /dev/null +++ b/apps/app/src/composables/timetable/usePointerDrag.ts @@ -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 + state: Ref + begin: (event: PointerEvent) => void + cancel: () => void +} { + const isDragging = ref(false) + const state = ref(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 } +}