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 TAG_REMOVE_DISTANCE = 160;
const STACK_HIT_EPSILON = 4;
const POINTER_DRAG_THRESHOLD_SQUARED = 16;
const DEBUG_DRAG = false;
const DEBUG_FOCUS = true;
@@ -572,8 +573,9 @@ const DesktopWorkspace = ({
searchResults = null,
onDocumentOpen,
onInspectDocument = null,
onDocumentPointerSelect = null,
onEntryPointer = null,
onDocumentStackSelect = null,
onPromoteSelection = null,
onAssignTagToDocument = null,
onRemoveTagFromDocument = null,
ensureAssetUrl = null,
@@ -1832,8 +1834,9 @@ const recalcVisibleDocIds = useCallback(() => {
pendingRemovalTag,
onDocumentOpen,
onInspectDocument,
onDocumentPointerSelect,
onEntryPointer,
onDocumentStackSelect,
onPromoteSelection,
ensureAssetUrl,
getDocumentAsset,
handleNavigatorSnapshot,
@@ -1885,7 +1888,7 @@ const recalcVisibleDocIds = useCallback(() => {
docSizeVersion,
onDocumentOpen,
onInspectDocument,
onDocumentPointerSelect,
onEntryPointer,
openOverlayForDoc,
overlayDisplay,
overlayOriginRect,
@@ -1902,6 +1905,7 @@ const recalcVisibleDocIds = useCallback(() => {
selectedDocumentIds,
onClearSelection,
onDocumentStackSelect,
onPromoteSelection,
markLayoutDirty,
detailPanelOpen,
onCloseDetailPanel,
@@ -1949,8 +1953,9 @@ const DesktopWorkspaceView = () => {
closeOverlay,
overlayOriginRect,
overlayOriginTransform,
onDocumentPointerSelect,
onEntryPointer,
onDocumentStackSelect,
onPromoteSelection,
selectedDocumentIds,
onClearSelection,
detailPanelOpen,
@@ -1960,6 +1965,10 @@ const DesktopWorkspaceView = () => {
const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } =
useDocumentDrag();
const deferredSelectionRef = useRef(null);
const pointerStartRef = useRef({ x: 0, y: 0 });
const pointerMovedRef = useRef(false);
const resolveStackDocIds = useCallback(
(event, targetDocId = null) => {
const container = containerRef.current;
@@ -2245,6 +2254,11 @@ const DesktopWorkspaceView = () => {
}
}}
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 metaOrCtrlOnly =
(event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey;
@@ -2260,23 +2274,46 @@ const DesktopWorkspaceView = () => {
if (hasStack && alreadySelected && typeof onDocumentStackSelect === 'function') {
onDocumentStackSelect(hits, event);
appliedStackSelection = true;
deferredSelectionRef.current = null;
}
}
}
const shouldInvokePointerSelect =
typeof onDocumentPointerSelect === 'function'
&& (
!metaOrCtrlOnly
|| !alreadySelected
|| event.shiftKey
|| event.altKey
|| !stackDocIds
|| stackDocIds.length <= 1
);
if (alreadySelected && typeof onPromoteSelection === 'function') {
onPromoteSelection(doc.id, event);
}
if (shouldInvokePointerSelect) {
onDocumentPointerSelect(doc.id, event);
const modifierActive =
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, {
@@ -2284,9 +2321,33 @@ const DesktopWorkspaceView = () => {
stackSelectionApplied: appliedStackSelection,
});
}}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
onPointerMove={(event) => {
const start = pointerStartRef.current;
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)}
onDragOver={(event) => handleTagDragOverDoc(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 { useDetailPanel } from './useDetailPanel';
import { useDocumentSelection } from './useDocumentSelection';
import { useEntryPointerHandler } from '../documents/useEntryPointer';
import { isTagTransferEvent } from '../documents/tagTransfer';
const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early
@@ -4383,6 +4384,29 @@ const AppLayout = () => {
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 chain = [];
const seen = new Set();
@@ -4715,9 +4739,8 @@ const AppLayout = () => {
viewMode: documentsViewMode,
onViewModeChange: handleDocumentsViewModeChange,
onClearSelection: clearDocumentSelection,
onEntrySelection: handleEntrySelection,
onDeleteSelection: handleDeleteSelection,
onOpenDetailPanel: openDetailPanel,
onEntryPointer: handleEntryPointer,
tags,
correspondents,
documentLookup,
@@ -4749,10 +4772,8 @@ const AppLayout = () => {
handleFolderDragEnd,
handleFolderDragStart,
handleFolderRename,
handleEntrySelection,
handleDeleteSelection,
isFilterActive,
openDetailPanel,
openDocumentPreview,
refreshCurrentFolder,
searchLoading,
@@ -4764,6 +4785,7 @@ const AppLayout = () => {
tagLookupById,
toggleCorrespondentFilter,
toggleTagFilter,
handleEntryPointer,
ensureAssetUrl,
getDocumentAsset,
tags,
@@ -4928,20 +4950,6 @@ const AppLayout = () => {
[applySelection, openDetailPanel],
);
const handleDeskDocumentPointerSelect = useCallback(
(docId, event) => {
if (!docId) {
return;
}
const rowKey = resolveDocumentRowKey(docId);
if (!rowKey) {
return;
}
handleEntrySelection(rowKey, event);
},
[handleEntrySelection],
);
const handleDeskDocumentStackSelect = useCallback(
(docIds) => {
if (!Array.isArray(docIds) || docIds.length === 0) {
@@ -4956,12 +4964,21 @@ const AppLayout = () => {
return;
}
applySelection(rowKeys, {
anchor: rowKeys[0],
const nextKeys = [...selectedEntries];
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,
});
},
[applySelection],
[applySelection, selectedEntries, selectionAnchorRef],
);
const handleDeskHelpOpen = useCallback(() => {
@@ -5001,8 +5018,9 @@ const AppLayout = () => {
onRefresh: refreshCurrentFolder,
onDocumentOpen: openDocumentPreview,
onInspectDocument: handleDeskInspectDocument,
onDocumentPointerSelect: handleDeskDocumentPointerSelect,
onEntryPointer: handleEntryPointer,
onDocumentStackSelect: handleDeskDocumentStackSelect,
onPromoteSelection: promoteSelectionOrder,
onOpenHelp: handleDeskHelpOpen,
helpOpen: deskHelpOpen,
onHelpClose: handleDeskHelpClose,
@@ -5041,8 +5059,9 @@ const AppLayout = () => {
refreshCurrentFolder,
openDocumentPreview,
handleDeskInspectDocument,
handleDeskDocumentPointerSelect,
handleDeskDocumentStackSelect,
handleEntryPointer,
promoteSelectionOrder,
handleDeskHelpOpen,
handleDeskHelpClose,
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 { preventAll } from './events';
import { clamp, formatTransform } from './math';
import usePointerTap from '../ui/usePointerTap';
import createDragPhysics, { MIN_TIMESTEP, MAX_TIMESTEP } from './dragPhysics';
const DRAG_HYSTERESIS_PX = 4;
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 useDocumentDrag = () => {
@@ -37,61 +31,40 @@ const useDocumentDrag = () => {
markLayoutDirty,
} = useDesktopContext();
const applyTransform = useCallback(
(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,
);
},
[itemRefs],
);
const markLayoutDirtyRef = useRef(markLayoutDirty);
useEffect(() => {
markLayoutDirtyRef.current = markLayoutDirty;
}, [markLayoutDirty]);
const finalizeGroupDrag = useCallback(
(dragState) => {
if (!dragState?.groupItems) {
return;
}
const syncLayoutSnapshotRef = useRef(syncLayoutSnapshot);
useEffect(() => {
syncLayoutSnapshotRef.current = syncLayoutSnapshot;
}, [syncLayoutSnapshot]);
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,
);
const physicsRef = useRef(null);
if (!physicsRef.current) {
physicsRef.current = createDragPhysics({
layoutRef,
itemRefs,
markLayoutDirtyRef,
syncLayoutSnapshotRef,
});
}
markLayoutDirty?.();
},
[applyTransform, layoutRef, markLayoutDirty],
useEffect(
() => () => {
physicsRef.current?.dispose?.();
},
[],
);
const {
applyTransform,
finalizeGroupDrag,
cancelInertiaAnimation,
startInertiaAnimation,
} = physicsRef.current;
const tapHandler = usePointerTap({
delay: 220,
onSingle: ({ data, event }) => {
@@ -119,118 +92,8 @@ const useDocumentDrag = () => {
});
const dragStateRef = useRef(null);
const inertiaAnimationsRef = useRef(new Map());
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(
(pointerId) => {
const state = dragStateRef.current;
@@ -346,7 +209,18 @@ const useDocumentDrag = () => {
const modifierPressed = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
if (!modifierPressed) {
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 {
bringToFront(docId);
}
+2 -2
View File
@@ -21,7 +21,7 @@ const DocumentsGrid = ({
onFolderDragStart,
onFolderDragEnd,
onDocumentClick,
onDocumentOpen,
onDocumentActivate,
onDocumentDragStart,
onDocumentDragEnd,
onDocumentTagDragOver,
@@ -244,7 +244,7 @@ const DocumentsGrid = ({
id={`document-card-${doc.id}`}
data-doc-id={doc.id}
onClick={(event) => onDocumentClick?.(doc, event)}
onDoubleClick={() => onDocumentOpen?.(doc.id)}
onDoubleClick={(event) => onDocumentActivate?.(doc, event)}
draggable
onDragStart={(event) => onDocumentDragStart?.(event, doc)}
onDragEnd={(event) => onDocumentDragEnd?.(event)}
+2 -2
View File
@@ -36,7 +36,7 @@ const DocumentsList = ({
onFolderDragEnd,
onFolderRename,
onDocumentClick,
onDocumentOpen,
onDocumentActivate,
onDocumentDragStart,
onDocumentDragEnd,
onDocumentTagDragOver,
@@ -266,7 +266,7 @@ const DocumentsList = ({
id={`document-row-${doc.id}`}
data-doc-id={doc.id}
onClick={(event) => onDocumentClick?.(doc, event)}
onDoubleClick={() => onDocumentOpen?.(doc.id)}
onDoubleClick={(event) => onDocumentActivate?.(doc, event)}
draggable
onDragStart={(event) => onDocumentDragStart?.(event, doc)}
onDragEnd={(event) => onDocumentDragEnd?.(event)}
+119 -53
View File
@@ -14,6 +14,9 @@ import DocumentsGrid from './DocumentsGrid';
import DocumentsList from './DocumentsList';
import { isTagTransferEvent } from './tagTransfer';
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;
@@ -39,15 +42,13 @@ const DocumentsPanel = ({
draggedFolderId,
onFolderRename,
selectedFolderIds = [],
onDocumentOpen,
selectedDocumentIds = [],
focusedRowKey,
draggingDocumentIds = [],
onDocumentDragStart,
onDocumentDragEnd,
onDocumentRename,
onEntrySelection = null,
onOpenDetailPanel = null,
onEntryPointer = null,
tagLookupById,
activeCorrespondentIds = [],
onDocumentListFocus,
@@ -118,6 +119,85 @@ const DocumentsPanel = ({
}, []);
const isGridView = viewMode === 'grid';
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 gridIconSize = DEFAULT_GRID_ICON_SIZE;
const handleSetViewMode = useCallback(
@@ -240,56 +320,20 @@ const DocumentsPanel = ({
[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(
(doc, event) => {
if (!doc) {
if (!doc || suppressDocumentClickRef.current) {
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(
@@ -297,9 +341,24 @@ const DocumentsPanel = ({
if (!folder) {
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(
@@ -345,7 +404,8 @@ const DocumentsPanel = ({
}, [breadcrumbEntries, currentFolderName, onFolderSelect]);
return (
<section
<>
<section
className={`documents-panel documents-panel--view-${isGridView ? 'grid' : 'list'}`}
>
{showHeader ? (
@@ -455,7 +515,7 @@ const DocumentsPanel = ({
onFolderDragStart={onFolderDragStart}
onFolderDragEnd={onFolderDragEnd}
onDocumentClick={handleDocumentClick}
onDocumentOpen={onDocumentOpen}
onDocumentActivate={handleDocumentActivate}
onDocumentDragStart={handleDocumentDragStartLocal}
onDocumentDragEnd={handleDocumentDragEndLocal}
onDocumentTagDragOver={handleDocumentTagDragOver}
@@ -492,7 +552,7 @@ const DocumentsPanel = ({
onFolderDragEnd={onFolderDragEnd}
onFolderRename={onFolderRename}
onDocumentClick={handleDocumentClick}
onDocumentOpen={onDocumentOpen}
onDocumentActivate={handleDocumentActivate}
onDocumentDragStart={handleDocumentDragStartLocal}
onDocumentDragEnd={handleDocumentDragEndLocal}
onDocumentTagDragOver={handleDocumentTagDragOver}
@@ -516,6 +576,12 @@ const DocumentsPanel = ({
</div>
)}
</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;