import { useCallback, useEffect, useRef, } from 'react'; import { CLICK_ACTIONS, LONG_PRESS_DURATION_MS, POINTER_DRAG_THRESHOLD_SQUARED, STACK_HIT_EPSILON, applyClickPlanImmediately, applyLongPressSelection, createPointerIntent, finalizeClickSelection, withinThreshold, } from './pointerUtils'; import { getPointerPosition, safeInvoke } from '../events.js'; const buildEntryDescriptor = (docId) => ({ type: 'document', id: docId, key: `document:${docId}`, }); export const useDeskPointer = ({ containerRef, items, layoutRef, ensureDocumentSize, activeTagSet, handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel, onEntryPointer, onDocumentStackSelect, onPromoteSelection, onDocumentOpen, selectedDocumentIds, onClearSelection, detailPanelOpen, onCloseDetailPanel, }) => { const pointerIntentRef = useRef(null); const pointerStartRef = useRef({ x: 0, y: 0 }); const pointerMovedRef = useRef(false); const longPressTimerRef = useRef(null); const longPressActiveRef = useRef(false); const resetLongPressState = useCallback(() => { if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); longPressTimerRef.current = null; } longPressActiveRef.current = false; }, []); const resolveStackDocIds = useCallback( (event, targetDocId = null) => { const container = containerRef.current; if (!container || !event) { return []; } const rect = container.getBoundingClientRect(); const pointerCanvasX = event.clientX - rect.left; const pointerCanvasY = event.clientY - rect.top; if (!Number.isFinite(pointerCanvasX) || !Number.isFinite(pointerCanvasY)) { return []; } const candidates = []; items.forEach((doc) => { if (!doc?.id) { return; } const docKey = String(doc.id); const layout = layoutRef.current.get(docKey); if (!layout) { return; } const sizeInfo = ensureDocumentSize(doc); if (!sizeInfo) { return; } const { width, height } = sizeInfo; if (!width || !height) { return; } if (activeTagSet.size) { const docTagKeys = Array.isArray(doc.tags) ? doc.tags.map((tag) => (tag ? tag.id : null)).filter(Boolean) : []; if (!docTagKeys.some((key) => activeTagSet.has(key))) { return; } } const centerX = Number(layout.centerX); const centerY = Number(layout.centerY); if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) { return; } const rotationDeg = Number(layout.rotation) || 0; const rotationRad = (rotationDeg * Math.PI) / 180; const dx = pointerCanvasX - centerX; const dy = pointerCanvasY - centerY; const cosRotation = Math.cos(-rotationRad); const sinRotation = Math.sin(-rotationRad); const localX = dx * cosRotation - dy * sinRotation; const localY = dx * sinRotation + dy * cosRotation; const halfWidth = width / 2; const halfHeight = height / 2; const containsPointer = Math.abs(localX) <= halfWidth + STACK_HIT_EPSILON && Math.abs(localY) <= halfHeight + STACK_HIT_EPSILON; candidates.push({ id: docKey, z: Number.isFinite(layout.z) ? layout.z : 0, centerX, centerY, width, height, halfWidth, halfHeight, containsPointer, }); }); const pointerCandidates = candidates.filter((candidate) => candidate.containsPointer); if (!pointerCandidates.length) { return []; } const sortedByZ = [...pointerCandidates].sort((a, b) => (b.z ?? 0) - (a.z ?? 0)); const targetKey = targetDocId != null ? String(targetDocId) : sortedByZ[0].id; const primary = sortedByZ.find((candidate) => candidate.id === targetKey) || sortedByZ[0]; if (!primary) { return []; } const radius = Math.max(Math.min(primary.halfWidth, primary.halfHeight) * 1.2, 6); const radiusSquared = radius * radius; const selected = candidates .filter((candidate) => { if (!candidate?.id) { return false; } const dx = candidate.centerX - primary.centerX; const dy = candidate.centerY - primary.centerY; return dx * dx + dy * dy <= radiusSquared + 1e-4; }) .sort((a, b) => (b.z ?? 0) - (a.z ?? 0)); if (targetKey) { const targetIndex = selected.findIndex((entry) => entry.id === targetKey); if (targetIndex > 0) { const [targetEntry] = selected.splice(targetIndex, 1); selected.unshift(targetEntry); } } return selected .map((candidate) => candidate.id) .filter((id, index, array) => array.indexOf(id) === index); }, [activeTagSet, containerRef, ensureDocumentSize, items, layoutRef], ); const scheduleLongPress = useCallback( ({ doc, modifierActive, pointerType }) => { if (modifierActive || pointerType !== 'touch') { longPressActiveRef.current = false; return; } longPressActiveRef.current = true; if (typeof window === 'undefined') { return; } longPressTimerRef.current = window.setTimeout(() => { if (!longPressActiveRef.current || pointerMovedRef.current) { resetLongPressState(); return; } const intent = pointerIntentRef.current; if (!intent || intent.docId !== doc.id) { resetLongPressState(); return; } const syntheticEvent = { clientX: pointerStartRef.current.x, clientY: pointerStartRef.current.y, }; const stackHits = resolveStackDocIds(syntheticEvent, doc.id); applyLongPressSelection({ intent, stackDocIds: stackHits, syntheticEvent, onDocumentStackSelect, }); pointerIntentRef.current = intent; resetLongPressState(); }, LONG_PRESS_DURATION_MS); }, [onDocumentStackSelect, resolveStackDocIds, resetLongPressState], ); useEffect(() => () => resetLongPressState(), [resetLongPressState]); const handleCardPointerDown = useCallback( (event, doc) => { if (!doc?.id) { return; } pointerStartRef.current = getPointerPosition(event, { fallbackToPage: false }); pointerMovedRef.current = false; resetLongPressState(); const pointerButton = typeof event.button === 'number' ? event.button : 0; const pointerType = typeof event.pointerType === 'string' ? event.pointerType : ''; const metaKey = (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey; const modifierActive = Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey); const entryDescriptor = buildEntryDescriptor(doc.id); const stackHits = metaKey ? resolveStackDocIds(event, doc.id) : null; const intent = createPointerIntent({ doc, entryDescriptor, selectedDocumentIds, metaKey, pointerButton, pointerType, stackHits, }); if (intent.selectedAtDown) { safeInvoke(onPromoteSelection, doc.id, event); } applyClickPlanImmediately({ intent, event, onEntryPointer, onDocumentStackSelect, }); pointerIntentRef.current = intent; handlePointerDown(event, doc.id, { stackDocIds: intent.stackDocIdsForDrag, stackSelectionApplied: intent.stackSelectionApplied, wasSelected: intent.selectedAtDown, modifierActive, stackReplace: intent.stackReplaceOnDrag, }); scheduleLongPress({ doc, modifierActive, pointerType, }); }, [ handlePointerDown, onPromoteSelection, onEntryPointer, onDocumentStackSelect, resolveStackDocIds, resetLongPressState, scheduleLongPress, selectedDocumentIds, ], ); const handleCardPointerMove = useCallback( (event) => { const start = pointerStartRef.current; const { x, y } = getPointerPosition(event, { fallbackToPage: false }); const dx = x - start.x; const dy = y - start.y; if (!withinThreshold(dx, dy, POINTER_DRAG_THRESHOLD_SQUARED)) { pointerMovedRef.current = true; resetLongPressState(); } handlePointerMove(event); }, [handlePointerMove, resetLongPressState], ); const handleCardPointerUp = useCallback( (event, doc) => { const pointerState = pointerIntentRef.current; const pointerMoved = pointerMovedRef.current; resetLongPressState(); handlePointerUp(event); if (!pointerMoved && pointerState) { finalizeClickSelection({ intent: pointerState, event, onEntryPointer, onDocumentStackSelect, }); if ( pointerState.clickAction === CLICK_ACTIONS.openDetail && !pointerState.longPressTriggered && pointerState.docId === doc.id ) { const expectedButton = typeof pointerState.pointerButton === 'number' ? pointerState.pointerButton : 0; const releasedButton = typeof event.button === 'number' ? event.button : expectedButton; const isPrimaryRelease = expectedButton === 0 && releasedButton === 0; const stillSelected = Array.isArray(selectedDocumentIds) && selectedDocumentIds.includes(doc.id); if (isPrimaryRelease && stillSelected) { const useSelection = pointerState.selectedAtDown && pointerState.selectionCountAtDown > 0; safeInvoke(onDocumentOpen, doc.id, { useSelection }); } } } pointerIntentRef.current = null; pointerMovedRef.current = false; }, [ handlePointerUp, onDocumentOpen, onDocumentStackSelect, onEntryPointer, resetLongPressState, selectedDocumentIds, ], ); const handleCardPointerCancel = useCallback( (event) => { pointerMovedRef.current = false; resetLongPressState(); pointerIntentRef.current = null; handlePointerCancel(event); }, [handlePointerCancel, resetLongPressState], ); const getCardPointerHandlers = useCallback( (doc) => ({ onPointerDown: (event) => handleCardPointerDown(event, doc), onPointerMove: handleCardPointerMove, onPointerUp: (event) => handleCardPointerUp(event, doc), onPointerCancel: handleCardPointerCancel, }), [ handleCardPointerCancel, handleCardPointerDown, handleCardPointerMove, handleCardPointerUp, ], ); const handleShellKeyDown = useCallback( (event) => { if (!event || event.defaultPrevented) { return; } const { key } = event; if (key !== ' ' && key !== 'Space' && key !== 'Spacebar') { return; } const target = event.target; if (target instanceof HTMLElement) { const tagName = target.tagName ? target.tagName.toLowerCase() : ''; if ( target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select' || tagName === 'button' ) { return; } } if (Array.isArray(selectedDocumentIds) && selectedDocumentIds.length > 0) { event.preventDefault(); onClearSelection?.(); return; } if (detailPanelOpen) { event.preventDefault(); safeInvoke(onCloseDetailPanel); } }, [detailPanelOpen, onClearSelection, onCloseDetailPanel, selectedDocumentIds], ); return { getCardPointerHandlers, handleShellKeyDown, }; }; export default useDeskPointer;