426 lines
12 KiB
JavaScript
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;
|