import { useCallback, useRef } from 'react'; import { useDesktopContext } from './context'; import { preventAll } from './events'; import { clamp, formatTransform } from './math'; const DRAG_HYSTERESIS_PX = 4; const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX; const useDocumentDrag = () => { const { layoutRef, itemRefs, documentLookup, ensureDocumentSize, resolveBaseMetrics, bringToFront, setDraggingId, syncLayoutSnapshot, canvasSize, openOverlayForDoc, recalcVisibleDocIds, settings, } = useDesktopContext(); const dragStateRef = useRef(null); const { canvasPadding, defaultCanvasWidth, defaultCanvasHeight, debugDrag } = settings; const finishDrag = useCallback( (pointerId) => { const state = dragStateRef.current; if (!state || state.pointerId !== pointerId) { return; } const capturedTarget = state.capturedTarget; if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') { try { capturedTarget.releasePointerCapture(pointerId); } catch (error) { if (debugDrag) { console.warn('[skeuo] releasePointerCapture failed', error); } } } dragStateRef.current = null; setDraggingId((current) => (current === state.docId ? null : current)); syncLayoutSnapshot(); }, [debugDrag, setDraggingId, syncLayoutSnapshot], ); const handlePointerDown = useCallback( (event, docId) => { if (debugDrag) { console.log( '[skeuo] handlePointerDown fired for doc', docId, 'button', event.button, 'pointerType', event.pointerType, 'pointerId', event.pointerId, ); } preventAll(event); const docKey = docId != null ? String(docId) : null; const doc = docKey ? documentLookup.get(docKey) : null; const { width: docWidth, height: docHeight } = ensureDocumentSize(doc); const { baseScale } = resolveBaseMetrics(doc, docWidth, docHeight); const normalizedBaseScale = Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1; const defaultCenterX = canvasPadding + docWidth / 2; const defaultCenterY = canvasPadding + docHeight / 2; bringToFront(docId); const entry = layoutRef.current.get(docId) || null; const centerX = typeof entry?.centerX === 'number' ? entry.centerX : defaultCenterX; const centerY = typeof entry?.centerY === 'number' ? entry.centerY : defaultCenterY; if (entry && (entry.centerX !== centerX || entry.centerY !== centerY)) { layoutRef.current.set(docId, { ...entry, centerX, centerY }); } const capturedTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null; if (capturedTarget && typeof capturedTarget.setPointerCapture === 'function') { try { capturedTarget.setPointerCapture(event.pointerId); } catch (error) { if (debugDrag) { console.warn('[skeuo] setPointerCapture failed', error); } } } dragStateRef.current = { docId, pointerId: event.pointerId, originCenterX: centerX, originCenterY: centerY, startX: event.clientX, startY: event.clientY, rotation: entry?.rotation ?? 0, moved: false, locked: false, width: docWidth, height: docHeight, dragScale: 1, baseScale: normalizedBaseScale, capturedTarget, }; setDraggingId(docId); }, [ bringToFront, canvasPadding, documentLookup, ensureDocumentSize, layoutRef, resolveBaseMetrics, setDraggingId, debugDrag, ], ); const handlePointerMove = useCallback( (event) => { const state = dragStateRef.current; if (!state) { if (debugDrag) { console.log('[skeuo] handlePointerMove: no drag state for pointer', event.pointerId); } return; } if (state.pointerId !== event.pointerId) { if (debugDrag) { console.log( '[skeuo] handlePointerMove: pointer mismatch expected', state.pointerId, 'got', event.pointerId, ); } return; } preventAll(event); if (state.locked) { if (debugDrag) { console.log('[skeuo] handlePointerMove: locked drag for doc', state.docId); } return; } const entry = layoutRef.current.get(state.docId); if (!entry) { return; } const deltaX = event.clientX - state.startX; const deltaY = event.clientY - state.startY; const nextCenterX = state.originCenterX + deltaX; const nextCenterY = state.originCenterY + deltaY; const docWidth = state.width; const docHeight = state.height; const halfWidth = docWidth / 2; const halfHeight = docHeight / 2; const canvasWidth = canvasSize.width || defaultCanvasWidth; const canvasHeight = canvasSize.height || defaultCanvasHeight; const minCenterX = canvasPadding + halfWidth; const maxCenterX = Math.max(minCenterX, canvasWidth - canvasPadding - halfWidth); const minCenterY = canvasPadding + halfHeight; const maxCenterY = Math.max(minCenterY, canvasHeight - canvasPadding - halfHeight); const clampedCenterX = clamp(nextCenterX, minCenterX, maxCenterX); const clampedCenterY = clamp(nextCenterY, minCenterY, maxCenterY); if (!state.moved) { const distanceSquared = deltaX * deltaX + deltaY * deltaY; if (distanceSquared < DRAG_HYSTERESIS_SQUARED) { return; } bringToFront(state.docId); state.moved = true; } const updated = { ...entry, centerX: clampedCenterX, centerY: clampedCenterY }; layoutRef.current.set(state.docId, updated); const node = itemRefs.current.get(state.docId); if (node) { node.style.transform = formatTransform( clampedCenterX - state.width / 2, clampedCenterY - state.height / 2, state.rotation, state.dragScale || 1, ); } if (debugDrag) { console.log('[skeuo] handlePointerMove: moved doc', state.docId, 'to', clampedCenterX, clampedCenterY); } recalcVisibleDocIds(); }, [ bringToFront, canvasPadding, canvasSize.height, canvasSize.width, defaultCanvasHeight, defaultCanvasWidth, itemRefs, layoutRef, recalcVisibleDocIds, debugDrag, ], ); const handlePointerUp = useCallback( (event) => { const state = dragStateRef.current; if (state && state.pointerId === event.pointerId) { if (state.moved) { finishDrag(event.pointerId); return; } const docId = state.docId; bringToFront(docId); const originInfo = { rotation: state.rotation || 0, scale: state.baseScale || 1, width: state.width, height: state.height, }; finishDrag(event.pointerId); openOverlayForDoc(docId, originInfo); return; } finishDrag(event.pointerId); }, [bringToFront, finishDrag, openOverlayForDoc], ); const handlePointerCancel = useCallback( (event) => { finishDrag(event.pointerId); }, [finishDrag], ); return { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel, }; }; export default useDocumentDrag;