work
This commit is contained in:
@@ -0,0 +1,425 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user