Files
papercrate/frontend/src/desktop/pointer/useDeskPointer.js
T
2025-11-06 14:43:43 +01:00

426 lines
12 KiB
JavaScript

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;