export type RuntimeInputDeviceKind = 'pointer' | 'mocap' | 'keyboard' | 'unknown'; export type RuntimeInputPoint = { clientX: number; clientY: number; normalizedX?: number; normalizedY?: number; }; export type RuntimeDragInputSession = { targetId: TTargetId; inputId: string; deviceKind: RuntimeInputDeviceKind; startPoint: RuntimeInputPoint; currentPoint: RuntimeInputPoint; dragging: boolean; forceDrop: boolean; }; export type RuntimeDragInputPress = { targetId: TTargetId; inputId: string; deviceKind: RuntimeInputDeviceKind; point: RuntimeInputPoint; }; export type RuntimeDragInputMove = { inputId: string; point: RuntimeInputPoint; forceDragging?: boolean; }; export type RuntimeDragInputRelease = { inputId: string; point: RuntimeInputPoint; forceDrop?: boolean; }; export type RuntimeDragInputControllerOptions< TTargetId extends string = string, > = { dragThresholdPx?: number; onPress?: (session: RuntimeDragInputSession) => void; onDragStart?: (session: RuntimeDragInputSession) => void; onDragMove?: (session: RuntimeDragInputSession) => void; onDrop?: (session: RuntimeDragInputSession) => void; onTap?: (session: RuntimeDragInputSession) => void; onCancel?: (session: RuntimeDragInputSession) => void; }; const DEFAULT_DRAG_THRESHOLD_PX = 8; function clonePoint(point: RuntimeInputPoint): RuntimeInputPoint { return { ...point }; } function shouldStartDragging( session: RuntimeDragInputSession, point: RuntimeInputPoint, thresholdPx: number, forceDragging = false, ) { if (session.dragging || forceDragging) { return true; } return ( Math.hypot( point.clientX - session.startPoint.clientX, point.clientY - session.startPoint.clientY, ) >= thresholdPx ); } export function createRuntimeDragInputController< TTargetId extends string = string, >(initialOptions: RuntimeDragInputControllerOptions = {}) { let options = initialOptions; let session: RuntimeDragInputSession | null = null; const setOptions = ( nextOptions: RuntimeDragInputControllerOptions, ) => { options = nextOptions; }; const cancel = (inputId?: string) => { if (inputId && session?.inputId !== inputId) { return; } const activeSession = session; session = null; if (activeSession) { options.onCancel?.(activeSession); } }; const press = (input: RuntimeDragInputPress) => { cancel(); session = { targetId: input.targetId, inputId: input.inputId, deviceKind: input.deviceKind, startPoint: clonePoint(input.point), currentPoint: clonePoint(input.point), dragging: false, forceDrop: false, }; options.onPress?.(session); return session; }; const move = (input: RuntimeDragInputMove) => { if (!session || session.inputId !== input.inputId) { return null; } const wasDragging = session.dragging; session = { ...session, currentPoint: clonePoint(input.point), dragging: shouldStartDragging( session, input.point, options.dragThresholdPx ?? DEFAULT_DRAG_THRESHOLD_PX, input.forceDragging, ), }; if (!wasDragging && session.dragging) { options.onDragStart?.(session); } if (session.dragging) { options.onDragMove?.(session); } return session; }; const release = (input: RuntimeDragInputRelease) => { if (!session || session.inputId !== input.inputId) { return null; } const completedSession = { ...session, currentPoint: clonePoint(input.point), forceDrop: input.forceDrop === true, }; session = null; if (completedSession.dragging || completedSession.forceDrop) { options.onDrop?.(completedSession); } else { options.onTap?.(completedSession); } return completedSession; }; return { cancel, getSession: () => session, move, press, release, setOptions, }; }