From 01fd774b185bfbbd1f8d468b9ab9e78d30766a9b Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Tue, 4 Nov 2025 00:36:44 +0100 Subject: [PATCH] ui --- frontend/src/DesktopWorkspace.jsx | 99 ++++++++-- frontend/src/app/AppLayout.jsx | 65 ++++--- frontend/src/desktop/dragPhysics.js | 202 +++++++++++++++++++++ frontend/src/desktop/useDocumentDrag.js | 210 +++++----------------- frontend/src/documents/DocumentsGrid.jsx | 4 +- frontend/src/documents/DocumentsList.jsx | 4 +- frontend/src/documents/DocumentsPanel.jsx | 172 ++++++++++++------ frontend/src/documents/useEntryPointer.js | 62 +++++++ 8 files changed, 551 insertions(+), 267 deletions(-) create mode 100644 frontend/src/desktop/dragPhysics.js create mode 100644 frontend/src/documents/useEntryPointer.js diff --git a/frontend/src/DesktopWorkspace.jsx b/frontend/src/DesktopWorkspace.jsx index db24a3c..7bdebd9 100644 --- a/frontend/src/DesktopWorkspace.jsx +++ b/frontend/src/DesktopWorkspace.jsx @@ -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)} diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx index 78b83d2..8687183 100644 --- a/frontend/src/app/AppLayout.jsx +++ b/frontend/src/app/AppLayout.jsx @@ -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, diff --git a/frontend/src/desktop/dragPhysics.js b/frontend/src/desktop/dragPhysics.js new file mode 100644 index 0000000..a37e3f1 --- /dev/null +++ b/frontend/src/desktop/dragPhysics.js @@ -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; diff --git a/frontend/src/desktop/useDocumentDrag.js b/frontend/src/desktop/useDocumentDrag.js index 964b975..38f1775 100644 --- a/frontend/src/desktop/useDocumentDrag.js +++ b/frontend/src/desktop/useDocumentDrag.js @@ -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); } diff --git a/frontend/src/documents/DocumentsGrid.jsx b/frontend/src/documents/DocumentsGrid.jsx index 9c56ea6..4d84c95 100644 --- a/frontend/src/documents/DocumentsGrid.jsx +++ b/frontend/src/documents/DocumentsGrid.jsx @@ -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)} diff --git a/frontend/src/documents/DocumentsList.jsx b/frontend/src/documents/DocumentsList.jsx index 88be6b0..9811e99 100644 --- a/frontend/src/documents/DocumentsList.jsx +++ b/frontend/src/documents/DocumentsList.jsx @@ -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)} diff --git a/frontend/src/documents/DocumentsPanel.jsx b/frontend/src/documents/DocumentsPanel.jsx index 9eaefb7..4f97bff 100644 --- a/frontend/src/documents/DocumentsPanel.jsx +++ b/frontend/src/documents/DocumentsPanel.jsx @@ -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 ( -
+
{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 = ({ )}
+ + ); }; diff --git a/frontend/src/documents/useEntryPointer.js b/frontend/src/documents/useEntryPointer.js new file mode 100644 index 0000000..f2a4eea --- /dev/null +++ b/frontend/src/documents/useEntryPointer.js @@ -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;