This commit is contained in:
2025-11-04 00:36:44 +01:00
parent 9ad704f50a
commit 01fd774b18
8 changed files with 551 additions and 267 deletions
+80 -19
View File
@@ -38,6 +38,7 @@ const CARD_MIN = 240;
const CARD_MAX = 340; const CARD_MAX = 340;
const TAG_REMOVE_DISTANCE = 160; const TAG_REMOVE_DISTANCE = 160;
const STACK_HIT_EPSILON = 4; const STACK_HIT_EPSILON = 4;
const POINTER_DRAG_THRESHOLD_SQUARED = 16;
const DEBUG_DRAG = false; const DEBUG_DRAG = false;
const DEBUG_FOCUS = true; const DEBUG_FOCUS = true;
@@ -572,8 +573,9 @@ const DesktopWorkspace = ({
searchResults = null, searchResults = null,
onDocumentOpen, onDocumentOpen,
onInspectDocument = null, onInspectDocument = null,
onDocumentPointerSelect = null, onEntryPointer = null,
onDocumentStackSelect = null, onDocumentStackSelect = null,
onPromoteSelection = null,
onAssignTagToDocument = null, onAssignTagToDocument = null,
onRemoveTagFromDocument = null, onRemoveTagFromDocument = null,
ensureAssetUrl = null, ensureAssetUrl = null,
@@ -1832,8 +1834,9 @@ const recalcVisibleDocIds = useCallback(() => {
pendingRemovalTag, pendingRemovalTag,
onDocumentOpen, onDocumentOpen,
onInspectDocument, onInspectDocument,
onDocumentPointerSelect, onEntryPointer,
onDocumentStackSelect, onDocumentStackSelect,
onPromoteSelection,
ensureAssetUrl, ensureAssetUrl,
getDocumentAsset, getDocumentAsset,
handleNavigatorSnapshot, handleNavigatorSnapshot,
@@ -1885,7 +1888,7 @@ const recalcVisibleDocIds = useCallback(() => {
docSizeVersion, docSizeVersion,
onDocumentOpen, onDocumentOpen,
onInspectDocument, onInspectDocument,
onDocumentPointerSelect, onEntryPointer,
openOverlayForDoc, openOverlayForDoc,
overlayDisplay, overlayDisplay,
overlayOriginRect, overlayOriginRect,
@@ -1902,6 +1905,7 @@ const recalcVisibleDocIds = useCallback(() => {
selectedDocumentIds, selectedDocumentIds,
onClearSelection, onClearSelection,
onDocumentStackSelect, onDocumentStackSelect,
onPromoteSelection,
markLayoutDirty, markLayoutDirty,
detailPanelOpen, detailPanelOpen,
onCloseDetailPanel, onCloseDetailPanel,
@@ -1949,8 +1953,9 @@ const DesktopWorkspaceView = () => {
closeOverlay, closeOverlay,
overlayOriginRect, overlayOriginRect,
overlayOriginTransform, overlayOriginTransform,
onDocumentPointerSelect, onEntryPointer,
onDocumentStackSelect, onDocumentStackSelect,
onPromoteSelection,
selectedDocumentIds, selectedDocumentIds,
onClearSelection, onClearSelection,
detailPanelOpen, detailPanelOpen,
@@ -1960,6 +1965,10 @@ const DesktopWorkspaceView = () => {
const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } =
useDocumentDrag(); useDocumentDrag();
const deferredSelectionRef = useRef(null);
const pointerStartRef = useRef({ x: 0, y: 0 });
const pointerMovedRef = useRef(false);
const resolveStackDocIds = useCallback( const resolveStackDocIds = useCallback(
(event, targetDocId = null) => { (event, targetDocId = null) => {
const container = containerRef.current; const container = containerRef.current;
@@ -2245,6 +2254,11 @@ const DesktopWorkspaceView = () => {
} }
}} }}
onPointerDown={(event) => { onPointerDown={(event) => {
pointerStartRef.current = {
x: Number.isFinite(event.clientX) ? event.clientX : 0,
y: Number.isFinite(event.clientY) ? event.clientY : 0,
};
pointerMovedRef.current = false;
const alreadySelected = selectedDocumentIds.includes(doc.id); const alreadySelected = selectedDocumentIds.includes(doc.id);
const metaOrCtrlOnly = const metaOrCtrlOnly =
(event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey; (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey;
@@ -2260,23 +2274,46 @@ const DesktopWorkspaceView = () => {
if (hasStack && alreadySelected && typeof onDocumentStackSelect === 'function') { if (hasStack && alreadySelected && typeof onDocumentStackSelect === 'function') {
onDocumentStackSelect(hits, event); onDocumentStackSelect(hits, event);
appliedStackSelection = true; appliedStackSelection = true;
deferredSelectionRef.current = null;
} }
} }
} }
const shouldInvokePointerSelect = if (alreadySelected && typeof onPromoteSelection === 'function') {
typeof onDocumentPointerSelect === 'function' onPromoteSelection(doc.id, event);
&& ( }
!metaOrCtrlOnly
|| !alreadySelected
|| event.shiftKey
|| event.altKey
|| !stackDocIds
|| stackDocIds.length <= 1
);
if (shouldInvokePointerSelect) { const modifierActive =
onDocumentPointerSelect(doc.id, event); Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey);
const deferSelection =
!modifierActive
&& alreadySelected
&& Array.isArray(selectedDocumentIds)
&& selectedDocumentIds.length > 1;
const skipPointerSelection =
appliedStackSelection
|| (metaOrCtrlOnly
&& alreadySelected
&& Array.isArray(stackDocIds)
&& stackDocIds.length > 1);
if (skipPointerSelection) {
deferredSelectionRef.current = null;
} else if (deferSelection) {
deferredSelectionRef.current = {
type: 'document',
id: doc.id,
key: `document:${doc.id}`,
};
} else {
deferredSelectionRef.current = null;
if (typeof onEntryPointer === 'function') {
onEntryPointer(
{ type: 'document', id: doc.id, key: `document:${doc.id}` },
event,
);
}
} }
handlePointerDown(event, doc.id, { handlePointerDown(event, doc.id, {
@@ -2284,9 +2321,33 @@ const DesktopWorkspaceView = () => {
stackSelectionApplied: appliedStackSelection, stackSelectionApplied: appliedStackSelection,
}); });
}} }}
onPointerMove={handlePointerMove} onPointerMove={(event) => {
onPointerUp={handlePointerUp} const start = pointerStartRef.current;
onPointerCancel={handlePointerCancel} const dx = Number.isFinite(event.clientX) ? event.clientX - start.x : 0;
const dy = Number.isFinite(event.clientY) ? event.clientY - start.y : 0;
if (dx * dx + dy * dy > POINTER_DRAG_THRESHOLD_SQUARED) {
pointerMovedRef.current = true;
}
handlePointerMove(event);
}}
onPointerUp={(event) => {
const deferredEntry = deferredSelectionRef.current;
const pointerMoved = pointerMovedRef.current;
handlePointerUp(event);
if (!pointerMoved && deferredEntry && typeof onEntryPointer === 'function') {
onEntryPointer(deferredEntry, event);
}
deferredSelectionRef.current = null;
pointerMovedRef.current = false;
}}
onPointerCancel={(event) => {
deferredSelectionRef.current = null;
pointerMovedRef.current = false;
handlePointerCancel(event);
}}
onDragEnter={(event) => handleTagDragEnterDoc(event, doc.id)} onDragEnter={(event) => handleTagDragEnterDoc(event, doc.id)}
onDragOver={(event) => handleTagDragOverDoc(event, doc.id)} onDragOver={(event) => handleTagDragOverDoc(event, doc.id)}
onDragLeave={(event) => handleTagDragLeaveDoc(event, doc.id)} onDragLeave={(event) => handleTagDragLeaveDoc(event, doc.id)}
+42 -23
View File
@@ -21,6 +21,7 @@ import { useManagementModals } from './useManagementModals';
import { api, useAppDispatch, useAppState } from './appState'; import { api, useAppDispatch, useAppState } from './appState';
import { useDetailPanel } from './useDetailPanel'; import { useDetailPanel } from './useDetailPanel';
import { useDocumentSelection } from './useDocumentSelection'; import { useDocumentSelection } from './useDocumentSelection';
import { useEntryPointerHandler } from '../documents/useEntryPointer';
import { isTagTransferEvent } from '../documents/tagTransfer'; import { isTagTransferEvent } from '../documents/tagTransfer';
const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early
@@ -4383,6 +4384,29 @@ const AppLayout = () => {
close: closeDetailPanel, close: closeDetailPanel,
}; };
const handleEntryPointer = useEntryPointerHandler({
resolveDocumentRowKey,
resolveFolderRowKey,
onSelectDocument: (documentId, event, { modifierClick, primaryClick, rowKey }) => {
const key = rowKey || resolveDocumentRowKey(documentId);
if (key) {
handleEntrySelection(key, event);
}
if (!modifierClick && primaryClick) {
openDetailPanel({ documentIds: [documentId] });
}
},
onSelectFolder: (folderId, event, { modifierClick, primaryClick, rowKey }) => {
const key = rowKey || resolveFolderRowKey(folderId);
if (key) {
handleEntrySelection(key, event);
}
if (!modifierClick && primaryClick) {
selectFolder(folderId);
}
},
});
const { breadcrumbs, missingBreadcrumbAncestors } = useMemo(() => { const { breadcrumbs, missingBreadcrumbAncestors } = useMemo(() => {
const chain = []; const chain = [];
const seen = new Set(); const seen = new Set();
@@ -4715,9 +4739,8 @@ const AppLayout = () => {
viewMode: documentsViewMode, viewMode: documentsViewMode,
onViewModeChange: handleDocumentsViewModeChange, onViewModeChange: handleDocumentsViewModeChange,
onClearSelection: clearDocumentSelection, onClearSelection: clearDocumentSelection,
onEntrySelection: handleEntrySelection,
onDeleteSelection: handleDeleteSelection, onDeleteSelection: handleDeleteSelection,
onOpenDetailPanel: openDetailPanel, onEntryPointer: handleEntryPointer,
tags, tags,
correspondents, correspondents,
documentLookup, documentLookup,
@@ -4749,10 +4772,8 @@ const AppLayout = () => {
handleFolderDragEnd, handleFolderDragEnd,
handleFolderDragStart, handleFolderDragStart,
handleFolderRename, handleFolderRename,
handleEntrySelection,
handleDeleteSelection, handleDeleteSelection,
isFilterActive, isFilterActive,
openDetailPanel,
openDocumentPreview, openDocumentPreview,
refreshCurrentFolder, refreshCurrentFolder,
searchLoading, searchLoading,
@@ -4764,6 +4785,7 @@ const AppLayout = () => {
tagLookupById, tagLookupById,
toggleCorrespondentFilter, toggleCorrespondentFilter,
toggleTagFilter, toggleTagFilter,
handleEntryPointer,
ensureAssetUrl, ensureAssetUrl,
getDocumentAsset, getDocumentAsset,
tags, tags,
@@ -4928,20 +4950,6 @@ const AppLayout = () => {
[applySelection, openDetailPanel], [applySelection, openDetailPanel],
); );
const handleDeskDocumentPointerSelect = useCallback(
(docId, event) => {
if (!docId) {
return;
}
const rowKey = resolveDocumentRowKey(docId);
if (!rowKey) {
return;
}
handleEntrySelection(rowKey, event);
},
[handleEntrySelection],
);
const handleDeskDocumentStackSelect = useCallback( const handleDeskDocumentStackSelect = useCallback(
(docIds) => { (docIds) => {
if (!Array.isArray(docIds) || docIds.length === 0) { if (!Array.isArray(docIds) || docIds.length === 0) {
@@ -4956,12 +4964,21 @@ const AppLayout = () => {
return; return;
} }
applySelection(rowKeys, { const nextKeys = [...selectedEntries];
anchor: rowKeys[0], rowKeys.forEach((key) => {
if (!nextKeys.includes(key)) {
nextKeys.push(key);
}
});
const anchor = rowKeys[0] || selectionAnchorRef.current || nextKeys[nextKeys.length - 1];
applySelection(nextKeys, {
anchor,
interactedKeys: rowKeys, interactedKeys: rowKeys,
}); });
}, },
[applySelection], [applySelection, selectedEntries, selectionAnchorRef],
); );
const handleDeskHelpOpen = useCallback(() => { const handleDeskHelpOpen = useCallback(() => {
@@ -5001,8 +5018,9 @@ const AppLayout = () => {
onRefresh: refreshCurrentFolder, onRefresh: refreshCurrentFolder,
onDocumentOpen: openDocumentPreview, onDocumentOpen: openDocumentPreview,
onInspectDocument: handleDeskInspectDocument, onInspectDocument: handleDeskInspectDocument,
onDocumentPointerSelect: handleDeskDocumentPointerSelect, onEntryPointer: handleEntryPointer,
onDocumentStackSelect: handleDeskDocumentStackSelect, onDocumentStackSelect: handleDeskDocumentStackSelect,
onPromoteSelection: promoteSelectionOrder,
onOpenHelp: handleDeskHelpOpen, onOpenHelp: handleDeskHelpOpen,
helpOpen: deskHelpOpen, helpOpen: deskHelpOpen,
onHelpClose: handleDeskHelpClose, onHelpClose: handleDeskHelpClose,
@@ -5041,8 +5059,9 @@ const AppLayout = () => {
refreshCurrentFolder, refreshCurrentFolder,
openDocumentPreview, openDocumentPreview,
handleDeskInspectDocument, handleDeskInspectDocument,
handleDeskDocumentPointerSelect,
handleDeskDocumentStackSelect, handleDeskDocumentStackSelect,
handleEntryPointer,
promoteSelectionOrder,
handleDeskHelpOpen, handleDeskHelpOpen,
handleDeskHelpClose, handleDeskHelpClose,
currentTenantId, currentTenantId,
+202
View File
@@ -0,0 +1,202 @@
import { clamp, formatTransform } from './math';
export const MIN_TIMESTEP = 1 / 120;
export const MAX_TIMESTEP = 1 / 20;
export const MAX_DYNAMIC_ROTATION = 4;
export const MAX_ANGULAR_VELOCITY = 180;
export const ANGULAR_DAMPING = 11;
export const TORQUE_TO_ACCELERATION = 0.006;
export const SETTLE_ANGULAR_VELOCITY = 1.2;
const callRef = (ref) => {
const handler = ref?.current;
if (typeof handler === 'function') {
handler();
}
};
export const createDragPhysics = ({
layoutRef,
itemRefs,
markLayoutDirtyRef,
syncLayoutSnapshotRef,
}) => {
const inertiaAnimations = new Map();
const applyTransform = (docId, centerX, centerY, width, height, rotation, scale = 1) => {
const node = itemRefs.current.get(docId);
if (!node) {
return;
}
node.style.transform = formatTransform(
centerX - width / 2,
centerY - height / 2,
rotation,
scale,
);
};
const finalizeGroupDrag = (dragState) => {
if (!dragState?.groupItems) {
return;
}
dragState.groupItems.forEach((item) => {
if (!item) {
return;
}
const entryItem = layoutRef.current.get(item.docId) || {};
const centerX = item.currentCenterX ?? entryItem.centerX ?? dragState.originCenterX;
const centerY = item.currentCenterY ?? entryItem.centerY ?? dragState.originCenterY;
const rotation = item.displayRotation ?? entryItem.rotation ?? 0;
layoutRef.current.set(item.docId, {
...entryItem,
centerX,
centerY,
rotation,
});
applyTransform(
item.docId,
centerX,
centerY,
item.width,
item.height,
rotation,
item.docId === dragState.docKey ? dragState.dragScale || 1 : 1,
);
});
callRef(markLayoutDirtyRef);
};
const cancelInertiaAnimation = (docId) => {
if (typeof window === 'undefined') {
inertiaAnimations.delete(docId);
return;
}
const existing = inertiaAnimations.get(docId);
if (existing && typeof window.cancelAnimationFrame === 'function') {
window.cancelAnimationFrame(existing.frameId);
}
inertiaAnimations.delete(docId);
};
const integrateRotation = (simulationState, dt, torque = 0, dampingOverride = null) => {
const { docId } = simulationState;
const entry = layoutRef.current.get(docId);
if (!entry) {
return true;
}
const centerX = Number(entry.centerX);
const centerY = Number(entry.centerY);
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
return true;
}
const torqueAcceleration = torque * TORQUE_TO_ACCELERATION;
let angularVelocity = simulationState.angularVelocity + torqueAcceleration * dt;
angularVelocity = clamp(angularVelocity, -MAX_ANGULAR_VELOCITY, MAX_ANGULAR_VELOCITY);
const dampingConstant = typeof dampingOverride === 'number' ? dampingOverride : ANGULAR_DAMPING;
const dampingFactor = Math.exp(-dampingConstant * dt);
angularVelocity *= dampingFactor;
let dynamicRotation = simulationState.dynamicRotation + angularVelocity * dt;
if (dynamicRotation > MAX_DYNAMIC_ROTATION) {
dynamicRotation = MAX_DYNAMIC_ROTATION;
angularVelocity = Math.min(angularVelocity, 0);
} else if (dynamicRotation < -MAX_DYNAMIC_ROTATION) {
dynamicRotation = -MAX_DYNAMIC_ROTATION;
angularVelocity = Math.max(angularVelocity, 0);
}
simulationState.angularVelocity = angularVelocity;
simulationState.dynamicRotation = dynamicRotation;
simulationState.rotation = simulationState.restRotation + dynamicRotation;
const rotation = simulationState.rotation;
layoutRef.current.set(docId, { ...entry, rotation });
callRef(markLayoutDirtyRef);
const node = itemRefs.current.get(docId);
if (node) {
node.style.transform = formatTransform(
centerX - simulationState.width / 2,
centerY - simulationState.height / 2,
rotation,
simulationState.dragScale || 1,
);
}
const isSettled = Math.abs(angularVelocity) < SETTLE_ANGULAR_VELOCITY;
return isSettled;
};
const startInertiaAnimation = (docId, baseState) => {
if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') {
return;
}
cancelInertiaAnimation(docId);
const now =
typeof performance !== 'undefined' && typeof performance.now === 'function'
? performance.now()
: Date.now();
const simulationState = {
...baseState,
docId,
dragScale: baseState.dragScale || 1,
lastTimestamp: now,
};
const step = (timestamp) => {
const safeTimestamp = Number.isFinite(timestamp) ? timestamp : now + 16;
const previous = simulationState.lastTimestamp;
let dt = (safeTimestamp - previous) / 1000;
if (!Number.isFinite(dt) || dt <= 0) {
dt = MIN_TIMESTEP;
}
dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP);
simulationState.lastTimestamp = safeTimestamp;
const settled = integrateRotation(simulationState, dt, 0);
if (settled) {
inertiaAnimations.delete(docId);
callRef(syncLayoutSnapshotRef);
return;
}
simulationState.frameId = window.requestAnimationFrame(step);
};
simulationState.frameId = window.requestAnimationFrame(step);
inertiaAnimations.set(docId, simulationState);
};
const dispose = () => {
if (typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
inertiaAnimations.forEach((animation) => {
if (animation?.frameId != null) {
window.cancelAnimationFrame(animation.frameId);
}
});
}
inertiaAnimations.clear();
};
return {
applyTransform,
finalizeGroupDrag,
cancelInertiaAnimation,
integrateRotation,
startInertiaAnimation,
dispose,
};
};
export default createDragPhysics;
+42 -168
View File
@@ -1,18 +1,12 @@
import { useCallback, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { useDesktopContext } from './context'; import { useDesktopContext } from './context';
import { preventAll } from './events'; import { preventAll } from './events';
import { clamp, formatTransform } from './math'; import { clamp, formatTransform } from './math';
import usePointerTap from '../ui/usePointerTap'; import usePointerTap from '../ui/usePointerTap';
import createDragPhysics, { MIN_TIMESTEP, MAX_TIMESTEP } from './dragPhysics';
const DRAG_HYSTERESIS_PX = 4; const DRAG_HYSTERESIS_PX = 4;
const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX; const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
const MIN_TIMESTEP = 1 / 120;
const MAX_TIMESTEP = 1 / 20;
const MAX_DYNAMIC_ROTATION = 4;
const MAX_ANGULAR_VELOCITY = 180;
const ANGULAR_DAMPING = 11;
const TORQUE_TO_ACCELERATION = 0.006;
const SETTLE_ANGULAR_VELOCITY = 1.2;
const EDGE_COLLISION_THRESHOLD = 0.5; const EDGE_COLLISION_THRESHOLD = 0.5;
const useDocumentDrag = () => { const useDocumentDrag = () => {
@@ -37,61 +31,40 @@ const useDocumentDrag = () => {
markLayoutDirty, markLayoutDirty,
} = useDesktopContext(); } = useDesktopContext();
const applyTransform = useCallback( const markLayoutDirtyRef = useRef(markLayoutDirty);
(docId, centerX, centerY, width, height, rotation, scale = 1) => { useEffect(() => {
const node = itemRefs.current.get(docId); markLayoutDirtyRef.current = markLayoutDirty;
if (!node) { }, [markLayoutDirty]);
return;
}
node.style.transform = formatTransform(
centerX - width / 2,
centerY - height / 2,
rotation,
scale,
);
},
[itemRefs],
);
const finalizeGroupDrag = useCallback( const syncLayoutSnapshotRef = useRef(syncLayoutSnapshot);
(dragState) => { useEffect(() => {
if (!dragState?.groupItems) { syncLayoutSnapshotRef.current = syncLayoutSnapshot;
return; }, [syncLayoutSnapshot]);
}
dragState.groupItems.forEach((item) => { const physicsRef = useRef(null);
if (!item) { if (!physicsRef.current) {
return; physicsRef.current = createDragPhysics({
} layoutRef,
itemRefs,
const entryItem = layoutRef.current.get(item.docId) || {}; markLayoutDirtyRef,
const centerX = item.currentCenterX ?? entryItem.centerX ?? dragState.originCenterX; syncLayoutSnapshotRef,
const centerY = item.currentCenterY ?? entryItem.centerY ?? dragState.originCenterY;
const rotation = item.displayRotation ?? entryItem.rotation ?? 0;
layoutRef.current.set(item.docId, {
...entryItem,
centerX,
centerY,
rotation,
});
applyTransform(
item.docId,
centerX,
centerY,
item.width,
item.height,
rotation,
item.docId === dragState.docKey ? dragState.dragScale || 1 : 1,
);
}); });
}
markLayoutDirty?.(); useEffect(
}, () => () => {
[applyTransform, layoutRef, markLayoutDirty], physicsRef.current?.dispose?.();
},
[],
); );
const {
applyTransform,
finalizeGroupDrag,
cancelInertiaAnimation,
startInertiaAnimation,
} = physicsRef.current;
const tapHandler = usePointerTap({ const tapHandler = usePointerTap({
delay: 220, delay: 220,
onSingle: ({ data, event }) => { onSingle: ({ data, event }) => {
@@ -119,118 +92,8 @@ const useDocumentDrag = () => {
}); });
const dragStateRef = useRef(null); const dragStateRef = useRef(null);
const inertiaAnimationsRef = useRef(new Map());
const { canvasPadding, defaultCanvasWidth, defaultCanvasHeight, debugDrag } = settings; const { canvasPadding, defaultCanvasWidth, defaultCanvasHeight, debugDrag } = settings;
const cancelInertiaAnimation = useCallback((docId) => {
if (typeof window === 'undefined') {
inertiaAnimationsRef.current.delete(docId);
return;
}
const existing = inertiaAnimationsRef.current.get(docId);
if (existing && typeof window.cancelAnimationFrame === 'function') {
window.cancelAnimationFrame(existing.frameId);
}
inertiaAnimationsRef.current.delete(docId);
}, []);
const integrateRotation = useCallback(
(simulationState, dt, torque = 0, dampingOverride = null) => {
const { docId } = simulationState;
const entry = layoutRef.current.get(docId);
if (!entry) {
return true;
}
const centerX = Number(entry.centerX);
const centerY = Number(entry.centerY);
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
return true;
}
const torqueAcceleration = torque * TORQUE_TO_ACCELERATION;
let angularVelocity = simulationState.angularVelocity + torqueAcceleration * dt;
angularVelocity = clamp(angularVelocity, -MAX_ANGULAR_VELOCITY, MAX_ANGULAR_VELOCITY);
const dampingConstant = typeof dampingOverride === 'number' ? dampingOverride : ANGULAR_DAMPING;
const dampingFactor = Math.exp(-dampingConstant * dt);
angularVelocity *= dampingFactor;
let dynamicRotation = simulationState.dynamicRotation + angularVelocity * dt;
if (dynamicRotation > MAX_DYNAMIC_ROTATION) {
dynamicRotation = MAX_DYNAMIC_ROTATION;
angularVelocity = Math.min(angularVelocity, 0);
} else if (dynamicRotation < -MAX_DYNAMIC_ROTATION) {
dynamicRotation = -MAX_DYNAMIC_ROTATION;
angularVelocity = Math.max(angularVelocity, 0);
}
simulationState.angularVelocity = angularVelocity;
simulationState.dynamicRotation = dynamicRotation;
simulationState.rotation = simulationState.restRotation + dynamicRotation;
const rotation = simulationState.rotation;
layoutRef.current.set(docId, { ...entry, rotation });
markLayoutDirty?.();
const node = itemRefs.current.get(docId);
if (node) {
node.style.transform = formatTransform(
centerX - simulationState.width / 2,
centerY - simulationState.height / 2,
rotation,
simulationState.dragScale || 1,
);
}
const isSettled = Math.abs(angularVelocity) < SETTLE_ANGULAR_VELOCITY;
return isSettled;
},
[itemRefs, layoutRef, markLayoutDirty],
);
const startInertiaAnimation = useCallback(
(docId, baseState) => {
if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') {
return;
}
cancelInertiaAnimation(docId);
const now =
typeof performance !== 'undefined' && typeof performance.now === 'function'
? performance.now()
: Date.now();
const simulationState = {
...baseState,
docId,
dragScale: baseState.dragScale || 1,
lastTimestamp: now,
};
const step = (timestamp) => {
const safeTimestamp = Number.isFinite(timestamp) ? timestamp : now + 16;
const previous = simulationState.lastTimestamp;
let dt = (safeTimestamp - previous) / 1000;
if (!Number.isFinite(dt) || dt <= 0) {
dt = MIN_TIMESTEP;
}
dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP);
simulationState.lastTimestamp = safeTimestamp;
const settled = integrateRotation(simulationState, dt, 0);
if (settled) {
inertiaAnimationsRef.current.delete(docId);
syncLayoutSnapshot();
return;
}
simulationState.frameId = window.requestAnimationFrame(step);
};
simulationState.frameId = window.requestAnimationFrame(step);
inertiaAnimationsRef.current.set(docId, simulationState);
},
[cancelInertiaAnimation, integrateRotation, syncLayoutSnapshot],
);
const finishDrag = useCallback( const finishDrag = useCallback(
(pointerId) => { (pointerId) => {
const state = dragStateRef.current; const state = dragStateRef.current;
@@ -346,7 +209,18 @@ const useDocumentDrag = () => {
const modifierPressed = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; const modifierPressed = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
if (!modifierPressed) { if (!modifierPressed) {
if (isGroupDrag) { if (isGroupDrag) {
groupDocIds.forEach((id) => bringToFront(id)); const layout = layoutRef.current;
const ordered = [...groupDocIds]
.filter((id, index, array) => array.indexOf(id) === index)
.sort((a, b) => {
const aZ = layout.get(a)?.z ?? 0;
const bZ = layout.get(b)?.z ?? 0;
return aZ - bZ;
});
ordered.forEach((id) => {
bringToFront(id === docKey ? docId : id);
});
} else { } else {
bringToFront(docId); bringToFront(docId);
} }
+2 -2
View File
@@ -21,7 +21,7 @@ const DocumentsGrid = ({
onFolderDragStart, onFolderDragStart,
onFolderDragEnd, onFolderDragEnd,
onDocumentClick, onDocumentClick,
onDocumentOpen, onDocumentActivate,
onDocumentDragStart, onDocumentDragStart,
onDocumentDragEnd, onDocumentDragEnd,
onDocumentTagDragOver, onDocumentTagDragOver,
@@ -244,7 +244,7 @@ const DocumentsGrid = ({
id={`document-card-${doc.id}`} id={`document-card-${doc.id}`}
data-doc-id={doc.id} data-doc-id={doc.id}
onClick={(event) => onDocumentClick?.(doc, event)} onClick={(event) => onDocumentClick?.(doc, event)}
onDoubleClick={() => onDocumentOpen?.(doc.id)} onDoubleClick={(event) => onDocumentActivate?.(doc, event)}
draggable draggable
onDragStart={(event) => onDocumentDragStart?.(event, doc)} onDragStart={(event) => onDocumentDragStart?.(event, doc)}
onDragEnd={(event) => onDocumentDragEnd?.(event)} onDragEnd={(event) => onDocumentDragEnd?.(event)}
+2 -2
View File
@@ -36,7 +36,7 @@ const DocumentsList = ({
onFolderDragEnd, onFolderDragEnd,
onFolderRename, onFolderRename,
onDocumentClick, onDocumentClick,
onDocumentOpen, onDocumentActivate,
onDocumentDragStart, onDocumentDragStart,
onDocumentDragEnd, onDocumentDragEnd,
onDocumentTagDragOver, onDocumentTagDragOver,
@@ -266,7 +266,7 @@ const DocumentsList = ({
id={`document-row-${doc.id}`} id={`document-row-${doc.id}`}
data-doc-id={doc.id} data-doc-id={doc.id}
onClick={(event) => onDocumentClick?.(doc, event)} onClick={(event) => onDocumentClick?.(doc, event)}
onDoubleClick={() => onDocumentOpen?.(doc.id)} onDoubleClick={(event) => onDocumentActivate?.(doc, event)}
draggable draggable
onDragStart={(event) => onDocumentDragStart?.(event, doc)} onDragStart={(event) => onDocumentDragStart?.(event, doc)}
onDragEnd={(event) => onDocumentDragEnd?.(event)} onDragEnd={(event) => onDocumentDragEnd?.(event)}
+119 -53
View File
@@ -14,6 +14,9 @@ import DocumentsGrid from './DocumentsGrid';
import DocumentsList from './DocumentsList'; import DocumentsList from './DocumentsList';
import { isTagTransferEvent } from './tagTransfer'; import { isTagTransferEvent } from './tagTransfer';
import SelectionFloatingActions from './SelectionFloatingActions'; import SelectionFloatingActions from './SelectionFloatingActions';
import PreviewZoomOverlay from '../detail/PreviewZoomOverlay';
import { useAssetNavigator } from '../hooks/useAssetNavigator';
import { isPointerModifierEvent, isPrimaryPointerEvent } from './useEntryPointer';
const DEFAULT_GRID_ICON_SIZE = 144; const DEFAULT_GRID_ICON_SIZE = 144;
@@ -39,15 +42,13 @@ const DocumentsPanel = ({
draggedFolderId, draggedFolderId,
onFolderRename, onFolderRename,
selectedFolderIds = [], selectedFolderIds = [],
onDocumentOpen,
selectedDocumentIds = [], selectedDocumentIds = [],
focusedRowKey, focusedRowKey,
draggingDocumentIds = [], draggingDocumentIds = [],
onDocumentDragStart, onDocumentDragStart,
onDocumentDragEnd, onDocumentDragEnd,
onDocumentRename, onDocumentRename,
onEntrySelection = null, onEntryPointer = null,
onOpenDetailPanel = null,
tagLookupById, tagLookupById,
activeCorrespondentIds = [], activeCorrespondentIds = [],
onDocumentListFocus, onDocumentListFocus,
@@ -118,6 +119,85 @@ const DocumentsPanel = ({
}, []); }, []);
const isGridView = viewMode === 'grid'; const isGridView = viewMode === 'grid';
const isDeskView = viewMode === 'desk'; const isDeskView = viewMode === 'desk';
const [previewDocId, setPreviewDocId] = useState(null);
const previewDoc = useMemo(() => {
if (!previewDocId) {
return null;
}
return rows.find((doc) => doc?.id === previewDocId) || null;
}, [previewDocId, rows]);
useEffect(() => {
if (previewDocId && !previewDoc) {
setPreviewDocId(null);
}
}, [previewDocId, previewDoc]);
const previewNavigator = useAssetNavigator({
document: previewDoc,
assetType: 'preview',
ensureAssetUrl,
getAsset: getDocumentAsset,
prefetch: 3,
});
const {
currentUrl: previewUrl,
canGoPrev: previewCanGoPrev,
canGoNext: previewCanGoNext,
goPrev: previewGoPrev,
goNext: previewGoNext,
} = previewNavigator;
const previewDisplay = useMemo(() => {
if (!previewDoc || !previewUrl) {
return null;
}
return {
url: previewUrl,
alt: previewDoc.title,
canGoPrev: Boolean(previewCanGoPrev),
canGoNext: Boolean(previewCanGoNext),
goPrev: previewGoPrev,
goNext: previewGoNext,
};
}, [previewDoc, previewUrl, previewCanGoPrev, previewCanGoNext, previewGoPrev, previewGoNext]);
const closePreviewOverlay = useCallback(() => {
setPreviewDocId(null);
}, []);
const handleDocumentPreviewZoom = useCallback(
(doc) => {
if (!doc || !doc.id) {
return;
}
const previewAsset = typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, 'preview') : null;
if (!previewAsset) {
return;
}
setPreviewDocId(doc.id);
},
[getDocumentAsset],
);
const handleDocumentActivate = useCallback(
(doc, event) => {
if (event) {
if (typeof event.preventDefault === 'function') {
event.preventDefault();
}
if (typeof event.stopPropagation === 'function') {
event.stopPropagation();
}
}
handleDocumentPreviewZoom(doc);
},
[handleDocumentPreviewZoom],
);
const isListView = viewMode === 'list'; const isListView = viewMode === 'list';
const gridIconSize = DEFAULT_GRID_ICON_SIZE; const gridIconSize = DEFAULT_GRID_ICON_SIZE;
const handleSetViewMode = useCallback( const handleSetViewMode = useCallback(
@@ -240,56 +320,20 @@ const DocumentsPanel = ({
[isTagDragEvent, onDocumentTagDrop], [isTagDragEvent, onDocumentTagDrop],
); );
const handleEntryClick = useCallback(
(entry, event) => {
if (!entry || !entry.id) {
return;
}
if (entry.type === EntryType.document && suppressDocumentClickRef.current) {
return;
}
const rowKey = entry.type === EntryType.document ? `document:${entry.id}` : `folder:${entry.id}`;
if (rowKey && typeof onEntrySelection === 'function') {
onEntrySelection(rowKey, event);
}
if (entry.type === EntryType.document) {
const hasModifier = Boolean(
event && (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey),
);
if (!hasModifier && typeof onOpenDetailPanel === 'function') {
onOpenDetailPanel();
}
return;
}
if (entry.type === EntryType.folder) {
const hasModifier = Boolean(
event && (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey),
);
const isPrimaryClick = Boolean(event && event.type === 'click' && event.button === 0);
if (!hasModifier && isPrimaryClick && typeof onFolderSelect === 'function') {
onFolderSelect(entry.id);
}
if (scrollRef.current) {
scrollRef.current.focus({ preventScroll: true });
}
onFocusedRowChange?.(rowKey);
}
},
[onEntrySelection, onOpenDetailPanel, onFocusedRowChange, onFolderSelect],
);
const handleDocumentClick = useCallback( const handleDocumentClick = useCallback(
(doc, event) => { (doc, event) => {
if (!doc) { if (!doc || suppressDocumentClickRef.current) {
return; return;
} }
handleEntryClick({ type: EntryType.document, id: doc.id, document: doc }, event);
if (typeof onEntryPointer === 'function') {
onEntryPointer(
{ type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc },
event,
);
}
}, },
[handleEntryClick], [onEntryPointer],
); );
const handleFolderClick = useCallback( const handleFolderClick = useCallback(
@@ -297,9 +341,24 @@ const DocumentsPanel = ({
if (!folder) { if (!folder) {
return; return;
} }
handleEntryClick({ type: EntryType.folder, id: folder.id, folder }, event);
if (typeof onEntryPointer === 'function') {
onEntryPointer(
{ type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder },
event,
);
}
if (
!isPointerModifierEvent(event)
&& isPrimaryPointerEvent(event)
&& scrollRef.current
) {
scrollRef.current.focus({ preventScroll: true });
onFocusedRowChange?.(`folder:${folder.id}`);
}
}, },
[handleEntryClick], [onEntryPointer, onFocusedRowChange],
); );
const handleDocumentDragStartLocal = useCallback( const handleDocumentDragStartLocal = useCallback(
@@ -345,7 +404,8 @@ const DocumentsPanel = ({
}, [breadcrumbEntries, currentFolderName, onFolderSelect]); }, [breadcrumbEntries, currentFolderName, onFolderSelect]);
return ( return (
<section <>
<section
className={`documents-panel documents-panel--view-${isGridView ? 'grid' : 'list'}`} className={`documents-panel documents-panel--view-${isGridView ? 'grid' : 'list'}`}
> >
{showHeader ? ( {showHeader ? (
@@ -455,7 +515,7 @@ const DocumentsPanel = ({
onFolderDragStart={onFolderDragStart} onFolderDragStart={onFolderDragStart}
onFolderDragEnd={onFolderDragEnd} onFolderDragEnd={onFolderDragEnd}
onDocumentClick={handleDocumentClick} onDocumentClick={handleDocumentClick}
onDocumentOpen={onDocumentOpen} onDocumentActivate={handleDocumentActivate}
onDocumentDragStart={handleDocumentDragStartLocal} onDocumentDragStart={handleDocumentDragStartLocal}
onDocumentDragEnd={handleDocumentDragEndLocal} onDocumentDragEnd={handleDocumentDragEndLocal}
onDocumentTagDragOver={handleDocumentTagDragOver} onDocumentTagDragOver={handleDocumentTagDragOver}
@@ -492,7 +552,7 @@ const DocumentsPanel = ({
onFolderDragEnd={onFolderDragEnd} onFolderDragEnd={onFolderDragEnd}
onFolderRename={onFolderRename} onFolderRename={onFolderRename}
onDocumentClick={handleDocumentClick} onDocumentClick={handleDocumentClick}
onDocumentOpen={onDocumentOpen} onDocumentActivate={handleDocumentActivate}
onDocumentDragStart={handleDocumentDragStartLocal} onDocumentDragStart={handleDocumentDragStartLocal}
onDocumentDragEnd={handleDocumentDragEndLocal} onDocumentDragEnd={handleDocumentDragEndLocal}
onDocumentTagDragOver={handleDocumentTagDragOver} onDocumentTagDragOver={handleDocumentTagDragOver}
@@ -516,6 +576,12 @@ const DocumentsPanel = ({
</div> </div>
)} )}
</section> </section>
<PreviewZoomOverlay
open={Boolean(previewDocId)}
display={previewDisplay}
onClose={closePreviewOverlay}
/>
</>
); );
}; };
+62
View File
@@ -0,0 +1,62 @@
import { useCallback } from 'react';
export const isPointerModifierEvent = (event) =>
Boolean(event && (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey));
export const isPrimaryPointerEvent = (event) => {
if (!event) {
return true;
}
if (typeof event.button === 'number' && event.button !== 0) {
return false;
}
const type = typeof event.type === 'string' ? event.type.toLowerCase() : '';
return type === 'click' || type === 'pointerdown' || type === 'pointerup';
};
export const useEntryPointerHandler = ({
resolveDocumentRowKey,
resolveFolderRowKey,
onSelectDocument,
onSelectFolder,
}) =>
useCallback(
(entry, event) => {
if (!entry || !entry.id) {
return;
}
const { type, id } = entry;
if (type !== 'document' && type !== 'folder') {
return;
}
const rowKey = entry.key
|| (type === 'document' ? resolveDocumentRowKey?.(id) : resolveFolderRowKey?.(id));
if (!rowKey) {
return;
}
const modifierClick = isPointerModifierEvent(event);
const primaryClick = isPrimaryPointerEvent(event);
if (type === 'document') {
if (typeof onSelectDocument === 'function') {
onSelectDocument(id, event, { modifierClick, primaryClick, rowKey });
}
return;
}
if (typeof onSelectFolder === 'function') {
onSelectFolder(id, event, { modifierClick, primaryClick, rowKey });
}
},
[
resolveDocumentRowKey,
resolveFolderRowKey,
onSelectDocument,
onSelectFolder,
],
);
export default useEntryPointerHandler;