ui
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user