From 3f49e26d199c12ccece51d58e1cc7b2dabc254e1 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Thu, 6 Nov 2025 14:43:43 +0100 Subject: [PATCH] work --- frontend/package.json | 3 +- frontend/src/DesktopWorkspace.jsx | 2840 ----------------- frontend/src/app/useWorkspaceSurface.js | 2 +- frontend/src/desk/db.js | 143 - frontend/src/desktop/DesktopDocumentCard.jsx | 124 + frontend/src/desktop/DesktopPreviewCard.jsx | 146 + .../src/{ => desktop}/DesktopWorkspace.css | 0 frontend/src/desktop/DesktopWorkspace.jsx | 1182 +++++++ frontend/src/desktop/context.js | 17 - frontend/src/desktop/db.js | 183 ++ frontend/src/desktop/dragPhysics.js | 202 -- frontend/src/desktop/events.js | 16 + frontend/src/desktop/pointer/pointerUtils.js | 131 + .../src/desktop/pointer/useDeskPointer.js | 425 +++ .../desktop/tags/useDeskTagInteractions.js | 351 ++ frontend/src/desktop/useDocumentDrag.js | 338 +- frontend/src/desktop/workspaceEngine.js | 1166 +++++++ frontend/src/styles.css | 7 + frontend/tests/workspaceEngine.test.js | 70 + 19 files changed, 3980 insertions(+), 3366 deletions(-) delete mode 100644 frontend/src/DesktopWorkspace.jsx delete mode 100644 frontend/src/desk/db.js create mode 100644 frontend/src/desktop/DesktopDocumentCard.jsx create mode 100644 frontend/src/desktop/DesktopPreviewCard.jsx rename frontend/src/{ => desktop}/DesktopWorkspace.css (100%) create mode 100644 frontend/src/desktop/DesktopWorkspace.jsx delete mode 100644 frontend/src/desktop/context.js create mode 100644 frontend/src/desktop/db.js delete mode 100644 frontend/src/desktop/dragPhysics.js create mode 100644 frontend/src/desktop/pointer/pointerUtils.js create mode 100644 frontend/src/desktop/pointer/useDeskPointer.js create mode 100644 frontend/src/desktop/tags/useDeskTagInteractions.js create mode 100644 frontend/src/desktop/workspaceEngine.js create mode 100644 frontend/tests/workspaceEngine.test.js diff --git a/frontend/package.json b/frontend/package.json index d8a1450..a8b9ad3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "webpack serve --mode development --open", "build": "webpack --mode production", - "lint": "eslint src --ext .js,.jsx" + "lint": "eslint src --ext .js,.jsx", + "test:engine": "node --test tests/workspaceEngine.test.js" }, "dependencies": { "@fontsource/inter": "^5.2.8", diff --git a/frontend/src/DesktopWorkspace.jsx b/frontend/src/DesktopWorkspace.jsx deleted file mode 100644 index e514379..0000000 --- a/frontend/src/DesktopWorkspace.jsx +++ /dev/null @@ -1,2840 +0,0 @@ -import React, { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { createPortal } from 'react-dom'; -import { resolveDocumentAssetUrl, createAssetView } from './asset_manager'; -import { useAssetNavigator } from './hooks/useAssetNavigator'; -import { ArrowLeftIcon, ArrowRightIcon, CloseIcon } from './ui/icons'; -import SelectionFloatingActions from './documents/SelectionFloatingActions'; -import { createDocumentsTableHeaderActions } from './documents/DocumentsPanel'; -import createWorkspaceSurfaceConfig from './documents/workspaceHeader'; -import { resolveCorrespondents } from './documents/correspondents'; -import DetailPanel from './detail/DetailPanel'; -import { clamp, formatTransform } from './desktop/math'; -import { preventAll } from './desktop/events'; -import useDocumentDrag from './desktop/useDocumentDrag'; -import { DesktopProvider, useDesktopContext } from './desktop/context'; -import PreviewZoomOverlay from './detail/PreviewZoomOverlay'; -import { getTagColorStyle } from './utils/colors'; -import { - isTagTransferEvent, - parseTagTransferPayload, - writeTagTransferData, -} from './documents/tagTransfer'; -import { fetchLayoutRecords, upsertLayoutRecords } from './desk/db'; -import './DesktopWorkspace.css'; - -const CANVAS_PADDING = 24; -const ROTATION_RANGE = 7; -const DEFAULT_CANVAS_WIDTH = 1024; -const DEFAULT_CANVAS_HEIGHT = 680; - -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 LONG_PRESS_DURATION_MS = 450; - -const DEBUG_DRAG = false; -const DEBUG_FOCUS = true; -const DEBUG_DROP = true; - -const CLICK_ACTIONS = { - selectSingle: 'selectSingle', - openDetail: 'openDetail', - addCard: 'addCard', - addStack: 'addStack', - none: 'none', -}; - -const DRAG_ACTIONS = { - dragSelectSingle: 'dragSelectSingle', - dragSelection: 'dragSelection', - dragSelectStack: 'dragSelectStack', - none: 'none', -}; - -const createPointerIntent = ({ - doc, - entryDescriptor, - selectedDocumentIds, - metaKey, - pointerButton, - pointerType, - stackHits, -}) => { - const alreadySelected = selectedDocumentIds.includes(doc.id); - const selectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; - - let clickAction = CLICK_ACTIONS.none; - let dragAction = DRAG_ACTIONS.none; - - if (metaKey) { - clickAction = CLICK_ACTIONS.addStack; - dragAction = DRAG_ACTIONS.dragSelectStack; - } else if (alreadySelected) { - clickAction = CLICK_ACTIONS.openDetail; - dragAction = selectionCount > 1 ? DRAG_ACTIONS.dragSelection : DRAG_ACTIONS.dragSelectSingle; - } else { - clickAction = CLICK_ACTIONS.selectSingle; - dragAction = DRAG_ACTIONS.dragSelectSingle; - } - - const stackList = Array.isArray(stackHits) && stackHits.length > 0 - ? stackHits.slice() - : [String(doc.id)]; - - const stackDocIdsForDrag = dragAction === DRAG_ACTIONS.dragSelectStack ? stackList : null; - const stackDocIdsForClick = clickAction === CLICK_ACTIONS.addStack ? stackList : null; - - return { - docId: doc.id, - entryDescriptor, - pointerType, - pointerButton, - selectedAtDown: alreadySelected, - selectionCountAtDown: selectionCount, - metaKey, - clickAction, - dragAction, - stackDocIdsForDrag, - stackDocIdsForClick, - stackReplaceOnClick: clickAction === CLICK_ACTIONS.addStack, - stackReplaceOnDrag: dragAction === DRAG_ACTIONS.dragSelectStack, - clickSelectionApplied: false, - stackSelectionApplied: false, - longPressTriggered: false, - }; -}; - -const applyClickPlanImmediately = ({ intent, event, onEntryPointer, onDocumentStackSelect }) => { - switch (intent.clickAction) { - case CLICK_ACTIONS.selectSingle: - case CLICK_ACTIONS.addCard: - if (typeof onEntryPointer === 'function') { - onEntryPointer(intent.entryDescriptor, event); - } - intent.clickSelectionApplied = true; - break; - case CLICK_ACTIONS.addStack: - if ( - Array.isArray(intent.stackDocIdsForClick) - && intent.stackDocIdsForClick.length > 0 - && typeof onDocumentStackSelect === 'function' - ) { - onDocumentStackSelect(intent.stackDocIdsForClick, event, { replace: intent.stackReplaceOnClick }); - intent.clickSelectionApplied = true; - intent.stackSelectionApplied = true; - } - break; - case CLICK_ACTIONS.openDetail: - default: - intent.clickSelectionApplied = true; - break; - } -}; - -const finalizeClickSelection = ({ intent, event, onEntryPointer, onDocumentStackSelect }) => { - if (!intent || intent.clickSelectionApplied) { - return; - } - - applyClickPlanImmediately({ intent, event, onEntryPointer, onDocumentStackSelect }); -}; - -const applyLongPressSelection = ({ intent, stackDocIds, syntheticEvent, onDocumentStackSelect }) => { - if (!intent) { - return; - } - - const stackCopy = Array.isArray(stackDocIds) && stackDocIds.length > 0 - ? stackDocIds.slice() - : [intent.docId]; - - if (typeof onDocumentStackSelect === 'function') { - onDocumentStackSelect(stackCopy, syntheticEvent, { replace: true }); - } - - intent.clickAction = CLICK_ACTIONS.addStack; - intent.dragAction = DRAG_ACTIONS.dragSelectStack; - intent.stackDocIdsForClick = stackCopy; - intent.stackDocIdsForDrag = stackCopy; - intent.stackReplaceOnClick = true; - intent.stackReplaceOnDrag = true; - intent.clickSelectionApplied = true; - intent.stackSelectionApplied = true; - intent.longPressTriggered = true; -}; - -const signedDistance = (ax, ay, bx, by, px, py) => (bx - ax) * (py - ay) - (by - ay) * (px - ax); - -const clipPolygonWithEdge = (subject, edgeStart, edgeEnd) => { - if (!subject.length) { - return []; - } - const result = []; - let prev = subject[subject.length - 1]; - let prevInside = signedDistance(edgeStart.x, edgeStart.y, edgeEnd.x, edgeEnd.y, prev.x, prev.y) >= 0; - subject.forEach((curr) => { - const currInside = signedDistance(edgeStart.x, edgeStart.y, edgeEnd.x, edgeEnd.y, curr.x, curr.y) >= 0; - if (currInside !== prevInside) { - const dx = curr.x - prev.x; - const dy = curr.y - prev.y; - const denom = (edgeEnd.x - edgeStart.x) * dy - (edgeEnd.y - edgeStart.y) * dx; - if (Math.abs(denom) > 1e-9) { - const t = ((edgeStart.x - prev.x) * dy - (edgeStart.y - prev.y) * dx) / denom; - result.push({ - x: edgeStart.x + t * (edgeEnd.x - edgeStart.x), - y: edgeStart.y + t * (edgeEnd.y - edgeStart.y), - }); - } - } - if (currInside) { - result.push(curr); - } - prev = curr; - prevInside = currInside; - }); - return result; -}; - -const clipPolygon = (subject, clipShape) => { - if (!subject.length) { - return []; - } - let output = subject; - let prev = clipShape[clipShape.length - 1]; - for (let index = 0; index < clipShape.length; index += 1) { - const curr = clipShape[index]; - output = clipPolygonWithEdge(output, prev, curr); - if (!output.length) { - return []; - } - prev = curr; - } - return output; -}; - -const isPointInsideConvex = (point, polygon) => { - if (!polygon.length) { - return false; - } - let prev = polygon[polygon.length - 1]; - for (let index = 0; index < polygon.length; index += 1) { - const curr = polygon[index]; - if (signedDistance(prev.x, prev.y, curr.x, curr.y, point.x, point.y) < -1e-6) { - return false; - } - prev = curr; - } - return true; -}; - -const polygonCentroid = (polygon) => { - let x = 0; - let y = 0; - polygon.forEach((point) => { - x += point.x; - y += point.y; - }); - const count = polygon.length || 1; - return { - x: x / count, - y: y / count, - }; -}; - -const DesktopPreviewCard = ({ - doc, - title, - ensureAssetUrl, - getDocumentAsset, - prefetch = 3, - onNavigatorSnapshot, - shouldLoad = true, -}) => { - const navigator = useAssetNavigator({ - document: doc, - assetType: 'preview', - ensureAssetUrl: shouldLoad ? ensureAssetUrl : null, - getAsset: getDocumentAsset, - prefetch, - }); - - const { currentUrl, cardinality, canGoPrev, canGoNext, currentMetadata, ordinal } = navigator; - const docId = doc?.id ?? null; - - const metadataWidth = Number(currentMetadata?.width); - const metadataHeight = Number(currentMetadata?.height); - - useEffect(() => { - if (!onNavigatorSnapshot || !docId) { - return undefined; - } - const snapshot = { - url: currentUrl || null, - alt: title, - canGoPrev, - canGoNext, - goPrev: navigator.goPrev, - goNext: navigator.goNext, - ordinal, - width: Number.isFinite(metadataWidth) && metadataWidth > 0 ? metadataWidth : null, - height: Number.isFinite(metadataHeight) && metadataHeight > 0 ? metadataHeight : null, - }; - onNavigatorSnapshot(docId, snapshot); - return () => onNavigatorSnapshot(docId, null); - }, [ - docId, - currentUrl, - title, - canGoPrev, - canGoNext, - ordinal, - metadataWidth, - metadataHeight, - navigator.goPrev, - navigator.goNext, - onNavigatorSnapshot, - ]); - const hasPreview = Boolean(currentUrl); - const cardClasses = ['desk-item__card']; - if (!hasPreview) cardClasses.push('desk-item__card--empty'); - const showNav = hasPreview && (cardinality > 1 || canGoPrev || canGoNext); - - return ( -
{ - if (event instanceof DragEvent) { - event.preventDefault(); - } - }} - > - {hasPreview ? ( - {title} event.preventDefault()} - /> - ) : ( -
-
DOC
-
- {title} -
-
- )} - {showNav ? ( -
- - -
- ) : null} -
- ); -}; - -const generateInitialLayout = ( - entries, - { - canvasWidth, - canvasHeight, - padding, - startZ = 0, - rotationRange = ROTATION_RANGE, - minSpacing = 48, - shelfWidth = 0, - }, -) => { - const layout = new Map(); - let currentZ = startZ; - let maxZ = startZ; - - if (!entries.length) { - return { layout, maxZ }; - } - - const shelfOffset = Math.max(shelfWidth, 0); - const spacingBuffer = Math.max(minSpacing, 0); - const placed = []; - - const resolveBounds = (width, height) => { - const halfWidth = width / 2; - const halfHeight = height / 2; - return { - minCenterX: padding + halfWidth, - maxCenterX: Math.max( - padding + halfWidth, - canvasWidth - shelfOffset - padding - halfWidth, - ), - minCenterY: padding + halfHeight, - maxCenterY: Math.max(padding + halfHeight, canvasHeight - padding - halfHeight), - }; - }; - - const evaluateCandidateSpacing = (x, y, radius) => { - if (!placed.length) { - return Number.POSITIVE_INFINITY; - } - let best = Number.POSITIVE_INFINITY; - for (let i = 0; i < placed.length; i += 1) { - const item = placed[i]; - const dx = item.x - x; - const dy = item.y - y; - const distance = Math.sqrt(dx * dx + dy * dy) - item.radius - radius - spacingBuffer; - if (distance < best) { - best = distance; - } - } - return best; - }; - - entries.forEach((entry) => { - const width = Number(entry.width) || 0; - const height = Number(entry.height) || 0; - if (!entry.id || width <= 0 || height <= 0) { - return; - } - - const { minCenterX, maxCenterX, minCenterY, maxCenterY } = resolveBounds(width, height); - - const radius = Math.sqrt(width * width + height * height) / 2; - - let bestScore = -Infinity; - let bestX = (minCenterX + maxCenterX) / 2; - let bestY = (minCenterY + maxCenterY) / 2; - const samplesPerAxis = 14; - for (let gx = 0; gx < samplesPerAxis; gx += 1) { - const fracX = (gx + 0.5) / samplesPerAxis; - for (let gy = 0; gy < samplesPerAxis; gy += 1) { - const fracY = (gy + 0.5) / samplesPerAxis; - const candidateX = minCenterX + fracX * (maxCenterX - minCenterX); - const candidateY = minCenterY + fracY * (maxCenterY - minCenterY); - const edgeSpacing = Math.min( - candidateX - minCenterX, - maxCenterX - candidateX, - candidateY - minCenterY, - maxCenterY - candidateY, - ) - spacingBuffer * 0.5; - if (edgeSpacing <= 0) { - continue; - } - const neighborSpacing = evaluateCandidateSpacing(candidateX, candidateY, radius); - const score = Math.min(edgeSpacing, neighborSpacing); - if (score > bestScore) { - bestScore = score; - bestX = candidateX; - bestY = candidateY; - } - } - } - const centerX = clamp(bestX, minCenterX, maxCenterX); - const centerY = clamp(bestY, minCenterY, maxCenterY); - - const rotation = randomRangeFromSeed( - buildKey(entry.id, 'rotation'), - -rotationRange, - rotationRange, - ); - - currentZ += 1; - layout.set(entry.id, { - centerX, - centerY, - rotation, - z: currentZ, - width, - height, - }); - maxZ = Math.max(maxZ, currentZ); - - placed.push({ x: centerX, y: centerY, radius }); - }); - - return { layout, maxZ }; -}; - -const createDragPreview = (node, clientX, clientY) => { - if (!(node instanceof HTMLElement)) { - return null; - } - const rect = node.getBoundingClientRect(); - const safeClientX = Number.isFinite(clientX) ? clientX : rect.left + rect.width / 2; - const safeClientY = Number.isFinite(clientY) ? clientY : rect.top + rect.height / 2; - const offsetX = clamp(safeClientX - rect.left, 0, rect.width); - const offsetY = clamp(safeClientY - rect.top, 0, rect.height); - const clone = node.cloneNode(true); - clone.style.position = 'absolute'; - clone.style.top = '-9999px'; - clone.style.left = '-9999px'; - clone.style.pointerEvents = 'none'; - clone.style.opacity = '1'; - clone.style.transform = 'none'; - document.body.appendChild(clone); - return { clone, offsetX, offsetY }; -}; - -const cleanupPreview = (previewNode) => { - if (previewNode && previewNode.parentNode) { - previewNode.parentNode.removeChild(previewNode); - } -}; - -const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => { - const [metadataMap, setMetadataMap] = useState(() => new Map()); - - useEffect(() => { - let cancelled = false; - const docs = Array.isArray(documents) ? documents : []; - if (!docs.length) { - setMetadataMap(new Map()); - return () => { - cancelled = true; - }; - } - - const fetchMetadataForDoc = async (doc) => { - if (!doc?.id) { - return null; - } - - const docId = String(doc.id); - const resolveAsset = (type) => (typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, type) : null); - - let asset = resolveAsset('preview') || resolveAsset('thumbnail'); - let view = createAssetView(asset); - let metadata = view.getPrimaryMetadata(); - - const hasDimensions = (meta) => - Number.isFinite(Number(meta?.width)) && Number.isFinite(Number(meta?.height)) && - Number(meta.width) > 0 && Number(meta.height) > 0; - - if (!hasDimensions(metadata) && ensureAssetUrl && docId && asset?.id) { - try { - const ensured = await ensureAssetUrl(doc.id, asset, { start: 1, limit: 1 }); - if (ensured) { - asset = ensured; - view = createAssetView(asset); - metadata = view.getPrimaryMetadata(); - } - } catch (error) { - console.warn('[desk] ensureDocumentSize metadata fetch failed', error); - } - } - - if (!hasDimensions(metadata)) { - return null; - } - - const width = Number(metadata.width); - const height = Number(metadata.height); - if (!Number.isFinite(width) || !Number.isFinite(height)) { - return null; - } - - return [docId, { width, height }]; - }; - - Promise.all(docs.map((doc) => fetchMetadataForDoc(doc))) - .then((entries) => { - if (cancelled) return; - const next = new Map(); - entries.forEach((entry) => { - if (entry) { - next.set(entry[0], entry[1]); - } - }); - setMetadataMap(next); - }) - .catch(() => { - if (!cancelled) { - setMetadataMap(new Map()); - } - }); - - return () => { - cancelled = true; - }; - }, [documents, getDocumentAsset, ensureAssetUrl]); - - return metadataMap; -}; - -function seededRandom(input) { - const text = String(input); - let hash = 2166136261; - for (let index = 0; index < text.length; index += 1) { - hash ^= text.charCodeAt(index); - hash = Math.imul(hash, 16777619); - } - return (hash >>> 0) / 4294967295; -} - -function randomRangeFromSeed(seedKey, min, max) { - const span = max - min; - if (span <= 0) return min; - const seed = seededRandom(seedKey); - return min + seed * span; -} - -function buildKey(docId, suffix) { - return `${docId}::${suffix}`; -} - -const clampCardDimensions = (width, height) => { - const w = Number(width); - const h = Number(height); - - if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) { - return null; - } - - const low = Math.max(CARD_MIN / w, CARD_MIN / h); - const high = Math.min(CARD_MAX / w, CARD_MAX / h); - - const candidates = []; - const addCandidate = (scale) => { - if (Number.isFinite(scale) && scale > 0) { - candidates.push(scale); - } - }; - - addCandidate(1); - addCandidate(low); - addCandidate(high); - - const best = candidates.reduce((acc, scale) => { - const scaledWidth = w * scale; - const scaledHeight = h * scale; - const violation = Math.max( - Math.max(CARD_MIN - scaledWidth, 0), - Math.max(scaledWidth - CARD_MAX, 0), - Math.max(CARD_MIN - scaledHeight, 0), - Math.max(scaledHeight - CARD_MAX, 0), - ); - const deviation = Math.abs(scale - 1); - if (!acc || violation < acc.violation || (violation === acc.violation && deviation < acc.deviation)) { - return { scale, violation, deviation }; - } - return acc; - }, null); - - const scale = best ? best.scale : 1; - return { - width: Math.round(w * scale), - height: Math.round(h * scale), - }; -}; - -const computeFallbackCardSize = (docId) => { - const baseSeed = seededRandom(`${docId}:fallback-size`); - const aspectSeed = seededRandom(`${docId}:fallback-aspect`); - - const width = CARD_MIN + baseSeed * (CARD_MAX - CARD_MIN); - const isPortrait = aspectSeed < 0.5; - const normalizedSeed = isPortrait ? aspectSeed / 0.5 : (aspectSeed - 0.5) / 0.5; - const aspectRange = 0.75; // keeps generated ratio pleasant but varied - const aspect = isPortrait - ? 1 + normalizedSeed * aspectRange - : 1 / (1 + normalizedSeed * aspectRange); - const height = width * aspect; - - return clampCardDimensions(width, height); -}; - -const DesktopWorkspace = ({ - documents = [], - searchResults = null, - onDocumentOpen, - onInspectDocument = null, - onEntryPointer = null, - onDocumentStackSelect = null, - onPromoteSelection = null, - onAssignTagToDocument = null, - onRemoveTagFromDocument = null, - ensureAssetUrl = null, - getDocumentAsset = () => null, - activeTagIds = [], - selectedDocumentIds = [], - onClearSelection = null, - detailPanelOpen = false, - onCloseDetailPanel = null, - helpOpen = false, - onHelpClose = null, - tenantId = null, - viewId = 'default', -}) => { - const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]); - - const allowLayoutPersistence = Boolean(tenantId && viewId && viewId.startsWith('folder:')); - - const containerRef = useRef(null); - const layoutRef = useRef(new Map()); - const itemRefs = useRef(new Map()); - const zCounterRef = useRef(10); - const layoutDirtyRef = useRef(false); - - const [layoutSnapshot, setLayoutSnapshot] = useState(() => new Map()); - - const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); - const [visibleDocIds, setVisibleDocIds] = useState(() => new Set()); - const [draggingId, setDraggingId] = useState(null); - const [overlayDocId, setOverlayDocId] = useState(null); - const [overlayOriginRect, setOverlayOriginRect] = useState(null); - const [overlayOriginTransform, setOverlayOriginTransform] = useState(null); - const [previewSnapshots, setPreviewSnapshots] = useState(() => new Map()); - const [docSizeVersion, setDocSizeVersion] = useState(0); - const [tagDropTargetId, setTagDropTargetId] = useState(null); - const [pendingTagDocId, setPendingTagDocId] = useState(null); - const [pendingRemovalTag, setPendingRemovalTag] = useState(null); - const draggingTagRef = useRef(null); - const pendingDocTagDragRef = useRef(null); - const docSizeMapRef = useRef(new Map()); - const documentLookupRef = useRef(new Map()); - const removalCursorActiveRef = useRef(false); - const previewMetadata = usePreviewMetadata(items, getDocumentAsset, ensureAssetUrl); - const documentLookup = useMemo(() => { - const map = new Map(); - items.forEach((doc) => { - const key = doc?.id != null ? String(doc.id) : null; - if (key) { - map.set(key, doc); - } - }); - return map; - }, [items]); - - useEffect(() => { - documentLookupRef.current = documentLookup; - }, [documentLookup]); - - const persistedLayoutRef = useRef(new Map()); - - const markLayoutDirty = useCallback(() => { - layoutDirtyRef.current = true; - }, []); - - const applySnapshotDimensions = useCallback((docKey, snapshot) => { - const width = Number(snapshot?.width); - const height = Number(snapshot?.height); - if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { - return; - } - const normalized = clampCardDimensions(width, height); - if (!normalized) { - return; - } - const existing = docSizeMapRef.current.get(docKey); - if (existing && existing.width === normalized.width && existing.height === normalized.height) { - return; - } - const next = new Map(docSizeMapRef.current); - next.set(docKey, { ...normalized, source: 'snapshot' }); - docSizeMapRef.current = next; - setDocSizeVersion((value) => value + 1); - }, []); - const handleNavigatorSnapshot = useCallback( - (docId, snapshot) => { - const docKey = docId != null ? String(docId) : null; - if (!docKey) { - return; - } - - setPreviewSnapshots((previous) => { - const prevSnapshot = previous.get(docKey); - if (!snapshot) { - if (!previous.has(docKey)) { - return previous; - } - const next = new Map(previous); - next.delete(docKey); - return next; - } - - const next = new Map(previous); - const sameSnapshot = - prevSnapshot && - prevSnapshot.url === snapshot.url && - prevSnapshot.alt === snapshot.alt && - prevSnapshot.canGoPrev === snapshot.canGoPrev && - prevSnapshot.canGoNext === snapshot.canGoNext && - prevSnapshot.goPrev === snapshot.goPrev && - prevSnapshot.goNext === snapshot.goNext && - prevSnapshot.ordinal === snapshot.ordinal && - prevSnapshot.width === snapshot.width && - prevSnapshot.height === snapshot.height; - if (sameSnapshot) { - return previous; - } - next.set(docKey, snapshot); - return next; - }); - - if (snapshot) { - applySnapshotDimensions(docKey, snapshot); - } - }, - [applySnapshotDimensions], - ); - const activeTagSet = useMemo(() => { - if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) { - return new Set(); - } - const set = new Set(); - activeTagIds.forEach((id) => { - if (id != null) { - set.add(String(id)); - } - }); - return set; - }, [activeTagIds]); - - const resolvePreviewDimensions = useCallback( - (doc) => { - if (!doc?.id) { - return null; - } - return previewMetadata.get(String(doc.id)) || null; - }, - [previewMetadata], - ); - - useEffect(() => { - if (!ensureAssetUrl) { - return; - } - - visibleDocIds.forEach((docId) => { - const doc = documentLookup.get(docId); - if (!doc) { - return; - } - resolveDocumentAssetUrl(doc, 'preview', { - ensureAssetUrl, - getAsset: getDocumentAsset, - }); - }); - }, [visibleDocIds, ensureAssetUrl, getDocumentAsset, documentLookup]); - - const requestCanvasFocus = useCallback(() => { - const canvas = containerRef.current; - if (!canvas || typeof canvas.focus !== 'function') { - return; - } - - const focusTarget = () => { - try { - if (DEBUG_FOCUS) { - console.log('[desk] focusCanvas -> attempting focus', canvas); - } - canvas.focus({ preventScroll: true }); - if (DEBUG_FOCUS) { - console.log('[desk] focusCanvas: applied focus. activeElement:', document?.activeElement); - } - } catch (error) { - if (DEBUG_FOCUS) { - console.warn('[desk] focusTarget failed to focus canvas', error); - } - } - }; - - if (typeof window === 'undefined') { - focusTarget(); - return; - } - - if (DEBUG_FOCUS) { - console.log('[desk] requestCanvasFocus -> scheduling deferred focus'); - } - - if (typeof window.requestAnimationFrame === 'function') { - window.requestAnimationFrame(() => { - if (DEBUG_FOCUS) { - console.log('[desk] requestCanvasFocus -> executing deferred focus (rAF)'); - } - focusTarget(); - }); - } else { - setTimeout(() => { - if (DEBUG_FOCUS) { - console.log('[desk] requestCanvasFocus -> executing deferred focus (timeout)'); - } - focusTarget(); - }, 0); - } - }, []); - - const updateRemovalCursor = useCallback((active) => { - if (typeof document === 'undefined') { - return; - } - if (removalCursorActiveRef.current === active) { - return; - } - const body = document.body; - if (!body) { - return; - } - removalCursorActiveRef.current = active; - if (active) { - body.classList.add('desk-cursor-remove'); - } else { - body.classList.remove('desk-cursor-remove'); - } - }, []); - - useEffect( - () => () => { - updateRemovalCursor(false); - }, - [updateRemovalCursor], - ); - - const isTagTransfer = useCallback((event) => isTagTransferEvent(event), []); - - const handleTagDragEnd = useCallback(() => { - updateRemovalCursor(false); - setTagDropTargetId(null); - }, [updateRemovalCursor]); - - const finalizeTagDrag = useCallback( - (dropEffect = 'none') => { - const state = draggingTagRef.current; - if (!state) { - updateRemovalCursor(false); - return; - } - - draggingTagRef.current = null; - - const node = state.element; - const showNode = () => { - if (node instanceof HTMLElement) { - node.classList.remove('is-drag-hidden'); - } - }; - const scheduleShowNode = () => { - if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { - window.requestAnimationFrame(showNode); - } else { - setTimeout(showNode, 0); - } - }; - - cleanupPreview(state.previewClone); - - const shouldRemove = - !state.dropHandled && - dropEffect === 'none' && - state.sourceDocId && - typeof onRemoveTagFromDocument === 'function' && - (state.distance || 0) >= TAG_REMOVE_DISTANCE; - - if (!shouldRemove) { - scheduleShowNode(); - updateRemovalCursor(false); - return; - } - - updateRemovalCursor(false); - setPendingRemovalTag({ docId: state.sourceDocId, tagId: state.tagId }); - void (async () => { - try { - await onRemoveTagFromDocument(state.sourceDocId, state.tagId); - if (DEBUG_DROP) { - console.log('[desk] finalizeTagDrag -> removed tag due to fling'); - } - } catch (error) { - console.error('Failed to remove tag after drag', error); - scheduleShowNode(); - } finally { - setPendingRemovalTag(null); - } - })(); - }, - [onRemoveTagFromDocument, updateRemovalCursor, setPendingRemovalTag], - ); - - const ensureDocumentSize = useCallback((doc) => { - if (!doc?.id) { - return null; - } - return docSizeMapRef.current.get(String(doc.id)) || null; - }, []); - - useEffect(() => { - const current = docSizeMapRef.current; - const next = new Map(current); - const itemKeys = new Set(items.filter((doc) => doc?.id != null).map((doc) => String(doc.id))); - let changed = false; - - items.forEach((doc) => { - if (!doc?.id) { - return; - } - const key = String(doc.id); - const existing = next.get(key) || null; - const meta = previewMetadata.get(key); - if (meta) { - const normalized = clampCardDimensions(meta.width, meta.height); - if (normalized) { - if (existing?.source === 'snapshot') { - return; - } - if (!existing || existing.width !== normalized.width || existing.height !== normalized.height || existing.source !== 'metadata') { - next.set(key, { ...normalized, source: 'metadata' }); - changed = true; - } - return; - } - } - - if (!existing) { - const fallback = computeFallbackCardSize(key); - if (fallback) { - next.set(key, { ...fallback, source: 'fallback' }); - changed = true; - } - } - }); - - current.forEach((_, key) => { - if (!itemKeys.has(key)) { - next.delete(key); - changed = true; - } - }); - - if (changed) { - docSizeMapRef.current = next; - setDocSizeVersion((value) => value + 1); - } - }, [items, previewMetadata]); - - const overlayDisplay = useMemo(() => { - if (!overlayDocId) { - return null; - } - const snapshot = previewSnapshots.get(overlayDocId); - if (!snapshot || !snapshot.url) { - return null; - } - const doc = documentLookup.get(overlayDocId); - const alt = snapshot.alt || doc?.title; - return { - url: snapshot.url, - alt, - canGoPrev: snapshot.canGoPrev, - canGoNext: snapshot.canGoNext, - goPrev: snapshot.goPrev, - goNext: snapshot.goNext, - }; - }, [overlayDocId, previewSnapshots, documentLookup]); - - const closeOverlay = useCallback(() => { - setOverlayDocId(null); - setOverlayOriginRect(null); - setOverlayOriginTransform(null); - }, []); - - useEffect(() => { - if (overlayDocId && !documentLookup.has(overlayDocId)) { - setOverlayDocId(null); - setOverlayOriginRect(null); - setOverlayOriginTransform(null); - } - }, [overlayDocId, documentLookup]); - - const resolveBaseMetrics = useCallback( - (doc, cardWidth, cardHeight) => { - const previewDims = doc ? resolvePreviewDimensions(doc) : null; - if (previewDims?.width && previewDims?.height) { - const baseWidth = Math.max(previewDims.width, cardWidth); - const baseHeight = Math.max(previewDims.height, cardHeight); - const scaleX = cardWidth / baseWidth; - const scaleY = cardHeight / baseHeight; - const baseScale = Math.min(scaleX, scaleY, 1); - return { - baseWidth, - baseHeight, - baseScale: Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1, - }; - } - return { - baseWidth: cardWidth, - baseHeight: cardHeight, - baseScale: 1, - }; - }, - [resolvePreviewDimensions], - ); -const recalcVisibleDocIds = useCallback(() => { - const layoutMap = layoutRef.current; - const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH; - const canvasHeight = canvasSize.height || DEFAULT_CANVAS_HEIGHT; - - if (!layoutMap.size || canvasWidth <= 0 || canvasHeight <= 0) { - setVisibleDocIds((prev) => (prev.size ? new Set() : prev)); - return; - } - - const viewport = [ - { x: 0, y: 0 }, - { x: canvasWidth, y: 0 }, - { x: canvasWidth, y: canvasHeight }, - { x: 0, y: canvasHeight }, - ]; - - const entries = []; - layoutMap.forEach((entry, rawId) => { - const docKey = rawId != null ? String(rawId) : null; - if (!docKey) { - return; - } - const doc = documentLookup.get(docKey); - if (!doc) { - return; - } - const sizeInfo = ensureDocumentSize(doc); - if (!sizeInfo) { - return; - } - const { width: cardWidth, height: cardHeight } = sizeInfo; - const rotationDeg = Number(entry?.rotation) || 0; - const rotationRad = (rotationDeg * Math.PI) / 180; - const cosRot = Math.cos(rotationRad); - const sinRot = Math.sin(rotationRad); - const halfWidth = cardWidth / 2; - const halfHeight = cardHeight / 2; - const localCorners = [ - { x: -halfWidth, y: -halfHeight }, - { x: halfWidth, y: -halfHeight }, - { x: halfWidth, y: halfHeight }, - { x: -halfWidth, y: halfHeight }, - ]; - const centerX = entry?.centerX ?? CANVAS_PADDING + cardWidth / 2; - const centerY = entry?.centerY ?? CANVAS_PADDING + cardHeight / 2; - const corners = localCorners.map(({ x, y }) => ({ - x: centerX + x * cosRot - y * sinRot, - y: centerY + x * sinRot + y * cosRot, - })); - const clipped = clipPolygon(corners, viewport); - if (!clipped.length) { - return; - } - entries.push({ - key: docKey, - z: entry?.z ?? 0, - polygon: clipped, - }); - }); - - if (!entries.length) { - setVisibleDocIds((prev) => (prev.size ? new Set() : prev)); - return; - } - - entries.sort((a, b) => (b.z || 0) - (a.z || 0)); - - const visiblePolygons = []; - const result = new Set(); - - entries.forEach(({ key, polygon }) => { - if (polygon.length < 3) { - return; - } - - let fullyCovered = true; - for (let i = 0; i < polygon.length; i += 1) { - const point = polygon[i]; - const inside = visiblePolygons.some((poly) => isPointInsideConvex(point, poly)); - if (!inside) { - fullyCovered = false; - break; - } - } - - if (fullyCovered) { - const centroid = polygonCentroid(polygon); - if (!visiblePolygons.some((poly) => isPointInsideConvex(centroid, poly))) { - fullyCovered = false; - } - } - - if (!fullyCovered) { - result.add(key); - visiblePolygons.push(polygon); - } - }); - - setVisibleDocIds((prev) => { - if (prev.size === result.size) { - let same = true; - prev.forEach((id) => { - if (!result.has(id)) { - same = false; - } - }); - if (same) { - result.forEach((id) => { - if (!prev.has(id)) { - same = false; - } - }); - } - if (same) { - return prev; - } - } - return result; - }); - }, [ - canvasSize.width, - canvasSize.height, - ensureDocumentSize, - documentLookup, - ]); - - const persistLayoutSnapshot = useCallback( - async (snapshot, force = false) => { - if (!allowLayoutPersistence || !tenantId || !viewId) { - return; - } - if (!force && !layoutDirtyRef.current) { - return; - } - layoutDirtyRef.current = false; - const merged = new Map(persistedLayoutRef.current); - snapshot.forEach((entry, docId) => { - if (!docId || !entry) { - return; - } - const centerX = Number(entry.centerX); - const centerY = Number(entry.centerY); - if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) { - return; - } - const rotation = Number.isFinite(Number(entry.rotation)) ? Number(entry.rotation) : 0; - const z = Number.isFinite(Number(entry.z)) ? Number(entry.z) : undefined; - merged.set(docId, { centerX, centerY, rotation, z }); - }); - - persistedLayoutRef.current = merged; - - const records = []; - merged.forEach((entry, docId) => { - if (!docId || !entry) { - return; - } - records.push({ - documentId: docId, - centerX: entry.centerX, - centerY: entry.centerY, - rotation: entry.rotation ?? 0, - zIndex: entry.z ?? 0, - }); - }); - - await upsertLayoutRecords({ tenantId, viewId, entries: records }); - }, - [tenantId, viewId, allowLayoutPersistence], - ); - - const syncLayoutSnapshot = useCallback((force = false) => { - const snapshot = new Map(layoutRef.current); - setLayoutSnapshot(snapshot); - persistLayoutSnapshot(snapshot, force); - }, [persistLayoutSnapshot]); - - useLayoutEffect(() => { - const container = containerRef.current; - if (!container) return () => {}; - - const nodeEnv = typeof globalThis !== 'undefined' ? globalThis.process?.env?.NODE_ENV : undefined; - if (nodeEnv !== 'production') { - console.log('[desk] canvas element', container); - } - - const commitSize = () => { - const rect = container.getBoundingClientRect(); - const width = Math.floor(rect.width) || 0; - const height = Math.floor(rect.height) || 0; - setCanvasSize((prev) => { - if (prev.width === width && prev.height === height) { - return prev; - } - return { width, height }; - }); - }; - - commitSize(); - - if (typeof ResizeObserver === 'undefined') { - window.addEventListener('resize', commitSize); - return () => { - window.removeEventListener('resize', commitSize); - }; - } - - const observer = new ResizeObserver(() => { - commitSize(); - }); - - observer.observe(container); - return () => observer.disconnect(); - }, []); - useEffect(() => { - if (!allowLayoutPersistence) { - persistedLayoutRef.current = new Map(); - layoutDirtyRef.current = false; - return; - } - if (!tenantId || !viewId) { - return; - } - let cancelled = false; - - const loadLayouts = async () => { - const records = await fetchLayoutRecords({ tenantId, viewId }); - if (cancelled) { - return; - } - const map = new Map(); - records.forEach((record) => { - if (!record || !record.documentId) { - return; - } - map.set(String(record.documentId), { - centerX: Number(record.centerX) || 0, - centerY: Number(record.centerY) || 0, - rotation: Number(record.rotation) || 0, - z: Number(record.zIndex) || 0, - }); - }); - persistedLayoutRef.current = map; - layoutDirtyRef.current = false; - if (records.length) { - zCounterRef.current = Math.max( - zCounterRef.current, - ...records.map((r) => Number(r.zIndex) || 0), - ); - } - setDocSizeVersion((value) => value + 1); - }; - - loadLayouts(); - - return () => { - cancelled = true; - }; - }, [tenantId, viewId, allowLayoutPersistence]); - - useLayoutEffect(() => { - if (!containerRef.current || !canvasSize.width || !canvasSize.height) { - return; - } - - if (!items.length) { - layoutRef.current = new Map(); - syncLayoutSnapshot(); - return; - } - - const missingSizes = items.some((doc) => !ensureDocumentSize(doc)); - if (missingSizes) { - return; - } - - const previous = layoutRef.current; - const next = new Map(); - let maxZ = zCounterRef.current; - const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH; - const canvasHeight = canvasSize.height || DEFAULT_CANVAS_HEIGHT; - const docsNeedingLayout = []; - - items.forEach((doc) => { - const sizeInfo = ensureDocumentSize(doc); - if (!sizeInfo) { - return; - } - const { width: docWidth, height: docHeight } = sizeInfo; - const halfWidth = docWidth / 2; - const halfHeight = docHeight / 2; - - const minCenterX = CANVAS_PADDING + halfWidth; - const maxCenterX = Math.max(minCenterX, canvasWidth - CANVAS_PADDING - halfWidth); - const minCenterY = CANVAS_PADDING + halfHeight; - const maxCenterY = Math.max(minCenterY, canvasHeight - CANVAS_PADDING - halfHeight); - - const docKey = doc?.id != null ? String(doc.id) : null; - const persisted = docKey ? persistedLayoutRef.current.get(docKey) : null; - let existing = previous.get(doc.id) || null; - if (persisted) { - existing = existing ? { ...existing, ...persisted } : { ...persisted }; - } - if (existing) { - const defaultCenterX = (minCenterX + maxCenterX) / 2; - const defaultCenterY = (minCenterY + maxCenterY) / 2; - const prevCenterX = typeof existing.centerX === 'number' ? existing.centerX : defaultCenterX; - const prevCenterY = typeof existing.centerY === 'number' ? existing.centerY : defaultCenterY; - const centerX = clamp(prevCenterX, minCenterX, maxCenterX); - const centerY = clamp(prevCenterY, minCenterY, maxCenterY); - const rotation = existing.rotation ?? 0; - const z = existing.z ?? maxZ; - maxZ = Math.max(maxZ, z); - next.set(doc.id, { centerX, centerY, rotation, z, width: docWidth, height: docHeight }); - return; - } - - docsNeedingLayout.push({ - id: doc.id, - width: docWidth, - height: docHeight, - seedKey: doc.id, - }); - }); - - if (docsNeedingLayout.length) { - const { layout: generatedLayout, maxZ: updatedMaxZ } = generateInitialLayout( - docsNeedingLayout, - { - canvasWidth, - canvasHeight, - padding: CANVAS_PADDING, - startZ: maxZ, - rotationRange: ROTATION_RANGE, - minSpacing: 48, - shelfWidth: 0, - }, - ); - generatedLayout.forEach((entry, docId) => { - next.set(docId, entry); - }); - maxZ = Math.max(maxZ, updatedMaxZ); - } - - layoutRef.current = next; - zCounterRef.current = Math.max(zCounterRef.current, maxZ); - syncLayoutSnapshot(); - recalcVisibleDocIds(); - }, [ - items, - canvasSize.width, - canvasSize.height, - docSizeVersion, - ensureDocumentSize, - syncLayoutSnapshot, - recalcVisibleDocIds, - ]); - useEffect(() => { - recalcVisibleDocIds(); - }, [recalcVisibleDocIds, items.length, canvasSize.width, canvasSize.height, docSizeVersion]); - - useEffect(() => { - if (draggingId && !items.some((doc) => doc.id === draggingId)) { - setDraggingId(null); - } - }, [draggingId, items]); - - const bringToFront = useCallback( - (docId) => { - zCounterRef.current += 1; - const entry = layoutRef.current.get(docId); - if (!entry) return; - const updated = { ...entry, z: zCounterRef.current }; - layoutRef.current.set(docId, updated); - markLayoutDirty(); - syncLayoutSnapshot(); - recalcVisibleDocIds(); - }, - [syncLayoutSnapshot, recalcVisibleDocIds, markLayoutDirty], - ); - - const openOverlayForDoc = useCallback( - (docId, originInfo = null) => { - if (!docId) { - return; - } - const docKey = String(docId); - const snapshot = previewSnapshots.get(docKey); - if (!snapshot || !snapshot.url) { - return; - } - const container = itemRefs.current.get(docId); - const imageNode = container?.querySelector?.('.desk-item__card img'); - if (!container || !imageNode) { - return; - } - const rect = imageNode.getBoundingClientRect(); - let originTransform = null; - if (originInfo) { - const { rotation = 0, scale = 1, width: originWidth, height: originHeight } = originInfo; - originTransform = { - rotation, - scaleX: scale, - scaleY: scale, - baseWidth: originWidth, - baseHeight: originHeight, - }; - } - if (!originTransform) { - const entry = layoutRef.current.get(docId) || null; - const doc = documentLookup.get(docKey) || null; - const sizeInfo = ensureDocumentSize(doc); - if (!sizeInfo) { - return; - } - const { width: cardWidth, height: cardHeight } = sizeInfo; - const { baseWidth, baseHeight, baseScale } = resolveBaseMetrics(doc, cardWidth, cardHeight); - const effectiveWidth = baseWidth * baseScale; - const effectiveHeight = baseHeight * baseScale; - originTransform = { - rotation: entry?.rotation ?? 0, - scaleX: baseScale, - scaleY: baseScale, - baseWidth: Number.isFinite(effectiveWidth) && effectiveWidth > 0 ? effectiveWidth : cardWidth, - baseHeight: Number.isFinite(effectiveHeight) && effectiveHeight > 0 ? effectiveHeight : cardHeight, - }; - } - bringToFront(docId); - setOverlayOriginRect(rect); - setOverlayOriginTransform(originTransform); - setOverlayDocId(docKey); - }, - [ - bringToFront, - previewSnapshots, - itemRefs, - setOverlayOriginTransform, - ensureDocumentSize, - resolveBaseMetrics, - documentLookup, - layoutRef, - ], - ); - const handleTagDragEnterDoc = useCallback( - (event, docId) => { - if (!isTagTransfer(event)) { - return; - } - preventAll(event); - updateRemovalCursor(false); - if (tagDropTargetId !== docId) { - setTagDropTargetId(docId); - } - }, - [isTagTransfer, tagDropTargetId, updateRemovalCursor], - ); - - const handleTagDragOverDoc = useCallback( - (event, docId) => { - if (!isTagTransfer(event)) { - return; - } - preventAll(event); - updateRemovalCursor(false); - const activeDrag = draggingTagRef.current; - event.dataTransfer.dropEffect = activeDrag?.sourceDocId ? 'move' : 'copy'; - if (tagDropTargetId !== docId) { - setTagDropTargetId(docId); - } - }, - [isTagTransfer, tagDropTargetId, updateRemovalCursor], - ); - - const handleTagDragLeaveDoc = useCallback((event, docId) => { - if (!isTagTransfer(event)) { - return; - } - preventAll(event); - if ( - event.currentTarget instanceof HTMLElement && - event.relatedTarget instanceof Node && - event.currentTarget.contains(event.relatedTarget) - ) { - return; - } - setTagDropTargetId((current) => (current === docId ? null : current)); - updateRemovalCursor(false); - }, [isTagTransfer, updateRemovalCursor]); - - const markActiveTagDropHandled = useCallback((tagId, sourceDocId = null) => { - const state = draggingTagRef.current; - if (!state) { - return; - } - if (state.tagId !== tagId) { - return; - } - if (sourceDocId && state.sourceDocId !== sourceDocId) { - return; - } - state.dropHandled = true; - }, []); - - const handleTagDropOnDoc = useCallback( - async (event, doc) => { - if (!doc || !isTagTransfer(event) || typeof onAssignTagToDocument !== 'function') { - if (DEBUG_DROP) { - console.log('[desk] handleTagDropOnDoc: drop ignored', { doc, hasTransfer: isTagTransfer(event) }); - } - return; - } - preventAll(event); - if (DEBUG_DROP) { - console.log('[desk] handleTagDropOnDoc: drop accepted for doc', doc.id, 'event', event); - } - setTagDropTargetId(null); - - const payload = parseTagTransferPayload(event); - if (!payload && DEBUG_DROP) { - console.warn('[desk] handleTagDropOnDoc: failed to parse payload'); - } - - if (!payload?.id) { - if (DEBUG_DROP) { - console.log('[desk] handleTagDropOnDoc: missing tag id payload', payload); - } - return; - } - - const tagId = payload.id; - const sourceDocId = payload.sourceDocId || null; - if (DEBUG_DROP) { - console.log('[desk] handleTagDropOnDoc: parsed payload', { tagId, sourceDocId }); - } - - if (sourceDocId && sourceDocId === doc.id) { - markActiveTagDropHandled(tagId, sourceDocId); - if (DEBUG_DROP) { - console.log('[desk] handleTagDropOnDoc: drop from same doc ignored', tagId); - } - return; - } - - const alreadyAssigned = Array.isArray(doc.tags) - ? doc.tags.some((tag) => tag.id === tagId) - : false; - if (alreadyAssigned) { - markActiveTagDropHandled(tagId, sourceDocId); - if (DEBUG_DROP) { - console.log('[desk] handleTagDropOnDoc: tag already assigned', tagId); - } - return; - } - - const movingBetweenDocuments = Boolean(sourceDocId && sourceDocId !== doc.id); - if (DEBUG_DROP) { - console.log('[desk] handleTagDropOnDoc: movingBetweenDocuments', movingBetweenDocuments); - } - - setPendingTagDocId(doc.id); - try { - await onAssignTagToDocument({ documentId: doc.id, tagId, tag: payload }); - markActiveTagDropHandled(tagId, sourceDocId); - if (DEBUG_DROP) { - console.log('[desk] handleTagDropOnDoc: assigned tag', tagId, 'to doc', doc.id); - } - if (movingBetweenDocuments && typeof onRemoveTagFromDocument === 'function') { - setPendingRemovalTag({ docId: sourceDocId, tagId }); - try { - await onRemoveTagFromDocument(sourceDocId, tagId); - if (DEBUG_DROP) { - console.log('[desk] handleTagDropOnDoc: removed tag from source doc', sourceDocId); - } - } finally { - setPendingRemovalTag(null); - } - } - } finally { - setPendingTagDocId(null); - if (DEBUG_DROP) { - console.log('[desk] handleTagDropOnDoc: finalizing drop for tag', tagId); - } - handleTagDragEnd(); - finalizeTagDrag(payload?.sourceDocId ? 'move' : 'copy'); - } - }, - [ - isTagTransfer, - markActiveTagDropHandled, - onAssignTagToDocument, - onRemoveTagFromDocument, - handleTagDragEnd, - finalizeTagDrag, - ], - ); - - const handleCanvasDragOver = useCallback( - (event) => { - if (!isTagTransfer(event)) { - return; - } - preventAll(event); - event.dataTransfer.dropEffect = 'move'; - updateRemovalCursor(true); - setTagDropTargetId(null); - }, - [isTagTransfer, updateRemovalCursor], - ); - - const handleCanvasDragLeave = useCallback( - (event) => { - if (!isTagTransfer(event)) { - return; - } - preventAll(event); - if ( - event.currentTarget instanceof HTMLElement && - event.relatedTarget instanceof Node && - event.currentTarget.contains(event.relatedTarget) - ) { - return; - } - updateRemovalCursor(false); - }, - [isTagTransfer, updateRemovalCursor], - ); - - const handleCanvasDrop = useCallback( - async (event) => { - if (!isTagTransfer(event)) { - return; - } - preventAll(event); - updateRemovalCursor(false); - requestCanvasFocus(); - setTagDropTargetId(null); - - const payload = parseTagTransferPayload(event); - if (!payload?.id) { - return; - } - - const tagId = payload.id; - const sourceDocId = payload.sourceDocId || null; - markActiveTagDropHandled(tagId, sourceDocId); - - if (!sourceDocId || typeof onRemoveTagFromDocument !== 'function') { - return; - } - - setPendingRemovalTag({ docId: sourceDocId, tagId }); - try { - await onRemoveTagFromDocument(sourceDocId, tagId); - } catch (error) { - console.error('Failed to remove tag via canvas drop', error); - } finally { - setPendingRemovalTag(null); - } - }, - [ - isTagTransfer, - markActiveTagDropHandled, - onRemoveTagFromDocument, - requestCanvasFocus, - updateRemovalCursor, - ], - ); - - const handleDocTagPointerDown = useCallback((event, doc, tag) => { - event.stopPropagation(); - if (!doc || !tag) { - pendingDocTagDragRef.current = null; - return; - } - const startX = Number.isFinite(event.clientX) - ? event.clientX - : Number.isFinite(event.pageX) - ? event.pageX - : 0; - const startY = Number.isFinite(event.clientY) - ? event.clientY - : Number.isFinite(event.pageY) - ? event.pageY - : 0; - pendingDocTagDragRef.current = { - docId: doc.id, - tagId: tag.id, - startX, - startY, - }; - updateRemovalCursor(false); - }, [updateRemovalCursor]); - - const handleDocTagDragStart = useCallback( - (event, doc, tag) => { - if (!doc || !tag) { - return; - } - event.stopPropagation(); - try { - if (event.dataTransfer) { - event.dataTransfer.effectAllowed = 'copyMove'; - } - } catch (error) { - console.warn('[desk] Failed to set drag effect', error); - } - - writeTagTransferData(event.dataTransfer, tag, doc.id); - - const pending = pendingDocTagDragRef.current; - const node = event.currentTarget; - const startX = Number.isFinite(event.clientX) - ? event.clientX - : Number.isFinite(event.pageX) - ? event.pageX - : 0; - const startY = Number.isFinite(event.clientY) - ? event.clientY - : Number.isFinite(event.pageY) - ? event.pageY - : 0; - const initialX = - pending && pending.docId === doc.id && pending.tagId === tag.id && Number.isFinite(pending.startX) - ? pending.startX - : startX; - const initialY = - pending && pending.docId === doc.id && pending.tagId === tag.id && Number.isFinite(pending.startY) - ? pending.startY - : startY; - pendingDocTagDragRef.current = null; - let preview = null; - if (node instanceof HTMLElement) { - preview = createDragPreview(node, event.clientX, event.clientY); - if (preview && event.dataTransfer) { - try { - event.dataTransfer.setDragImage(preview.clone, preview.offsetX, preview.offsetY); - } catch (error) { - console.warn('[desk] Failed to set drag image', error); - } - } - } - draggingTagRef.current = { - sourceDocId: doc.id, - tagId: tag.id, - tagLabel: tag.label, - startX: initialX, - startY: initialY, - distance: 0, - element: node instanceof HTMLElement ? node : null, - dropHandled: false, - hasPosition: Number.isFinite(initialX) && Number.isFinite(initialY), - previewClone: preview?.clone || null, - }; - - const hideNode = () => { - if (draggingTagRef.current?.element === node) { - node.classList.add('is-drag-hidden'); - } - }; - if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { - window.requestAnimationFrame(hideNode); - } else { - setTimeout(hideNode, 0); - } - }, - [], - ); - - const handleDocTagDrag = useCallback( - (event) => { - const state = draggingTagRef.current; - if (!state) { - updateRemovalCursor(false); - return; - } - if (!state.hasPosition && Number.isFinite(event.clientX) && Number.isFinite(event.clientY)) { - state.startX = event.clientX; - state.startY = event.clientY; - state.hasPosition = true; - } - const clientX = Number.isFinite(event.clientX) ? event.clientX : state.startX; - const clientY = Number.isFinite(event.clientY) ? event.clientY : state.startY; - const deltaX = clientX - state.startX; - const deltaY = clientY - state.startY; - const distance = Math.hypot(deltaX, deltaY); - if (Number.isFinite(distance)) { - state.distance = distance; - } - const removalActive = - Boolean(state.sourceDocId) && - !tagDropTargetId && - Number.isFinite(state.distance) && - state.distance >= TAG_REMOVE_DISTANCE; - updateRemovalCursor(removalActive); - }, - [tagDropTargetId, updateRemovalCursor], - ); - - const handleDocTagDragEnd = useCallback( - (event) => { - handleTagDragEnd(); - finalizeTagDrag(event?.dataTransfer?.dropEffect || 'none'); - }, - [handleTagDragEnd, finalizeTagDrag], - ); - - const contextValue = useMemo( - () => ({ - layoutRef, - itemRefs, - documentLookup, - ensureDocumentSize, - resolveBaseMetrics, - bringToFront, - setDraggingId, - syncLayoutSnapshot, - canvasSize, - openOverlayForDoc, - recalcVisibleDocIds, - settings: { - canvasPadding: CANVAS_PADDING, - defaultCanvasWidth: DEFAULT_CANVAS_WIDTH, - defaultCanvasHeight: DEFAULT_CANVAS_HEIGHT, - debugDrag: DEBUG_DRAG, - }, - items, - containerRef, - handleCanvasDragOver, - handleCanvasDragLeave, - handleCanvasDrop, - layoutSnapshot, - docSizeVersion, - visibleDocIds, - draggingId, - tagDropTargetId, - pendingTagDocId, - pendingRemovalTag, - onDocumentOpen, - onInspectDocument, - onEntryPointer, - onDocumentStackSelect, - onPromoteSelection, - ensureAssetUrl, - getDocumentAsset, - handleNavigatorSnapshot, - activeTagSet, - handleTagDragEnterDoc, - handleTagDragOverDoc, - handleTagDragLeaveDoc, - handleTagDropOnDoc, - handleDocTagPointerDown, - handleDocTagDragStart, - handleDocTagDrag, - handleDocTagDragEnd, - overlayDisplay, - closeOverlay, - overlayOriginRect, - overlayOriginTransform, - markLayoutDirty, - selectedDocumentIds, - onClearSelection, - detailPanelOpen, - onCloseDetailPanel, - }), - [ - activeTagSet, - bringToFront, - canvasSize, - closeOverlay, - containerRef, - draggingId, - ensureAssetUrl, - ensureDocumentSize, - getDocumentAsset, - handleCanvasDragLeave, - handleCanvasDragOver, - handleCanvasDrop, - handleDocTagDrag, - handleDocTagDragEnd, - handleDocTagDragStart, - handleDocTagPointerDown, - handleNavigatorSnapshot, - handleTagDragEnterDoc, - handleTagDragLeaveDoc, - handleTagDragOverDoc, - handleTagDropOnDoc, - itemRefs, - items, - layoutRef, - layoutSnapshot, - docSizeVersion, - onDocumentOpen, - onInspectDocument, - onEntryPointer, - openOverlayForDoc, - overlayDisplay, - overlayOriginRect, - overlayOriginTransform, - pendingRemovalTag, - pendingTagDocId, - recalcVisibleDocIds, - resolveBaseMetrics, - setDraggingId, - syncLayoutSnapshot, - tagDropTargetId, - visibleDocIds, - documentLookup, - selectedDocumentIds, - onClearSelection, - onDocumentStackSelect, - onPromoteSelection, - markLayoutDirty, - detailPanelOpen, - onCloseDetailPanel, - ], - ); - - return ( - - - - - ); -}; - -const DesktopWorkspaceView = () => { - const { - items, - containerRef, - handleCanvasDragOver, - handleCanvasDragLeave, - handleCanvasDrop, - ensureDocumentSize, - layoutSnapshot, - layoutRef, - itemRefs, - visibleDocIds, - draggingId, - tagDropTargetId, - pendingTagDocId, - pendingRemovalTag, - onDocumentOpen, - ensureAssetUrl, - getDocumentAsset, - handleNavigatorSnapshot, - activeTagSet, - handleTagDragEnterDoc, - handleTagDragOverDoc, - handleTagDragLeaveDoc, - handleTagDropOnDoc, - handleDocTagPointerDown, - handleDocTagDragStart, - handleDocTagDrag, - handleDocTagDragEnd, - overlayDisplay, - closeOverlay, - overlayOriginRect, - overlayOriginTransform, - onEntryPointer, - onDocumentStackSelect, - onPromoteSelection, - selectedDocumentIds, - onClearSelection, - detailPanelOpen, - onCloseDetailPanel, - } = useDesktopContext(); - - const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = - useDocumentDrag(); - - const pointerIntentRef = useRef(null); - const pointerStartRef = useRef({ x: 0, y: 0 }); - const pointerMovedRef = useRef(false); - const longPressTimerRef = useRef(null); - const longPressActiveRef = useRef(false); - - const resolveStackDocIds = useCallback( - (event, targetDocId = null) => { - const container = containerRef.current; - if (!container || !event) { - return []; - } - - const rect = container.getBoundingClientRect(); - const pointerCanvasX = event.clientX - rect.left; - const pointerCanvasY = event.clientY - rect.top; - - if (!Number.isFinite(pointerCanvasX) || !Number.isFinite(pointerCanvasY)) { - return []; - } - - const candidates = []; - - items.forEach((doc) => { - if (!doc?.id) { - return; - } - const layout = layoutSnapshot.get(doc.id) ?? layoutRef.current.get(doc.id); - if (!layout) { - return; - } - - const sizeInfo = ensureDocumentSize(doc); - if (!sizeInfo) { - return; - } - const { width, height } = sizeInfo; - if (!width || !height) { - return; - } - - if (activeTagSet.size) { - const docTagKeys = Array.isArray(doc.tags) - ? doc.tags.map((tag) => (tag ? tag.id : null)).filter(Boolean) - : []; - if (!docTagKeys.some((key) => activeTagSet.has(key))) { - return; - } - } - - const centerX = Number(layout.centerX); - const centerY = Number(layout.centerY); - if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) { - return; - } - - const rotationDeg = Number(layout.rotation) || 0; - const rotationRad = (rotationDeg * Math.PI) / 180; - const dx = pointerCanvasX - centerX; - const dy = pointerCanvasY - centerY; - const cosRotation = Math.cos(-rotationRad); - const sinRotation = Math.sin(-rotationRad); - const localX = dx * cosRotation - dy * sinRotation; - const localY = dx * sinRotation + dy * cosRotation; - const halfWidth = width / 2; - const halfHeight = height / 2; - - const containsPointer = - Math.abs(localX) <= halfWidth + STACK_HIT_EPSILON - && Math.abs(localY) <= halfHeight + STACK_HIT_EPSILON; - - candidates.push({ - id: String(doc.id), - z: Number.isFinite(layout.z) ? layout.z : 0, - centerX, - centerY, - rotationDeg, - width, - height, - halfWidth, - halfHeight, - localX, - localY, - marginX: halfWidth - Math.abs(localX), - marginY: halfHeight - Math.abs(localY), - containsPointer, - }); - }); - - const pointerCandidates = candidates.filter((candidate) => candidate.containsPointer); - if (!pointerCandidates.length) { - return []; - } - - const sortedByZ = [...pointerCandidates].sort((a, b) => (b.z ?? 0) - (a.z ?? 0)); - const targetKey = targetDocId != null ? String(targetDocId) : sortedByZ[0].id; - - const primary = sortedByZ.find((candidate) => candidate.id === targetKey) || sortedByZ[0]; - if (!primary) { - return []; - } - - const radius = Math.max(Math.min(primary.halfWidth, primary.halfHeight) * 1.2, 6); - const radiusSquared = radius * radius; - - const selected = candidates - .filter((candidate) => { - if (!candidate?.id) { - return false; - } - const dx = candidate.centerX - primary.centerX; - const dy = candidate.centerY - primary.centerY; - return dx * dx + dy * dy <= radiusSquared + 1e-4; - }) - .sort((a, b) => (b.z ?? 0) - (a.z ?? 0)); - - if (targetKey) { - const targetIndex = selected.findIndex((entry) => entry.id === targetKey); - if (targetIndex > 0) { - const [targetEntry] = selected.splice(targetIndex, 1); - selected.unshift(targetEntry); - } - } - - return selected - .map((candidate) => candidate.id) - .filter((id, index, array) => array.indexOf(id) === index); - }, - [ - activeTagSet, - ensureDocumentSize, - items, - layoutRef, - layoutSnapshot, - containerRef, - ], - ); - - const allSizesReady = items.every((doc) => ensureDocumentSize(doc)); - - const resetLongPressState = useCallback(() => { - if (longPressTimerRef.current) { - clearTimeout(longPressTimerRef.current); - longPressTimerRef.current = null; - } - longPressActiveRef.current = false; - }, []); - - const scheduleLongPress = useCallback( - ({ doc, modifierActive, pointerType }) => { - if (modifierActive || pointerType !== 'touch') { - longPressActiveRef.current = false; - return; - } - - longPressActiveRef.current = true; - if (typeof window === 'undefined') { - return; - } - - longPressTimerRef.current = window.setTimeout(() => { - if (!longPressActiveRef.current || pointerMovedRef.current) { - resetLongPressState(); - return; - } - - const intent = pointerIntentRef.current; - if (!intent || intent.docId !== doc.id) { - resetLongPressState(); - return; - } - - const syntheticEvent = { - clientX: pointerStartRef.current.x, - clientY: pointerStartRef.current.y, - }; - const stackHits = resolveStackDocIds(syntheticEvent, doc.id); - applyLongPressSelection({ - intent, - stackDocIds: stackHits, - syntheticEvent, - onDocumentStackSelect, - }); - pointerIntentRef.current = intent; - resetLongPressState(); - }, LONG_PRESS_DURATION_MS); - }, - [onDocumentStackSelect, resetLongPressState, resolveStackDocIds], - ); - - useEffect(() => () => resetLongPressState(), [resetLongPressState]); - - const handleShellKeyDown = useCallback( - (event) => { - if (!event || event.defaultPrevented) { - return; - } - - const { key } = event; - if (key !== ' ' && key !== 'Space' && key !== 'Spacebar') { - return; - } - - const target = event.target; - if (target instanceof HTMLElement) { - const tag = target.tagName ? target.tagName.toLowerCase() : ''; - if ( - target.isContentEditable - || tag === 'input' - || tag === 'textarea' - || tag === 'select' - || tag === 'button' - ) { - return; - } - } - - if (Array.isArray(selectedDocumentIds) && selectedDocumentIds.length > 0) { - event.preventDefault(); - onClearSelection?.(); - return; - } - - if (detailPanelOpen && typeof onCloseDetailPanel === 'function') { - event.preventDefault(); - onCloseDetailPanel(); - } - }, - [ - detailPanelOpen, - onClearSelection, - onCloseDetailPanel, - selectedDocumentIds, - ], - ); - - return ( - <> -
{ - if (event.target === event.currentTarget && typeof onClearSelection === 'function') { - onClearSelection(); - } - }} - > -
{ - if (event.target === event.currentTarget && typeof onClearSelection === 'function') { - onClearSelection(); - } - }} - > - {!allSizesReady ? ( -
-

Loading previews…

-
- ) : items.length === 0 ? ( -
-

No documents to show here yet. Drop files to make this space come alive.

-
- ) : ( - items.map((doc) => { - const sizeInfo = ensureDocumentSize(doc); - if (!sizeInfo) { - return null; - } - const { width: cardWidth, height: cardHeight } = sizeInfo; - const layout = layoutSnapshot.get(doc.id) ?? layoutRef.current.get(doc.id); - if ( - !layout || - !Number.isFinite(layout.centerX) || - !Number.isFinite(layout.centerY) - ) { - return null; - } - const layoutCenterX = layout.centerX; - const layoutCenterY = layout.centerY; - const rotation = layout.rotation || 0; - const originX = layoutCenterX - cardWidth / 2; - const originY = layoutCenterY - cardHeight / 2; - const transform = formatTransform( - Math.round(originX), - Math.round(originY), - rotation, - 1, - ); - const style = { - transform, - zIndex: layout.z ?? 1, - width: Math.round(cardWidth), - height: Math.round(cardHeight), - }; - const docKey = doc?.id != null ? String(doc.id) : null; - const shouldLoad = docKey ? visibleDocIds.has(docKey) : false; - const dragging = draggingId === doc.id; - const tags = Array.isArray(doc.tags) ? doc.tags : []; - const correspondents = resolveCorrespondents(doc); - const docTagKeys = tags - .map((tag) => (tag ? tag.id : null)) - .filter(Boolean); - const matchesFilter = - activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key)); - const dropActive = tagDropTargetId === doc.id; - const dropPending = pendingTagDocId === doc.id; - const itemClasses = ['desk-item']; - if (dragging) itemClasses.push('is-dragging'); - if (dropActive) itemClasses.push('is-tag-target'); - if (dropPending) itemClasses.push('is-tag-pending'); - if (!matchesFilter) itemClasses.push('is-filtered-out'); - const isSelected = selectedDocumentIds.includes(doc.id); - if (isSelected) itemClasses.push('is-selected'); - const docTagTokens = docTagKeys.join(' '); - return ( -
{ - if (node) { - itemRefs.current.set(doc.id, node); - } else { - itemRefs.current.delete(doc.id); - } - }} - onPointerDown={(event) => { - pointerStartRef.current = { - x: Number.isFinite(event.clientX) ? event.clientX : 0, - y: Number.isFinite(event.clientY) ? event.clientY : 0, - }; - pointerMovedRef.current = false; - resetLongPressState(); - - const pointerButton = typeof event.button === 'number' ? event.button : 0; - const pointerType = typeof event.pointerType === 'string' ? event.pointerType : ''; - const metaKey = (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey; - const modifierActive = Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey); - - const entryDescriptor = { - type: 'document', - id: doc.id, - key: `document:${doc.id}`, - }; - - const stackHits = metaKey ? resolveStackDocIds(event, doc.id) : null; - - const intent = createPointerIntent({ - doc, - entryDescriptor, - selectedDocumentIds, - metaKey, - pointerButton, - pointerType, - stackHits, - }); - - if (intent.selectedAtDown && typeof onPromoteSelection === 'function') { - onPromoteSelection(doc.id, event); - } - - applyClickPlanImmediately({ - intent, - event, - onEntryPointer, - onDocumentStackSelect, - }); - - pointerIntentRef.current = intent; - - handlePointerDown(event, doc.id, { - stackDocIds: intent.stackDocIdsForDrag, - stackSelectionApplied: intent.stackSelectionApplied, - wasSelected: intent.selectedAtDown, - modifierActive, - stackReplace: intent.stackReplaceOnDrag, - }); - - scheduleLongPress({ - doc, - modifierActive, - pointerType, - }); - }} - 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; - resetLongPressState(); - } - handlePointerMove(event); - }} - onPointerUp={(event) => { - const pointerState = pointerIntentRef.current; - const pointerMoved = pointerMovedRef.current; - - resetLongPressState(); - handlePointerUp(event); - - if (!pointerMoved && pointerState) { - finalizeClickSelection({ - intent: pointerState, - event, - onEntryPointer, - onDocumentStackSelect, - }); - - if ( - pointerState.clickAction === CLICK_ACTIONS.openDetail - && !pointerState.longPressTriggered - && typeof onDocumentOpen === 'function' - && pointerState.docId === doc.id - ) { - const expectedButton = - typeof pointerState.pointerButton === 'number' - ? pointerState.pointerButton - : 0; - const releasedButton = typeof event.button === 'number' - ? event.button - : expectedButton; - const isPrimaryRelease = expectedButton === 0 && releasedButton === 0; - const stillSelected = Array.isArray(selectedDocumentIds) - && selectedDocumentIds.includes(doc.id); - if (isPrimaryRelease && stillSelected) { - const useSelection = pointerState.selectedAtDown - && pointerState.selectionCountAtDown > 0; - onDocumentOpen(doc.id, { useSelection }); - } - } - } - - pointerIntentRef.current = null; - pointerMovedRef.current = false; - }} - onPointerCancel={(event) => { - pointerMovedRef.current = false; - resetLongPressState(); - pointerIntentRef.current = null; - handlePointerCancel(event); - }} - onDragEnter={(event) => handleTagDragEnterDoc(event, doc.id)} - onDragOver={(event) => handleTagDragOverDoc(event, doc.id)} - onDragLeave={(event) => handleTagDragLeaveDoc(event, doc.id)} - onDrop={(event) => handleTagDropOnDoc(event, doc)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - preventAll(event); - onDocumentOpen?.(doc.id); - } - }} - > -
- - {correspondents.length > 0 && ( - - )} - {tags.length > 0 && ( - - )} -
-
- ); - }) - )} -
-
- - - ); -}; - -export default DesktopWorkspace; - -const DesktopHelpOverlay = ({ open = false, onClose = null }) => { - const portalTarget = typeof document !== 'undefined' ? document.body : null; - const closeButtonRef = useRef(null); - const previousFocusRef = useRef(null); - - const handleClose = useCallback(() => { - if (typeof onClose === 'function') { - onClose(); - } - }, [onClose]); - - useEffect(() => { - if (!open || typeof window === 'undefined') { - return undefined; - } - - const handleKeyDown = (event) => { - if (!event) { - return; - } - if (event.key === 'Escape') { - event.preventDefault(); - handleClose(); - } - }; - - window.addEventListener('keydown', handleKeyDown, true); - return () => window.removeEventListener('keydown', handleKeyDown, true); - }, [open, handleClose]); - - useEffect(() => { - if (!open) { - const previous = previousFocusRef.current; - if (previous && typeof previous.focus === 'function') { - previous.focus(); - } - previousFocusRef.current = null; - return; - } - - if (typeof document !== 'undefined') { - previousFocusRef.current = document.activeElement instanceof HTMLElement - ? document.activeElement - : null; - } - - if (closeButtonRef.current && typeof closeButtonRef.current.focus === 'function') { - closeButtonRef.current.focus(); - } - }, [open]); - - if (!open || !portalTarget) { - return null; - } - - return createPortal( -
-
-
-
-

Desk view tips

- -
-
-

Use the desk as a freeform workspace for triage and quick comparisons.

-
    -
  • Single-click a document to open it in the detail panel.
  • -
  • Double-click to open the zoomed preview.
  • -
  • - Drag selected cards to reposition them; build a selection with - {' '} - Cmd/Ctrl - {' '}+ click or Shift-click. -
  • -
  • - Cmd/Ctrl + click with an empty selection scoops up the stack under - {' '}the pointer. -
  • -
  • Space clears the current selection.
  • -
  • - Drag tags from the sidebar onto a card to assign them, or fling a - {' '}tag away to remove it. -
  • -
-
-
- -
-
-
, - portalTarget, - ); -}; - -export const createDesktopSurface = ({ - workspaceProps, - renderSidebarToggle, - parentBreadcrumb, - onNavigateParent, - detailProps = null, - detailOpen = false, -}) => { - if (!workspaceProps) { - return null; - } - - const { - currentFolderName, - searchResults, - onRefresh, - viewMode, - onViewModeChange, - selectedDocumentIds, - selectedFolderIds, - onDeleteSelection, - onClearSelection, - tags, - correspondents, - documentLookup, - tagLookupById, - onBulkTagAdd, - onBulkTagRemove, - onBulkCorrespondentAdd, - onBulkCorrespondentRemove, - onBulkReanalyze, - folderOptions, - onMoveDocumentsToFolder, - searchIncludeDescendants, - onToggleSearchIncludeDescendants, - } = workspaceProps; - - const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; - const subtitle = Array.isArray(searchResults) - ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}` - : null; - - - const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; - const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0; - const selectionCount = documentSelectionCount + folderSelectionCount; - const actions = createDocumentsTableHeaderActions({ - viewMode: viewMode || 'desk', - onViewModeChange, - onRefresh, - onShowDeskHelp: viewMode === 'desk' ? workspaceProps?.onOpenHelp : null, - includeDescendants: searchIncludeDescendants, - onToggleIncludeDescendants: onToggleSearchIncludeDescendants, - }); - - const floatingActions = selectionCount > 0 - ? ( - - ) - : null; - - const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; - const detail = detailOpen && detailProps ? : null; - const surfaceConfig = createWorkspaceSurfaceConfig({ - key: 'workspace', - variant: 'workspace', - title, - subtitle, - sidebarToggle, - parentBreadcrumb, - onNavigateParent, - actions, - breadcrumbs: workspaceProps?.breadcrumbs || null, - selectionLabel: null, - floatingActions, - content: , - detail, - }); - - return { - ...surfaceConfig, - supportsDetail: Boolean(detailProps), - }; -}; diff --git a/frontend/src/app/useWorkspaceSurface.js b/frontend/src/app/useWorkspaceSurface.js index 7a9c099..c3ec2db 100644 --- a/frontend/src/app/useWorkspaceSurface.js +++ b/frontend/src/app/useWorkspaceSurface.js @@ -2,7 +2,7 @@ import React, { useCallback, useMemo } from 'react'; import { SidebarExpandIcon } from '../ui/icons'; import { createDocumentsSurface } from '../documents/DocumentsPanel'; import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel'; -import { createDesktopSurface } from '../DesktopWorkspace'; +import { createDesktopSurface } from '../desktop/DesktopWorkspace'; export const useWorkspaceSurface = ({ sidebarCollapsed, diff --git a/frontend/src/desk/db.js b/frontend/src/desk/db.js deleted file mode 100644 index b49f91e..0000000 --- a/frontend/src/desk/db.js +++ /dev/null @@ -1,143 +0,0 @@ -const DB_NAME = 'papercrate_desk'; -const DB_VERSION = 1; -const LAYOUT_STORE = 'layouts'; - -const currentDbPromise = { value: null }; - -const openDatabase = () => { - if (currentDbPromise.value) { - return currentDbPromise.value; - } - - currentDbPromise.value = new Promise((resolve, reject) => { - if (typeof indexedDB === 'undefined') { - reject(new Error('IndexedDB not available')); - return; - } - - const request = indexedDB.open(DB_NAME, DB_VERSION); - - request.onupgradeneeded = () => { - const db = request.result; - if (!db.objectStoreNames.contains(LAYOUT_STORE)) { - const store = db.createObjectStore(LAYOUT_STORE, { - keyPath: ['tenantId', 'viewId', 'documentId'], - }); - store.createIndex('tenantViewIdx', ['tenantId', 'viewId'], { unique: false }); - store.createIndex('tenantIdx', 'tenantId', { unique: false }); - store.createIndex('updatedIdx', 'updatedAt', { unique: false }); - } - }; - - request.onsuccess = () => { - resolve(request.result); - }; - - request.onerror = () => { - reject(request.error || new Error('Failed to open IndexedDB')); - }; - }); - - return currentDbPromise.value; -}; - -export const fetchLayoutRecords = async ({ tenantId, viewId }) => { - if (!tenantId || !viewId) { - return []; - } - - try { - const db = await openDatabase(); - const transaction = db.transaction(LAYOUT_STORE, 'readonly'); - const store = transaction.objectStore(LAYOUT_STORE); - const index = store.index('tenantViewIdx'); - const request = index.getAll([tenantId, viewId]); - - return await new Promise((resolve, reject) => { - request.onsuccess = () => resolve(request.result || []); - request.onerror = () => reject(request.error || new Error('Failed to fetch layout records')); - }); - } catch (error) { - console.warn('[desk] Failed to read layout records', error); - return []; - } -}; - -export const upsertLayoutRecords = async ({ tenantId, viewId, entries }) => { - if (!tenantId || !viewId || !Array.isArray(entries) || !entries.length) { - return; - } - - try { - const db = await openDatabase(); - const transaction = db.transaction(LAYOUT_STORE, 'readwrite'); - const store = transaction.objectStore(LAYOUT_STORE); - const timestamp = Date.now(); - - entries.forEach((entry) => { - if (!entry || !entry.documentId) { - return; - } - store.put({ - tenantId, - viewId, - documentId: entry.documentId, - centerX: Number(entry.centerX) || 0, - centerY: Number(entry.centerY) || 0, - rotation: Number(entry.rotation) || 0, - zIndex: Number(entry.zIndex) || 0, - updatedAt: entry.updatedAt || timestamp, - }); - }); - - await new Promise((resolve, reject) => { - transaction.oncomplete = resolve; - transaction.onerror = () => reject(transaction.error || new Error('Failed to persist layout records')); - transaction.onabort = () => reject(transaction.error || new Error('Layout transaction aborted')); - }); - } catch (error) { - console.warn('[desk] Failed to upsert layout records', error); - } -}; - -export const deleteTenantLayouts = async (tenantId) => { - if (!tenantId) { - return; - } - try { - const db = await openDatabase(); - const transaction = db.transaction(LAYOUT_STORE, 'readwrite'); - const store = transaction.objectStore(LAYOUT_STORE); - const index = store.index('tenantIdx'); - const request = index.openCursor(tenantId); - - await new Promise((resolve, reject) => { - request.onsuccess = (event) => { - const cursor = event.target.result; - if (cursor) { - cursor.delete(); - cursor.continue(); - } else { - resolve(); - } - }; - request.onerror = () => reject(request.error || new Error('Failed to delete tenant layouts')); - }); - } catch (error) { - console.warn('[desk] Failed to clean tenant layouts', error); - } -}; - -export const closeDeskDatabase = () => { - if (!currentDbPromise.value) { - return; - } - currentDbPromise.value = currentDbPromise.value.then((db) => { - try { - db.close(); - } catch (error) { - console.warn('[desk] Failed to close IndexedDB', error); - } - return null; - }); -}; diff --git a/frontend/src/desktop/DesktopDocumentCard.jsx b/frontend/src/desktop/DesktopDocumentCard.jsx new file mode 100644 index 0000000..8051b88 --- /dev/null +++ b/frontend/src/desktop/DesktopDocumentCard.jsx @@ -0,0 +1,124 @@ +import React, { useMemo } from 'react'; +import DesktopPreviewCard from './DesktopPreviewCard'; +import { resolveCorrespondents } from '../documents/correspondents'; +import { getTagColorStyle } from '../utils/colors'; +import { preventAll } from './events'; + +const DesktopDocumentCard = ({ + doc, + style, + shouldLoad, + dragging, + matchesFilter, + tagTargetActive, + tagTargetPending, + selected, + docTagTokens, + ensureAssetUrl, + getDocumentAsset, + handleNavigatorSnapshot, + cardPointerHandlers, + onDocumentOpen, + onTagDragEnter, + onTagDragOver, + onTagDragLeave, + onTagDrop, + onDocTagPointerDown, + onDocTagDragStart, + onDocTagDrag, + onDocTagDragEnd, + pendingRemovalTag, + registerNode, +}) => { + const correspondents = useMemo(() => resolveCorrespondents(doc), [doc]); + const tags = Array.isArray(doc?.tags) ? doc.tags : []; + + const itemClasses = ['desk-item']; + if (dragging) itemClasses.push('is-dragging'); + if (tagTargetActive) itemClasses.push('is-tag-target'); + if (tagTargetPending) itemClasses.push('is-tag-pending'); + if (!matchesFilter) itemClasses.push('is-filtered-out'); + if (selected) itemClasses.push('is-selected'); + + const ariaHidden = matchesFilter ? undefined : 'true'; + const dataTagIds = docTagTokens || undefined; + + return ( +
onTagDragEnter(event, doc.id)} + onDragOver={(event) => onTagDragOver(event, doc.id)} + onDragLeave={(event) => onTagDragLeave(event, doc.id)} + onDrop={(event) => onTagDrop(event, doc)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + preventAll(event); + onDocumentOpen?.(doc.id); + } + }} + > +
+ + {correspondents.length > 0 && ( + + )} + {tags.length > 0 && ( + + )} +
+
+ ); +}; + +export default React.memo(DesktopDocumentCard); diff --git a/frontend/src/desktop/DesktopPreviewCard.jsx b/frontend/src/desktop/DesktopPreviewCard.jsx new file mode 100644 index 0000000..201c637 --- /dev/null +++ b/frontend/src/desktop/DesktopPreviewCard.jsx @@ -0,0 +1,146 @@ +import React, { useEffect } from 'react'; +import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons'; +import { useAssetNavigator } from '../hooks/useAssetNavigator'; +import { preventAll } from './events'; + +const DesktopPreviewCard = ({ + doc, + title, + ensureAssetUrl, + getDocumentAsset, + prefetch = 3, + onNavigatorSnapshot, + shouldLoad = true, +}) => { + const navigator = useAssetNavigator({ + document: doc, + assetType: 'preview', + ensureAssetUrl: shouldLoad ? ensureAssetUrl : null, + getAsset: getDocumentAsset, + prefetch, + }); + + const { currentUrl, cardinality, canGoPrev, canGoNext, currentMetadata, ordinal } = navigator; + const docId = doc?.id ?? null; + + const metadataWidth = Number(currentMetadata?.width); + const metadataHeight = Number(currentMetadata?.height); + + useEffect(() => { + if (!onNavigatorSnapshot || !docId) { + return undefined; + } + const snapshot = { + url: currentUrl || null, + alt: title, + canGoPrev, + canGoNext, + goPrev: navigator.goPrev, + goNext: navigator.goNext, + ordinal, + width: Number.isFinite(metadataWidth) && metadataWidth > 0 ? metadataWidth : null, + height: Number.isFinite(metadataHeight) && metadataHeight > 0 ? metadataHeight : null, + }; + onNavigatorSnapshot(docId, snapshot); + return () => onNavigatorSnapshot(docId, null); + }, [ + docId, + currentUrl, + title, + canGoPrev, + canGoNext, + ordinal, + metadataWidth, + metadataHeight, + navigator.goPrev, + navigator.goNext, + onNavigatorSnapshot, + ]); + + const hasPreview = Boolean(currentUrl); + const cardClasses = ['desk-item__card']; + if (!hasPreview) cardClasses.push('desk-item__card--empty'); + const showNav = hasPreview && (cardinality > 1 || canGoPrev || canGoNext); + + return ( +
{ + if (event instanceof DragEvent) { + event.preventDefault(); + } + }} + > + {hasPreview ? ( + {title} event.preventDefault()} + /> + ) : ( +
+
DOC
+
+ {title} +
+
+ )} + {showNav ? ( +
+ + +
+ ) : null} +
+ ); +}; + +export default DesktopPreviewCard; + diff --git a/frontend/src/DesktopWorkspace.css b/frontend/src/desktop/DesktopWorkspace.css similarity index 100% rename from frontend/src/DesktopWorkspace.css rename to frontend/src/desktop/DesktopWorkspace.css diff --git a/frontend/src/desktop/DesktopWorkspace.jsx b/frontend/src/desktop/DesktopWorkspace.jsx new file mode 100644 index 0000000..bb69003 --- /dev/null +++ b/frontend/src/desktop/DesktopWorkspace.jsx @@ -0,0 +1,1182 @@ +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react'; +import { createPortal } from 'react-dom'; +import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; +import { CloseIcon } from '../ui/icons'; +import SelectionFloatingActions from '../documents/SelectionFloatingActions'; +import { createDocumentsTableHeaderActions } from '../documents/DocumentsPanel'; +import createWorkspaceSurfaceConfig from '../documents/workspaceHeader'; +import DetailPanel from '../detail/DetailPanel'; +import { formatTransform } from './math'; +import useDocumentDrag from './useDocumentDrag'; +import PreviewZoomOverlay from '../detail/PreviewZoomOverlay'; +import { + WorkspaceEngine, + DESK_CANVAS_PADDING, + DESK_DEFAULT_CANVAS_HEIGHT, + DESK_DEFAULT_CANVAS_WIDTH, + clampCardDimensions, + computeFallbackCardSize, + useWorkspaceSnapshot, +} from './workspaceEngine'; +import useDeskPointer from './pointer/useDeskPointer'; +import useDeskTagInteractions from './tags/useDeskTagInteractions'; +import DesktopDocumentCard from './DesktopDocumentCard'; +import './DesktopWorkspace.css'; + +const DEBUG_DRAG = false; +const DEBUG_FOCUS = true; + +const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => { + const [metadataMap, setMetadataMap] = useState(() => new Map()); + + useEffect(() => { + let cancelled = false; + const docs = Array.isArray(documents) ? documents : []; + if (!docs.length) { + setMetadataMap(new Map()); + return () => { + cancelled = true; + }; + } + + const fetchMetadataForDoc = async (doc) => { + if (!doc?.id) { + return null; + } + + const docId = String(doc.id); + const resolveAsset = (type) => (typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, type) : null); + + let asset = resolveAsset('preview') || resolveAsset('thumbnail'); + let view = createAssetView(asset); + let metadata = view.getPrimaryMetadata(); + + const hasDimensions = (meta) => + Number.isFinite(Number(meta?.width)) && Number.isFinite(Number(meta?.height)) && + Number(meta.width) > 0 && Number(meta.height) > 0; + + if (!hasDimensions(metadata) && ensureAssetUrl && docId && asset?.id) { + try { + const ensured = await ensureAssetUrl(doc.id, asset, { start: 1, limit: 1 }); + if (ensured) { + asset = ensured; + view = createAssetView(asset); + metadata = view.getPrimaryMetadata(); + } + } catch (error) { + console.warn('[desk] ensureDocumentSize metadata fetch failed', error); + } + } + + if (!hasDimensions(metadata)) { + return null; + } + + const width = Number(metadata.width); + const height = Number(metadata.height); + if (!Number.isFinite(width) || !Number.isFinite(height)) { + return null; + } + + return [docId, { width, height }]; + }; + + Promise.all(docs.map((doc) => fetchMetadataForDoc(doc))) + .then((entries) => { + if (cancelled) return; + const next = new Map(); + entries.forEach((entry) => { + if (entry) { + next.set(entry[0], entry[1]); + } + }); + setMetadataMap(next); + }) + .catch(() => { + if (!cancelled) { + setMetadataMap(new Map()); + } + }); + + return () => { + cancelled = true; + }; + }, [documents, getDocumentAsset, ensureAssetUrl]); + + return metadataMap; +}; + +const DesktopWorkspace = ({ + documents = [], + searchResults = null, + onDocumentOpen, + onInspectDocument = null, + onEntryPointer = null, + onDocumentStackSelect = null, + onPromoteSelection = null, + onAssignTagToDocument = null, + onRemoveTagFromDocument = null, + ensureAssetUrl = null, + getDocumentAsset = () => null, + activeTagIds = [], + selectedDocumentIds = [], + onClearSelection = null, + detailPanelOpen = false, + onCloseDetailPanel = null, + helpOpen = false, + onHelpClose = null, + tenantId = null, + viewId = 'default', +}) => { + const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]); + + const allowLayoutPersistence = Boolean(tenantId && viewId && viewId.startsWith('folder:')); + + const containerRef = useRef(null); + const itemRefs = useRef(new Map()); + const dragTransformsRef = useRef(new Map()); + const [overlayDocId, setOverlayDocId] = useState(null); + const [overlayOriginRect, setOverlayOriginRect] = useState(null); + const [overlayOriginTransform, setOverlayOriginTransform] = useState(null); + const [previewSnapshots, setPreviewSnapshots] = useState(() => new Map()); + const [docSizeVersion, setDocSizeVersion] = useState(0); + const docSizeMapRef = useRef(new Map()); + const documentLookupRef = useRef(new Map()); + const ensureDocumentSize = useCallback((doc) => { + if (!doc?.id) { + return null; + } + return docSizeMapRef.current.get(String(doc.id)) || null; + }, []); + const previewMetadata = usePreviewMetadata(items, getDocumentAsset, ensureAssetUrl); + const documentLookup = useMemo(() => { + const map = new Map(); + items.forEach((doc) => { + const key = doc?.id != null ? String(doc.id) : null; + if (key) { + map.set(key, doc); + } + }); + return map; + }, [items]); + + useEffect(() => { + documentLookupRef.current = documentLookup; + }, [documentLookup]); + + const engineRef = useRef(null); + if (!engineRef.current) { + engineRef.current = new WorkspaceEngine({ + allowLayoutPersistence, + tenantId, + viewId, + }); + } + const engine = engineRef.current; + + useEffect(() => { + engine.updateConfig({ allowLayoutPersistence, tenantId, viewId }); + }, [engine, allowLayoutPersistence, tenantId, viewId]); + + useEffect(() => { + engine.setItems(items); + }, [engine, items]); + + useEffect(() => { + engine.setDocumentLookup(documentLookup); + }, [engine, documentLookup]); + + useEffect(() => { + engine.setEnsureDocumentSize(ensureDocumentSize); + }, [engine, ensureDocumentSize]); + + useEffect(() => { + engine.ensureLayoutForItems(); + }, [engine, docSizeVersion]); + + useEffect(() => { + engine.setItemRefs(itemRefs); + }, [engine, itemRefs]); + + const workspaceSnapshot = useWorkspaceSnapshot(engine, useSyncExternalStore); + const { + layout: layoutSnapshot, + canvasSize, + visibleDocIds, + draggingId, + tagDropTargetId, + pendingTagDocId, + pendingRemovalTag, + } = workspaceSnapshot; + + const layoutRef = useRef(layoutSnapshot); + layoutRef.current = engine.layout; + + const bringToFront = useCallback((docId) => { + engine.bringToFront(docId); + }, [engine]); + + const markLayoutDirty = useCallback(() => { + engine.markLayoutDirty(); + }, [engine]); + + const recalcVisibleDocIds = useCallback(() => { + engine.recalcVisibleDocIds(); + }, [engine]); + + const syncLayoutSnapshot = useCallback((force = false) => { + engine.syncLayoutSnapshot(force); + }, [engine]); + + const setDraggingId = useCallback((value) => { + engine.setDraggingId(value); + }, [engine]); + + const applySnapshotDimensions = useCallback((docKey, snapshot) => { + const width = Number(snapshot?.width); + const height = Number(snapshot?.height); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return; + } + const normalized = clampCardDimensions(width, height); + if (!normalized) { + return; + } + const existing = docSizeMapRef.current.get(docKey); + if (existing && existing.width === normalized.width && existing.height === normalized.height) { + return; + } + const next = new Map(docSizeMapRef.current); + next.set(docKey, { ...normalized, source: 'snapshot' }); + docSizeMapRef.current = next; + setDocSizeVersion((value) => value + 1); + }, []); + const handleNavigatorSnapshot = useCallback( + (docId, snapshot) => { + const docKey = docId != null ? String(docId) : null; + if (!docKey) { + return; + } + + setPreviewSnapshots((previous) => { + const prevSnapshot = previous.get(docKey); + if (!snapshot) { + if (!previous.has(docKey)) { + return previous; + } + const next = new Map(previous); + next.delete(docKey); + return next; + } + + const next = new Map(previous); + const sameSnapshot = + prevSnapshot && + prevSnapshot.url === snapshot.url && + prevSnapshot.alt === snapshot.alt && + prevSnapshot.canGoPrev === snapshot.canGoPrev && + prevSnapshot.canGoNext === snapshot.canGoNext && + prevSnapshot.goPrev === snapshot.goPrev && + prevSnapshot.goNext === snapshot.goNext && + prevSnapshot.ordinal === snapshot.ordinal && + prevSnapshot.width === snapshot.width && + prevSnapshot.height === snapshot.height; + if (sameSnapshot) { + return previous; + } + next.set(docKey, snapshot); + return next; + }); + + if (snapshot) { + applySnapshotDimensions(docKey, snapshot); + } + }, + [applySnapshotDimensions], + ); + const activeTagSet = useMemo(() => { + if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) { + return new Set(); + } + const set = new Set(); + activeTagIds.forEach((id) => { + if (id != null) { + set.add(String(id)); + } + }); + return set; + }, [activeTagIds]); + + useLayoutEffect(() => { + const container = containerRef.current; + if (!container) { + engine.setCanvasSize({ width: 0, height: 0 }); + return undefined; + } + + const commitSize = () => { + const rect = container.getBoundingClientRect(); + const width = Math.floor(rect.width) || 0; + const height = Math.floor(rect.height) || 0; + engine.setCanvasSize({ width, height }); + }; + + commitSize(); + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', commitSize); + return () => { + window.removeEventListener('resize', commitSize); + }; + } + + const observer = new ResizeObserver(commitSize); + observer.observe(container); + return () => observer.disconnect(); + }, [engine]); + + const resolvePreviewDimensions = useCallback( + (doc) => { + if (!doc?.id) { + return null; + } + return previewMetadata.get(String(doc.id)) || null; + }, + [previewMetadata], + ); + + useEffect(() => { + if (!ensureAssetUrl) { + return; + } + + visibleDocIds.forEach((docId) => { + const doc = documentLookup.get(docId); + if (!doc) { + return; + } + resolveDocumentAssetUrl(doc, 'preview', { + ensureAssetUrl, + getAsset: getDocumentAsset, + }); + }); + }, [visibleDocIds, ensureAssetUrl, getDocumentAsset, documentLookup]); + + const requestCanvasFocus = useCallback(() => { + const canvas = containerRef.current; + if (!canvas || typeof canvas.focus !== 'function') { + return; + } + + const focusTarget = () => { + try { + if (DEBUG_FOCUS) { + console.log('[desk] focusCanvas -> attempting focus', canvas); + } + canvas.focus({ preventScroll: true }); + if (DEBUG_FOCUS) { + console.log('[desk] focusCanvas: applied focus. activeElement:', document?.activeElement); + } + } catch (error) { + if (DEBUG_FOCUS) { + console.warn('[desk] focusTarget failed to focus canvas', error); + } + } + }; + + if (typeof window === 'undefined') { + focusTarget(); + return; + } + + if (DEBUG_FOCUS) { + console.log('[desk] requestCanvasFocus -> scheduling deferred focus'); + } + + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(() => { + if (DEBUG_FOCUS) { + console.log('[desk] requestCanvasFocus -> executing deferred focus (rAF)'); + } + focusTarget(); + }); + } else { + setTimeout(() => { + if (DEBUG_FOCUS) { + console.log('[desk] requestCanvasFocus -> executing deferred focus (timeout)'); + } + focusTarget(); + }, 0); + } + }, []); + + const tagInteractions = useDeskTagInteractions({ + engine, + onAssignTagToDocument, + onRemoveTagFromDocument, + requestCanvasFocus, + }); + + const { + handleTagDragEnterDoc, + handleTagDragOverDoc, + handleTagDragLeaveDoc, + handleTagDropOnDoc, + handleCanvasDragOver, + handleCanvasDragLeave, + handleCanvasDrop, + handleDocTagPointerDown, + handleDocTagDragStart, + handleDocTagDrag, + handleDocTagDragEnd, + } = tagInteractions; + + useEffect(() => { + const current = docSizeMapRef.current; + const next = new Map(current); + const itemKeys = new Set(items.filter((doc) => doc?.id != null).map((doc) => String(doc.id))); + let changed = false; + + items.forEach((doc) => { + if (!doc?.id) { + return; + } + const key = String(doc.id); + const existing = next.get(key) || null; + const meta = previewMetadata.get(key); + if (meta) { + const normalized = clampCardDimensions(meta.width, meta.height); + if (normalized) { + if (existing?.source === 'snapshot') { + return; + } + if (!existing || existing.width !== normalized.width || existing.height !== normalized.height || existing.source !== 'metadata') { + next.set(key, { ...normalized, source: 'metadata' }); + changed = true; + } + return; + } + } + + if (!existing) { + const fallback = computeFallbackCardSize(key); + if (fallback) { + next.set(key, { ...fallback, source: 'fallback' }); + changed = true; + } + } + }); + + current.forEach((_, key) => { + if (!itemKeys.has(key)) { + next.delete(key); + changed = true; + } + }); + + if (changed) { + docSizeMapRef.current = next; + setDocSizeVersion((value) => value + 1); + } + }, [items, previewMetadata]); + + const overlayDisplay = useMemo(() => { + if (!overlayDocId) { + return null; + } + const snapshot = previewSnapshots.get(overlayDocId); + if (!snapshot || !snapshot.url) { + return null; + } + const doc = documentLookup.get(overlayDocId); + const alt = snapshot.alt || doc?.title; + return { + url: snapshot.url, + alt, + canGoPrev: snapshot.canGoPrev, + canGoNext: snapshot.canGoNext, + goPrev: snapshot.goPrev, + goNext: snapshot.goNext, + }; + }, [overlayDocId, previewSnapshots, documentLookup]); + + const closeOverlay = useCallback(() => { + setOverlayDocId(null); + setOverlayOriginRect(null); + setOverlayOriginTransform(null); + }, []); + + useEffect(() => { + if (overlayDocId && !documentLookup.has(overlayDocId)) { + setOverlayDocId(null); + setOverlayOriginRect(null); + setOverlayOriginTransform(null); + } + }, [overlayDocId, documentLookup]); + + const resolveBaseMetrics = useCallback( + (doc, cardWidth, cardHeight) => { + const previewDims = doc ? resolvePreviewDimensions(doc) : null; + if (previewDims?.width && previewDims?.height) { + const baseWidth = Math.max(previewDims.width, cardWidth); + const baseHeight = Math.max(previewDims.height, cardHeight); + const scaleX = cardWidth / baseWidth; + const scaleY = cardHeight / baseHeight; + const baseScale = Math.min(scaleX, scaleY, 1); + return { + baseWidth, + baseHeight, + baseScale: Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1, + }; + } + return { + baseWidth: cardWidth, + baseHeight: cardHeight, + baseScale: 1, + }; + }, + [resolvePreviewDimensions], + ); + + + useEffect(() => { + if (draggingId && !items.some((doc) => String(doc.id) === draggingId)) { + setDraggingId(null); + } + }, [draggingId, items, setDraggingId]); + + const openOverlayForDoc = useCallback( + (docId, originInfo = null) => { + if (!docId) { + return; + } + const docKey = String(docId); + const snapshot = previewSnapshots.get(docKey); + if (!snapshot || !snapshot.url) { + return; + } + const container = itemRefs.current.get(docKey); + const imageNode = container?.querySelector?.('.desk-item__card img'); + if (!container || !imageNode) { + return; + } + const rect = imageNode.getBoundingClientRect(); + let originTransform = null; + if (originInfo) { + const { rotation = 0, scale = 1, width: originWidth, height: originHeight } = originInfo; + originTransform = { + rotation, + scaleX: scale, + scaleY: scale, + baseWidth: originWidth, + baseHeight: originHeight, + }; + } + if (!originTransform) { + const entry = engine.getLayout(docKey); + const doc = documentLookup.get(docKey) || null; + const sizeInfo = ensureDocumentSize(doc); + if (!sizeInfo) { + return; + } + const { width: cardWidth, height: cardHeight } = sizeInfo; + const { baseWidth, baseHeight, baseScale } = resolveBaseMetrics(doc, cardWidth, cardHeight); + const effectiveWidth = baseWidth * baseScale; + const effectiveHeight = baseHeight * baseScale; + originTransform = { + rotation: entry?.rotation ?? 0, + scaleX: baseScale, + scaleY: baseScale, + baseWidth: Number.isFinite(effectiveWidth) && effectiveWidth > 0 ? effectiveWidth : cardWidth, + baseHeight: Number.isFinite(effectiveHeight) && effectiveHeight > 0 ? effectiveHeight : cardHeight, + }; + } + bringToFront(docId); + setOverlayOriginRect(rect); + setOverlayOriginTransform(originTransform); + setOverlayDocId(docKey); + }, + [ + bringToFront, + previewSnapshots, + itemRefs, + setOverlayOriginTransform, + ensureDocumentSize, + resolveBaseMetrics, + documentLookup, + engine, + ], + ); + + const dragSettings = useMemo( + () => ({ + canvasPadding: DESK_CANVAS_PADDING, + defaultCanvasWidth: DESK_DEFAULT_CANVAS_WIDTH, + defaultCanvasHeight: DESK_DEFAULT_CANVAS_HEIGHT, + debugDrag: DEBUG_DRAG, + }), + [], + ); + + const viewProps = useMemo( + () => ({ + engine, + items, + containerRef, + handleCanvasDragOver, + handleCanvasDragLeave, + handleCanvasDrop, + ensureDocumentSize, + layoutSnapshot, + layoutRef, + dragTransformsRef, + itemRefs, + visibleDocIds, + draggingId, + tagDropTargetId, + pendingTagDocId, + pendingRemovalTag, + onDocumentOpen, + ensureAssetUrl, + getDocumentAsset, + handleNavigatorSnapshot, + activeTagSet, + handleTagDragEnterDoc, + handleTagDragOverDoc, + handleTagDragLeaveDoc, + handleTagDropOnDoc, + handleDocTagPointerDown, + handleDocTagDragStart, + handleDocTagDrag, + handleDocTagDragEnd, + overlayDisplay, + closeOverlay, + overlayOriginRect, + overlayOriginTransform, + onEntryPointer, + onDocumentStackSelect, + onPromoteSelection, + selectedDocumentIds, + onClearSelection, + detailPanelOpen, + onCloseDetailPanel, + documentLookup, + resolveBaseMetrics, + bringToFront, + setDraggingId, + syncLayoutSnapshot, + canvasSize, + openOverlayForDoc, + recalcVisibleDocIds, + dragSettings, + onInspectDocument, + markLayoutDirty, + }), + [ + activeTagSet, + bringToFront, + canvasSize, + closeOverlay, + containerRef, + draggingId, + dragSettings, + engine, + ensureAssetUrl, + ensureDocumentSize, + getDocumentAsset, + dragTransformsRef, + handleCanvasDragLeave, + handleCanvasDragOver, + handleCanvasDrop, + handleDocTagDrag, + handleDocTagDragEnd, + handleDocTagDragStart, + handleDocTagPointerDown, + handleNavigatorSnapshot, + handleTagDragEnterDoc, + handleTagDragLeaveDoc, + handleTagDragOverDoc, + handleTagDropOnDoc, + itemRefs, + items, + layoutRef, + layoutSnapshot, + onClearSelection, + onCloseDetailPanel, + onDocumentOpen, + onDocumentStackSelect, + onEntryPointer, + onInspectDocument, + onPromoteSelection, + openOverlayForDoc, + overlayDisplay, + overlayOriginRect, + overlayOriginTransform, + documentLookup, + pendingRemovalTag, + pendingTagDocId, + recalcVisibleDocIds, + resolveBaseMetrics, + setDraggingId, + selectedDocumentIds, + detailPanelOpen, + markLayoutDirty, + tagDropTargetId, + visibleDocIds, + syncLayoutSnapshot, + ], + ); + return ( + <> + + + + ); +}; + +const DesktopWorkspaceView = ({ + engine, + items, + containerRef, + handleCanvasDragOver, + handleCanvasDragLeave, + handleCanvasDrop, + ensureDocumentSize, + layoutSnapshot, + layoutRef, + itemRefs, + visibleDocIds, + draggingId, + tagDropTargetId, + pendingTagDocId, + pendingRemovalTag, + onDocumentOpen, + ensureAssetUrl, + getDocumentAsset, + handleNavigatorSnapshot, + activeTagSet, + handleTagDragEnterDoc, + handleTagDragOverDoc, + handleTagDragLeaveDoc, + handleTagDropOnDoc, + handleDocTagPointerDown, + handleDocTagDragStart, + handleDocTagDrag, + handleDocTagDragEnd, + overlayDisplay, + closeOverlay, + overlayOriginRect, + overlayOriginTransform, + onEntryPointer, + onDocumentStackSelect, + onPromoteSelection, + selectedDocumentIds, + onClearSelection, + detailPanelOpen, + onCloseDetailPanel, + documentLookup, + resolveBaseMetrics, + bringToFront, + setDraggingId, + syncLayoutSnapshot, + canvasSize, + openOverlayForDoc, + recalcVisibleDocIds, + dragSettings, + onInspectDocument, + markLayoutDirty, + dragTransformsRef, +}) => { + const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = + useDocumentDrag({ + engine, + layoutRef, + dragTransformsRef, + itemRefs, + documentLookup, + ensureDocumentSize, + resolveBaseMetrics, + bringToFront, + setDraggingId, + syncLayoutSnapshot, + canvasSize, + openOverlayForDoc, + recalcVisibleDocIds, + settings: dragSettings, + containerRef, + onInspectDocument, + onDocumentStackSelect, + selectedDocumentIds, + markLayoutDirty, + }); + + const { getCardPointerHandlers, handleShellKeyDown } = useDeskPointer({ + containerRef, + items, + layoutRef, + ensureDocumentSize, + activeTagSet, + handlePointerDown, + handlePointerMove, + handlePointerUp, + handlePointerCancel, + onEntryPointer, + onDocumentStackSelect, + onPromoteSelection, + onDocumentOpen, + selectedDocumentIds, + onClearSelection, + detailPanelOpen, + onCloseDetailPanel, + }); + + + + const allSizesReady = items.every((doc) => ensureDocumentSize(doc)); + + return ( + <> +
{ + if (event.target === event.currentTarget && typeof onClearSelection === 'function') { + onClearSelection(); + } + }} + > +
{ + if (event.target === event.currentTarget && typeof onClearSelection === 'function') { + onClearSelection(); + } + }} + > + {!allSizesReady ? ( +
+

Loading previews…

+
+ ) : items.length === 0 ? ( +
+

No documents to show here yet. Drop files to make this space come alive.

+
+ ) : ( + items.map((doc) => { + const sizeInfo = ensureDocumentSize(doc); + if (!sizeInfo) { + return null; + } + const { width: cardWidth, height: cardHeight } = sizeInfo; + const docKey = doc?.id != null ? String(doc.id) : null; + const dragOverride = docKey ? dragTransformsRef.current.get(docKey) : null; + const layout = docKey ? layoutRef.current.get(docKey) : null; + if (!dragOverride && (!layout && (!docKey || !layoutSnapshot.has(docKey)))) { + return null; + } + const centerX = dragOverride?.centerX ?? layout?.centerX; + const centerY = dragOverride?.centerY ?? layout?.centerY; + if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) { + return null; + } + const rotation = dragOverride?.rotation ?? layout?.rotation ?? 0; + const scale = dragOverride?.scale ?? 1; + const originX = centerX - cardWidth / 2; + const originY = centerY - cardHeight / 2; + const transform = formatTransform( + Math.round(originX), + Math.round(originY), + rotation, + scale, + ); + const style = { + transform, + zIndex: layout?.z ?? 1, + width: Math.round(cardWidth), + height: Math.round(cardHeight), + }; + const shouldLoad = docKey ? visibleDocIds.has(docKey) : false; + const dragging = docKey ? draggingId === docKey : false; + const docTagKeys = Array.isArray(doc?.tags) + ? doc.tags.map((tag) => (tag ? tag.id : null)).filter(Boolean) + : []; + const matchesFilter = + activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key)); + const dropActive = docKey ? tagDropTargetId === docKey : false; + const dropPending = docKey ? pendingTagDocId === docKey : false; + const isSelected = selectedDocumentIds.includes(doc.id); + const docTagTokens = docTagKeys.join(' '); + const cardPointerHandlers = getCardPointerHandlers(doc); + const registerNode = (node) => { + if (!docKey) { + return; + } + if (node) { + itemRefs.current.set(docKey, node); + } else { + itemRefs.current.delete(docKey); + } + }; + + return ( + + ); + }) + )} +
+
+ + + ); +}; + +export default DesktopWorkspace; + +const DesktopHelpOverlay = ({ open = false, onClose = null }) => { + const portalTarget = typeof document !== 'undefined' ? document.body : null; + const closeButtonRef = useRef(null); + const previousFocusRef = useRef(null); + + const handleClose = useCallback(() => { + if (typeof onClose === 'function') { + onClose(); + } + }, [onClose]); + + useEffect(() => { + if (!open || typeof window === 'undefined') { + return undefined; + } + + const handleKeyDown = (event) => { + if (!event) { + return; + } + if (event.key === 'Escape') { + event.preventDefault(); + handleClose(); + } + }; + + window.addEventListener('keydown', handleKeyDown, true); + return () => window.removeEventListener('keydown', handleKeyDown, true); + }, [open, handleClose]); + + useEffect(() => { + if (!open) { + const previous = previousFocusRef.current; + if (previous && typeof previous.focus === 'function') { + previous.focus(); + } + previousFocusRef.current = null; + return; + } + + if (typeof document !== 'undefined') { + previousFocusRef.current = document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + } + + if (closeButtonRef.current && typeof closeButtonRef.current.focus === 'function') { + closeButtonRef.current.focus(); + } + }, [open]); + + if (!open || !portalTarget) { + return null; + } + + return createPortal( +
+
+
+
+

Desk view tips

+ +
+
+

Use the desk as a freeform workspace for triage and quick comparisons.

+
    +
  • Single-click a document to open it in the detail panel.
  • +
  • Double-click to open the zoomed preview.
  • +
  • + Drag selected cards to reposition them; build a selection with + {' '} + Cmd/Ctrl + {' '}+ click or Shift-click. +
  • +
  • + Cmd/Ctrl + click with an empty selection scoops up the stack under + {' '}the pointer. +
  • +
  • Space clears the current selection.
  • +
  • + Drag tags from the sidebar onto a card to assign them, or fling a + {' '}tag away to remove it. +
  • +
+
+
+ +
+
+
, + portalTarget, + ); +}; + +export const createDesktopSurface = ({ + workspaceProps, + renderSidebarToggle, + parentBreadcrumb, + onNavigateParent, + detailProps = null, + detailOpen = false, +}) => { + if (!workspaceProps) { + return null; + } + + const { + currentFolderName, + searchResults, + onRefresh, + viewMode, + onViewModeChange, + selectedDocumentIds, + selectedFolderIds, + onDeleteSelection, + onClearSelection, + tags, + correspondents, + documentLookup, + tagLookupById, + onBulkTagAdd, + onBulkTagRemove, + onBulkCorrespondentAdd, + onBulkCorrespondentRemove, + onBulkReanalyze, + folderOptions, + onMoveDocumentsToFolder, + searchIncludeDescendants, + onToggleSearchIncludeDescendants, + } = workspaceProps; + + const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; + const subtitle = Array.isArray(searchResults) + ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}` + : null; + + + const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; + const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0; + const selectionCount = documentSelectionCount + folderSelectionCount; + const actions = createDocumentsTableHeaderActions({ + viewMode: viewMode || 'desk', + onViewModeChange, + onRefresh, + onShowDeskHelp: viewMode === 'desk' ? workspaceProps?.onOpenHelp : null, + includeDescendants: searchIncludeDescendants, + onToggleIncludeDescendants: onToggleSearchIncludeDescendants, + }); + + const floatingActions = selectionCount > 0 + ? ( + + ) + : null; + + const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; + const detail = detailOpen && detailProps ? : null; + const surfaceConfig = createWorkspaceSurfaceConfig({ + key: 'workspace', + variant: 'workspace', + title, + subtitle, + sidebarToggle, + parentBreadcrumb, + onNavigateParent, + actions, + breadcrumbs: workspaceProps?.breadcrumbs || null, + selectionLabel: null, + floatingActions, + content: , + detail, + }); + + return { + ...surfaceConfig, + supportsDetail: Boolean(detailProps), + }; +}; diff --git a/frontend/src/desktop/context.js b/frontend/src/desktop/context.js deleted file mode 100644 index 1c381f2..0000000 --- a/frontend/src/desktop/context.js +++ /dev/null @@ -1,17 +0,0 @@ -import React, { createContext, useContext } from 'react'; - -const DesktopContext = createContext(null); - -export const DesktopProvider = ({ value, children }) => ( - {children} -); - -export const useDesktopContext = () => { - const context = useContext(DesktopContext); - if (!context) { - throw new Error('useDesktopContext must be used within a DesktopProvider'); - } - return context; -}; - -export default DesktopContext; diff --git a/frontend/src/desktop/db.js b/frontend/src/desktop/db.js new file mode 100644 index 0000000..c1cdbf0 --- /dev/null +++ b/frontend/src/desktop/db.js @@ -0,0 +1,183 @@ +const DB_NAME = 'papercrate_desk'; +const DB_VERSION = 1; +const LAYOUT_STORE = 'layouts'; + +const currentDbPromise = { value: null }; + +const openDatabase = () => { + if (currentDbPromise.value) { + return currentDbPromise.value; + } + + currentDbPromise.value = new Promise((resolve, reject) => { + if (typeof indexedDB === 'undefined') { + reject(new Error('IndexedDB not available')); + return; + } + + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(LAYOUT_STORE)) { + const store = db.createObjectStore(LAYOUT_STORE, { + keyPath: ['tenantId', 'viewId', 'documentId'], + }); + store.createIndex('tenantViewIdx', ['tenantId', 'viewId'], { unique: false }); + store.createIndex('tenantIdx', 'tenantId', { unique: false }); + store.createIndex('updatedIdx', 'updatedAt', { unique: false }); + } + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(request.error || new Error('Failed to open IndexedDB')); + }; + }); + + return currentDbPromise.value; +}; + +const requestToPromise = (request, defaultValue) => new Promise((resolve, reject) => { + request.onsuccess = () => { + const { result } = request; + resolve(result ?? defaultValue); + }; + request.onerror = () => { + reject(request.error || new Error('IndexedDB request failed')); + }; +}); + +const iterateCursor = (request, iteratee) => new Promise((resolve, reject) => { + request.onsuccess = (event) => { + const cursor = event.target.result; + if (!cursor) { + resolve(); + return; + } + try { + iteratee(cursor); + cursor.continue(); + } catch (error) { + reject(error); + } + }; + request.onerror = () => { + reject(request.error || new Error('IndexedDB cursor failed')); + }; +}); + +const transactionComplete = (transaction) => new Promise((resolve, reject) => { + transaction.oncomplete = () => { + resolve(); + }; + transaction.onerror = () => { + reject(transaction.error || new Error('IndexedDB transaction failed')); + }; + transaction.onabort = () => { + reject(transaction.error || new Error('IndexedDB transaction aborted')); + }; +}); + +const withStore = async (mode, handler) => { + const db = await openDatabase(); + const transaction = db.transaction(LAYOUT_STORE, mode); + const store = transaction.objectStore(LAYOUT_STORE); + const done = transactionComplete(transaction); + try { + const result = await handler(store, transaction); + await done; + return result; + } catch (error) { + try { + transaction.abort(); + } catch (abortError) { + console.warn('[desk] Failed to abort transaction', abortError); + } + try { + await done; + } catch (suppressed) { + // noop – prefer original error + } + throw error; + } +}; + +export const fetchLayoutRecords = async ({ tenantId, viewId }) => { + if (!tenantId || !viewId) { + return []; + } + + try { + return await withStore('readonly', (store) => { + const index = store.index('tenantViewIdx'); + return requestToPromise(index.getAll([tenantId, viewId]), []); + }); + } catch (error) { + console.warn('[desk] Failed to read layout records', error); + return []; + } +}; + +export const upsertLayoutRecords = async ({ tenantId, viewId, entries }) => { + if (!tenantId || !viewId || !Array.isArray(entries) || !entries.length) { + return; + } + + try { + await withStore('readwrite', (store) => { + const timestamp = Date.now(); + entries.forEach((entry) => { + if (!entry || !entry.documentId) { + return; + } + store.put({ + tenantId, + viewId, + documentId: entry.documentId, + centerX: Number(entry.centerX) || 0, + centerY: Number(entry.centerY) || 0, + rotation: Number(entry.rotation) || 0, + zIndex: Number(entry.zIndex) || 0, + updatedAt: entry.updatedAt || timestamp, + }); + }); + }); + } catch (error) { + console.warn('[desk] Failed to upsert layout records', error); + } +}; + +export const deleteTenantLayouts = async (tenantId) => { + if (!tenantId) { + return; + } + try { + await withStore('readwrite', (store) => { + const index = store.index('tenantIdx'); + const request = index.openCursor(tenantId); + return iterateCursor(request, (cursor) => { + cursor.delete(); + }); + }); + } catch (error) { + console.warn('[desk] Failed to clean tenant layouts', error); + } +}; + +export const closeDeskDatabase = () => { + if (!currentDbPromise.value) { + return; + } + currentDbPromise.value = currentDbPromise.value.then((db) => { + try { + db.close(); + } catch (error) { + console.warn('[desk] Failed to close IndexedDB', error); + } + return null; + }); +}; diff --git a/frontend/src/desktop/dragPhysics.js b/frontend/src/desktop/dragPhysics.js deleted file mode 100644 index a37e3f1..0000000 --- a/frontend/src/desktop/dragPhysics.js +++ /dev/null @@ -1,202 +0,0 @@ -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/events.js b/frontend/src/desktop/events.js index 043c99f..e803064 100644 --- a/frontend/src/desktop/events.js +++ b/frontend/src/desktop/events.js @@ -13,3 +13,19 @@ export const preventAll = (event) => { console.warn('[events] stopPropagation failed', error); } }; + +export const safeInvoke = (fn, ...args) => (typeof fn === 'function' ? fn(...args) : undefined); + +export const getPointerPosition = (event, { fallbackToPage = true } = {}) => { + if (!event) { + return { x: 0, y: 0 }; + } + const clientX = Number.isFinite(event.clientX) ? event.clientX : null; + const clientY = Number.isFinite(event.clientY) ? event.clientY : null; + const pageX = fallbackToPage && Number.isFinite(event.pageX) ? event.pageX : null; + const pageY = fallbackToPage && Number.isFinite(event.pageY) ? event.pageY : null; + return { + x: clientX ?? pageX ?? 0, + y: clientY ?? pageY ?? 0, + }; +}; diff --git a/frontend/src/desktop/pointer/pointerUtils.js b/frontend/src/desktop/pointer/pointerUtils.js new file mode 100644 index 0000000..e6603c1 --- /dev/null +++ b/frontend/src/desktop/pointer/pointerUtils.js @@ -0,0 +1,131 @@ +import { safeInvoke } from '../events.js'; + +export const CLICK_ACTIONS = { + selectSingle: 'selectSingle', + openDetail: 'openDetail', + addCard: 'addCard', + addStack: 'addStack', + none: 'none', +}; + +export const DRAG_ACTIONS = { + dragSelectSingle: 'dragSelectSingle', + dragSelection: 'dragSelection', + dragSelectStack: 'dragSelectStack', + none: 'none', +}; + +export const STACK_HIT_EPSILON = 4; +export const POINTER_DRAG_THRESHOLD_SQUARED = 16; +export const LONG_PRESS_DURATION_MS = 450; + +export const withinThreshold = (dx, dy, thresholdSquared) => (dx * dx + dy * dy) <= thresholdSquared; + +export const createPointerIntent = ({ + doc, + entryDescriptor, + selectedDocumentIds, + metaKey, + pointerButton, + pointerType, + stackHits, +}) => { + const alreadySelected = selectedDocumentIds.includes(doc.id); + const selectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; + + let clickAction = CLICK_ACTIONS.none; + let dragAction = DRAG_ACTIONS.none; + + if (metaKey) { + clickAction = CLICK_ACTIONS.addStack; + dragAction = DRAG_ACTIONS.dragSelectStack; + } else if (alreadySelected) { + clickAction = CLICK_ACTIONS.openDetail; + dragAction = selectionCount > 1 ? DRAG_ACTIONS.dragSelection : DRAG_ACTIONS.dragSelectSingle; + } else { + clickAction = CLICK_ACTIONS.selectSingle; + dragAction = DRAG_ACTIONS.dragSelectSingle; + } + + const stackList = Array.isArray(stackHits) && stackHits.length > 0 + ? stackHits.slice() + : [String(doc.id)]; + + const stackDocIdsForDrag = dragAction === DRAG_ACTIONS.dragSelectStack ? stackList : null; + const stackDocIdsForClick = clickAction === CLICK_ACTIONS.addStack ? stackList : null; + + return { + docId: doc.id, + entryDescriptor, + pointerType, + pointerButton, + selectedAtDown: alreadySelected, + selectionCountAtDown: selectionCount, + metaKey, + clickAction, + dragAction, + stackDocIdsForDrag, + stackDocIdsForClick, + stackReplaceOnClick: clickAction === CLICK_ACTIONS.addStack, + stackReplaceOnDrag: dragAction === DRAG_ACTIONS.dragSelectStack, + clickSelectionApplied: false, + stackSelectionApplied: false, + longPressTriggered: false, + }; +}; + +export const applyClickPlanImmediately = ({ intent, event, onEntryPointer, onDocumentStackSelect }) => { + switch (intent.clickAction) { + case CLICK_ACTIONS.selectSingle: + case CLICK_ACTIONS.addCard: + safeInvoke(onEntryPointer, intent.entryDescriptor, event); + intent.clickSelectionApplied = true; + break; + case CLICK_ACTIONS.addStack: + if (Array.isArray(intent.stackDocIdsForClick) && intent.stackDocIdsForClick.length > 0) { + safeInvoke( + onDocumentStackSelect, + intent.stackDocIdsForClick, + event, + { replace: intent.stackReplaceOnClick }, + ); + intent.clickSelectionApplied = true; + intent.stackSelectionApplied = true; + } + break; + case CLICK_ACTIONS.openDetail: + default: + intent.clickSelectionApplied = true; + break; + } +}; + +export const finalizeClickSelection = ({ intent, event, onEntryPointer, onDocumentStackSelect }) => { + if (!intent || intent.clickSelectionApplied) { + return; + } + + applyClickPlanImmediately({ intent, event, onEntryPointer, onDocumentStackSelect }); +}; + +export const applyLongPressSelection = ({ intent, stackDocIds, syntheticEvent, onDocumentStackSelect }) => { + if (!intent) { + return; + } + + const stackCopy = Array.isArray(stackDocIds) && stackDocIds.length > 0 + ? stackDocIds.slice() + : [intent.docId]; + + safeInvoke(onDocumentStackSelect, stackCopy, syntheticEvent, { replace: true }); + + intent.clickAction = CLICK_ACTIONS.addStack; + intent.dragAction = DRAG_ACTIONS.dragSelectStack; + intent.stackDocIdsForClick = stackCopy; + intent.stackDocIdsForDrag = stackCopy; + intent.stackReplaceOnClick = true; + intent.stackReplaceOnDrag = true; + intent.clickSelectionApplied = true; + intent.stackSelectionApplied = true; + intent.longPressTriggered = true; +}; diff --git a/frontend/src/desktop/pointer/useDeskPointer.js b/frontend/src/desktop/pointer/useDeskPointer.js new file mode 100644 index 0000000..c1abb25 --- /dev/null +++ b/frontend/src/desktop/pointer/useDeskPointer.js @@ -0,0 +1,425 @@ +import { + useCallback, + useEffect, + useRef, +} from 'react'; +import { + CLICK_ACTIONS, + LONG_PRESS_DURATION_MS, + POINTER_DRAG_THRESHOLD_SQUARED, + STACK_HIT_EPSILON, + applyClickPlanImmediately, + applyLongPressSelection, + createPointerIntent, + finalizeClickSelection, + withinThreshold, +} from './pointerUtils'; +import { getPointerPosition, safeInvoke } from '../events.js'; + +const buildEntryDescriptor = (docId) => ({ + type: 'document', + id: docId, + key: `document:${docId}`, +}); + +export const useDeskPointer = ({ + containerRef, + items, + layoutRef, + ensureDocumentSize, + activeTagSet, + handlePointerDown, + handlePointerMove, + handlePointerUp, + handlePointerCancel, + onEntryPointer, + onDocumentStackSelect, + onPromoteSelection, + onDocumentOpen, + selectedDocumentIds, + onClearSelection, + detailPanelOpen, + onCloseDetailPanel, +}) => { + const pointerIntentRef = useRef(null); + const pointerStartRef = useRef({ x: 0, y: 0 }); + const pointerMovedRef = useRef(false); + const longPressTimerRef = useRef(null); + const longPressActiveRef = useRef(false); + + const resetLongPressState = useCallback(() => { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + } + longPressActiveRef.current = false; + }, []); + + const resolveStackDocIds = useCallback( + (event, targetDocId = null) => { + const container = containerRef.current; + if (!container || !event) { + return []; + } + + const rect = container.getBoundingClientRect(); + const pointerCanvasX = event.clientX - rect.left; + const pointerCanvasY = event.clientY - rect.top; + + if (!Number.isFinite(pointerCanvasX) || !Number.isFinite(pointerCanvasY)) { + return []; + } + + const candidates = []; + + items.forEach((doc) => { + if (!doc?.id) { + return; + } + const docKey = String(doc.id); + const layout = layoutRef.current.get(docKey); + if (!layout) { + return; + } + + const sizeInfo = ensureDocumentSize(doc); + if (!sizeInfo) { + return; + } + const { width, height } = sizeInfo; + if (!width || !height) { + return; + } + + if (activeTagSet.size) { + const docTagKeys = Array.isArray(doc.tags) + ? doc.tags.map((tag) => (tag ? tag.id : null)).filter(Boolean) + : []; + if (!docTagKeys.some((key) => activeTagSet.has(key))) { + return; + } + } + + const centerX = Number(layout.centerX); + const centerY = Number(layout.centerY); + if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) { + return; + } + + const rotationDeg = Number(layout.rotation) || 0; + const rotationRad = (rotationDeg * Math.PI) / 180; + const dx = pointerCanvasX - centerX; + const dy = pointerCanvasY - centerY; + const cosRotation = Math.cos(-rotationRad); + const sinRotation = Math.sin(-rotationRad); + const localX = dx * cosRotation - dy * sinRotation; + const localY = dx * sinRotation + dy * cosRotation; + const halfWidth = width / 2; + const halfHeight = height / 2; + + const containsPointer = + Math.abs(localX) <= halfWidth + STACK_HIT_EPSILON + && Math.abs(localY) <= halfHeight + STACK_HIT_EPSILON; + + candidates.push({ + id: docKey, + z: Number.isFinite(layout.z) ? layout.z : 0, + centerX, + centerY, + width, + height, + halfWidth, + halfHeight, + containsPointer, + }); + }); + + const pointerCandidates = candidates.filter((candidate) => candidate.containsPointer); + if (!pointerCandidates.length) { + return []; + } + + const sortedByZ = [...pointerCandidates].sort((a, b) => (b.z ?? 0) - (a.z ?? 0)); + const targetKey = targetDocId != null ? String(targetDocId) : sortedByZ[0].id; + + const primary = sortedByZ.find((candidate) => candidate.id === targetKey) || sortedByZ[0]; + if (!primary) { + return []; + } + + const radius = Math.max(Math.min(primary.halfWidth, primary.halfHeight) * 1.2, 6); + const radiusSquared = radius * radius; + + const selected = candidates + .filter((candidate) => { + if (!candidate?.id) { + return false; + } + const dx = candidate.centerX - primary.centerX; + const dy = candidate.centerY - primary.centerY; + return dx * dx + dy * dy <= radiusSquared + 1e-4; + }) + .sort((a, b) => (b.z ?? 0) - (a.z ?? 0)); + + if (targetKey) { + const targetIndex = selected.findIndex((entry) => entry.id === targetKey); + if (targetIndex > 0) { + const [targetEntry] = selected.splice(targetIndex, 1); + selected.unshift(targetEntry); + } + } + + return selected + .map((candidate) => candidate.id) + .filter((id, index, array) => array.indexOf(id) === index); + }, + [activeTagSet, containerRef, ensureDocumentSize, items, layoutRef], + ); + + const scheduleLongPress = useCallback( + ({ doc, modifierActive, pointerType }) => { + if (modifierActive || pointerType !== 'touch') { + longPressActiveRef.current = false; + return; + } + + longPressActiveRef.current = true; + if (typeof window === 'undefined') { + return; + } + + longPressTimerRef.current = window.setTimeout(() => { + if (!longPressActiveRef.current || pointerMovedRef.current) { + resetLongPressState(); + return; + } + + const intent = pointerIntentRef.current; + if (!intent || intent.docId !== doc.id) { + resetLongPressState(); + return; + } + + const syntheticEvent = { + clientX: pointerStartRef.current.x, + clientY: pointerStartRef.current.y, + }; + const stackHits = resolveStackDocIds(syntheticEvent, doc.id); + applyLongPressSelection({ + intent, + stackDocIds: stackHits, + syntheticEvent, + onDocumentStackSelect, + }); + pointerIntentRef.current = intent; + resetLongPressState(); + }, LONG_PRESS_DURATION_MS); + }, + [onDocumentStackSelect, resolveStackDocIds, resetLongPressState], + ); + + useEffect(() => () => resetLongPressState(), [resetLongPressState]); + + const handleCardPointerDown = useCallback( + (event, doc) => { + if (!doc?.id) { + return; + } + + pointerStartRef.current = getPointerPosition(event, { fallbackToPage: false }); + pointerMovedRef.current = false; + resetLongPressState(); + + const pointerButton = typeof event.button === 'number' ? event.button : 0; + const pointerType = typeof event.pointerType === 'string' ? event.pointerType : ''; + const metaKey = (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey; + const modifierActive = Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey); + + const entryDescriptor = buildEntryDescriptor(doc.id); + const stackHits = metaKey ? resolveStackDocIds(event, doc.id) : null; + + const intent = createPointerIntent({ + doc, + entryDescriptor, + selectedDocumentIds, + metaKey, + pointerButton, + pointerType, + stackHits, + }); + + if (intent.selectedAtDown) { + safeInvoke(onPromoteSelection, doc.id, event); + } + + applyClickPlanImmediately({ + intent, + event, + onEntryPointer, + onDocumentStackSelect, + }); + + pointerIntentRef.current = intent; + + handlePointerDown(event, doc.id, { + stackDocIds: intent.stackDocIdsForDrag, + stackSelectionApplied: intent.stackSelectionApplied, + wasSelected: intent.selectedAtDown, + modifierActive, + stackReplace: intent.stackReplaceOnDrag, + }); + + scheduleLongPress({ + doc, + modifierActive, + pointerType, + }); + }, + [ + handlePointerDown, + onPromoteSelection, + onEntryPointer, + onDocumentStackSelect, + resolveStackDocIds, + resetLongPressState, + scheduleLongPress, + selectedDocumentIds, + ], + ); + + const handleCardPointerMove = useCallback( + (event) => { + const start = pointerStartRef.current; + const { x, y } = getPointerPosition(event, { fallbackToPage: false }); + const dx = x - start.x; + const dy = y - start.y; + if (!withinThreshold(dx, dy, POINTER_DRAG_THRESHOLD_SQUARED)) { + pointerMovedRef.current = true; + resetLongPressState(); + } + handlePointerMove(event); + }, + [handlePointerMove, resetLongPressState], + ); + + const handleCardPointerUp = useCallback( + (event, doc) => { + const pointerState = pointerIntentRef.current; + const pointerMoved = pointerMovedRef.current; + + resetLongPressState(); + handlePointerUp(event); + + if (!pointerMoved && pointerState) { + finalizeClickSelection({ + intent: pointerState, + event, + onEntryPointer, + onDocumentStackSelect, + }); + + if ( + pointerState.clickAction === CLICK_ACTIONS.openDetail + && !pointerState.longPressTriggered + && pointerState.docId === doc.id + ) { + const expectedButton = typeof pointerState.pointerButton === 'number' + ? pointerState.pointerButton + : 0; + const releasedButton = typeof event.button === 'number' + ? event.button + : expectedButton; + const isPrimaryRelease = expectedButton === 0 && releasedButton === 0; + const stillSelected = Array.isArray(selectedDocumentIds) + && selectedDocumentIds.includes(doc.id); + if (isPrimaryRelease && stillSelected) { + const useSelection = pointerState.selectedAtDown && pointerState.selectionCountAtDown > 0; + safeInvoke(onDocumentOpen, doc.id, { useSelection }); + } + } + } + + pointerIntentRef.current = null; + pointerMovedRef.current = false; + }, + [ + handlePointerUp, + onDocumentOpen, + onDocumentStackSelect, + onEntryPointer, + resetLongPressState, + selectedDocumentIds, + ], + ); + + const handleCardPointerCancel = useCallback( + (event) => { + pointerMovedRef.current = false; + resetLongPressState(); + pointerIntentRef.current = null; + handlePointerCancel(event); + }, + [handlePointerCancel, resetLongPressState], + ); + + const getCardPointerHandlers = useCallback( + (doc) => ({ + onPointerDown: (event) => handleCardPointerDown(event, doc), + onPointerMove: handleCardPointerMove, + onPointerUp: (event) => handleCardPointerUp(event, doc), + onPointerCancel: handleCardPointerCancel, + }), + [ + handleCardPointerCancel, + handleCardPointerDown, + handleCardPointerMove, + handleCardPointerUp, + ], + ); + + const handleShellKeyDown = useCallback( + (event) => { + if (!event || event.defaultPrevented) { + return; + } + + const { key } = event; + if (key !== ' ' && key !== 'Space' && key !== 'Spacebar') { + return; + } + + const target = event.target; + if (target instanceof HTMLElement) { + const tagName = target.tagName ? target.tagName.toLowerCase() : ''; + if ( + target.isContentEditable + || tagName === 'input' + || tagName === 'textarea' + || tagName === 'select' + || tagName === 'button' + ) { + return; + } + } + + if (Array.isArray(selectedDocumentIds) && selectedDocumentIds.length > 0) { + event.preventDefault(); + onClearSelection?.(); + return; + } + + if (detailPanelOpen) { + event.preventDefault(); + safeInvoke(onCloseDetailPanel); + } + }, + [detailPanelOpen, onClearSelection, onCloseDetailPanel, selectedDocumentIds], + ); + + return { + getCardPointerHandlers, + handleShellKeyDown, + }; +}; + +export default useDeskPointer; diff --git a/frontend/src/desktop/tags/useDeskTagInteractions.js b/frontend/src/desktop/tags/useDeskTagInteractions.js new file mode 100644 index 0000000..bd2048f --- /dev/null +++ b/frontend/src/desktop/tags/useDeskTagInteractions.js @@ -0,0 +1,351 @@ +import { + useCallback, + useEffect, + useRef, +} from 'react'; +import { getPointerPosition, preventAll, safeInvoke } from '../events.js'; +import { + isTagTransferEvent, + parseTagTransferPayload, + writeTagTransferData, +} from '../../documents/tagTransfer'; + +const TAG_REMOVE_DISTANCE = 160; +const DEBUG_DROP = true; + +const createDragPreview = (node, clientX, clientY) => { + if (!(node instanceof HTMLElement)) { + return null; + } + const rect = node.getBoundingClientRect(); + const safeClientX = Number.isFinite(clientX) ? clientX : rect.left + rect.width / 2; + const safeClientY = Number.isFinite(clientY) ? clientY : rect.top + rect.height / 2; + const offsetX = Math.min(Math.max(safeClientX - rect.left, 0), rect.width); + const offsetY = Math.min(Math.max(safeClientY - rect.top, 0), rect.height); + const clone = node.cloneNode(true); + clone.style.position = 'absolute'; + clone.style.top = '-9999px'; + clone.style.left = '-9999px'; + clone.style.pointerEvents = 'none'; + clone.style.opacity = '1'; + clone.style.transform = 'none'; + document.body.appendChild(clone); + return { clone, offsetX, offsetY }; +}; + +const cleanupPreview = (previewNode) => { + if (previewNode && previewNode.parentNode) { + previewNode.parentNode.removeChild(previewNode); + } +}; + +export const useDeskTagInteractions = ({ + engine, + onAssignTagToDocument, + onRemoveTagFromDocument, + requestCanvasFocus, +}) => { + const draggingTagRef = useRef(null); + const pendingDocTagDragRef = useRef(null); + const removalCursorActiveRef = useRef(false); + + const updateRemovalCursor = useCallback((active) => { + if (typeof document === 'undefined') { + return; + } + if (removalCursorActiveRef.current === active) { + return; + } + const body = document.body; + if (!body) { + return; + } + removalCursorActiveRef.current = active; + if (active) { + body.classList.add('desk-cursor-remove'); + } else { + body.classList.remove('desk-cursor-remove'); + } + }, []); + + useEffect( + () => () => { + updateRemovalCursor(false); + }, + [updateRemovalCursor], + ); + + const isTagTransfer = useCallback((event) => isTagTransferEvent(event), []); + + const handleTagDragEnd = useCallback(() => { + updateRemovalCursor(false); + engine.setTagDropTargetId(null); + }, [engine, updateRemovalCursor]); + + const finalizeTagDrag = useCallback( + (dropEffect = 'none') => { + const state = draggingTagRef.current; + if (!state) { + updateRemovalCursor(false); + return; + } + + draggingTagRef.current = null; + + const node = state.element; + const showNode = () => { + if (node instanceof HTMLElement) { + node.classList.remove('is-drag-hidden'); + } + }; + const scheduleShowNode = () => { + if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(showNode); + } else { + setTimeout(showNode, 0); + } + }; + + cleanupPreview(state.previewClone); + + const shouldRemove = + !state.dropHandled + && dropEffect === 'none' + && state.sourceDocId + && (state.distance || 0) >= TAG_REMOVE_DISTANCE; + + if (!shouldRemove) { + scheduleShowNode(); + updateRemovalCursor(false); + return; + } + + updateRemovalCursor(false); + engine.setPendingRemovalTag({ docId: state.sourceDocId, tagId: state.tagId }); + const removePromise = safeInvoke(onRemoveTagFromDocument, state.sourceDocId, state.tagId); + if (!removePromise || typeof removePromise.then !== 'function') { + scheduleShowNode(); + engine.setPendingRemovalTag(null); + updateRemovalCursor(false); + return; + } + void (async () => { + try { + await removePromise; + if (DEBUG_DROP) { + console.log('[desk] finalizeTagDrag -> removed tag due to fling'); + } + } catch (error) { + console.error('Failed to remove tag after drag', error); + scheduleShowNode(); + } finally { + engine.setPendingRemovalTag(null); + } + })(); + }, + [engine, onRemoveTagFromDocument, updateRemovalCursor], + ); + + const handleDocTagPointerDown = useCallback((event, doc, tag) => { + event.stopPropagation(); + if (!doc || !tag) { + pendingDocTagDragRef.current = null; + return; + } + const { x: startX, y: startY } = getPointerPosition(event); + pendingDocTagDragRef.current = { + docId: doc.id, + tagId: tag.id, + startX, + startY, + }; + updateRemovalCursor(false); + }, [updateRemovalCursor]); + + const markActiveTagDropHandled = useCallback((tagId, sourceDocId = null) => { + const state = draggingTagRef.current; + if (!state) { + return; + } + if (state.tagId !== tagId) { + return; + } + if (sourceDocId && state.sourceDocId !== sourceDocId) { + return; + } + state.dropHandled = true; + }, []); + + const runTagHoverTransition = useCallback( + (event, docId, { applyTarget = false, applyPending = false, updateCursor = true } = {}) => { + if (!isTagTransfer(event)) { + return; + } + preventAll(event); + if (updateCursor) { + updateRemovalCursor(false); + } + const stringId = docId != null ? String(docId) : null; + if (applyTarget) { + engine.setTagDropTargetId(stringId); + } + if (applyPending) { + engine.setPendingTagDocId(stringId); + } + }, + [engine, isTagTransfer, updateRemovalCursor], + ); + + const handleTagDragEnterDoc = useCallback( + (event, docId) => runTagHoverTransition(event, docId, { applyTarget: true }), + [runTagHoverTransition], + ); + + const handleTagDragOverDoc = useCallback( + (event, docId) => runTagHoverTransition(event, docId, { applyTarget: true, applyPending: true }), + [runTagHoverTransition], + ); + + const handleTagDragLeaveDoc = useCallback( + (event) => runTagHoverTransition(event, null, { applyTarget: true, applyPending: true }), + [runTagHoverTransition], + ); + + const handleCanvasDragOver = useCallback( + (event) => runTagHoverTransition(event, null), + [runTagHoverTransition], + ); + + const handleCanvasDragLeave = useCallback( + (event) => runTagHoverTransition(event, null, { applyTarget: true, applyPending: true }), + [runTagHoverTransition], + ); + + const handleCanvasDrop = useCallback( + (event) => runTagHoverTransition(event, null, { applyTarget: true, applyPending: true }), + [runTagHoverTransition], + ); + + const handleTagDropOnDoc = useCallback( + (event, doc) => { + if (!doc) { + return; + } + if (!isTagTransfer(event)) { + return; + } + preventAll(event); + engine.setTagDropTargetId(null); + engine.setPendingTagDocId(null); + + const payload = parseTagTransferPayload(event); + if (!payload) { + return; + } + markActiveTagDropHandled(payload.tagId, payload.sourceDocId); + + if (payload.sourceDocId === doc.id) { + return; + } + + requestCanvasFocus?.(); + + void safeInvoke(onAssignTagToDocument, doc.id, payload.tagId, { sourceDocId: payload.sourceDocId }); + }, + [engine, isTagTransfer, markActiveTagDropHandled, onAssignTagToDocument, requestCanvasFocus], + ); + + const handleDocTagDragStart = useCallback( + (event, doc, tag) => { + if (!event?.dataTransfer || !doc || !tag) { + return; + } + event.stopPropagation(); + event.dataTransfer.effectAllowed = 'move'; + writeTagTransferData(event.dataTransfer, { docId: doc.id, tagId: tag.id }); + + const { x: pointerX, y: pointerY } = getPointerPosition(event, { fallbackToPage: false }); + const { clone, offsetX, offsetY } = createDragPreview(event.currentTarget, pointerX, pointerY) || {}; + if (clone && typeof event.dataTransfer.setDragImage === 'function') { + event.dataTransfer.setDragImage(clone, offsetX || 0, offsetY || 0); + } + + const element = event.currentTarget instanceof HTMLElement ? event.currentTarget : null; + if (element) { + element.classList.add('is-drag-hidden'); + } + + draggingTagRef.current = { + element, + previewClone: clone, + sourceDocId: doc.id, + tagId: tag.id, + initialX: pointerX, + initialY: pointerY, + distance: 0, + dropHandled: false, + }; + + updateRemovalCursor(false); + }, + [updateRemovalCursor], + ); + + const handleDocTagDrag = useCallback((event) => { + const state = draggingTagRef.current; + if (!state) { + return; + } + const { x, y } = getPointerPosition(event); + const dx = x - (state.initialX || 0); + const dy = y - (state.initialY || 0); + state.distance = Math.sqrt(dx * dx + dy * dy); + if (state.distance >= TAG_REMOVE_DISTANCE) { + updateRemovalCursor(true); + } else { + updateRemovalCursor(false); + } + }, [updateRemovalCursor]); + + const handleDocTagDragEnd = useCallback( + (event) => { + finalizeTagDrag(event?.dataTransfer?.dropEffect || 'none'); + const state = draggingTagRef.current; + if (!state) { + return; + } + const element = state.element; + if (element) { + element.classList.remove('is-drag-hidden'); + } + cleanupPreview(state.previewClone); + draggingTagRef.current = null; + }, + [finalizeTagDrag], + ); + + useEffect(() => { + return () => { + draggingTagRef.current = null; + pendingDocTagDragRef.current = null; + }; + }, []); + + return { + handleTagDragEnterDoc, + handleTagDragOverDoc, + handleTagDragLeaveDoc, + handleTagDropOnDoc, + handleDocTagPointerDown, + handleDocTagDragStart, + handleDocTagDrag, + handleDocTagDragEnd, + handleTagDragEnd, + markActiveTagDropHandled, + handleCanvasDragOver, + handleCanvasDragLeave, + handleCanvasDrop, + }; +}; + +export default useDeskTagInteractions; diff --git a/frontend/src/desktop/useDocumentDrag.js b/frontend/src/desktop/useDocumentDrag.js index d982bf6..b22af37 100644 --- a/frontend/src/desktop/useDocumentDrag.js +++ b/frontend/src/desktop/useDocumentDrag.js @@ -1,17 +1,18 @@ import { useCallback, useEffect, useRef } from 'react'; -import { useDesktopContext } from './context'; -import { preventAll } from './events'; -import { clamp, formatTransform } from './math'; +import { preventAll, safeInvoke } from './events'; +import { clamp } from './math'; import usePointerTap from '../ui/usePointerTap'; -import createDragPhysics, { MIN_TIMESTEP, MAX_TIMESTEP } from './dragPhysics'; +import { MIN_TIMESTEP, MAX_TIMESTEP, applyDomTransform } from './workspaceEngine'; const DRAG_HYSTERESIS_PX = 4; const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX; const EDGE_COLLISION_THRESHOLD = 0.5; -const useDocumentDrag = () => { +const useDocumentDrag = (options = {}) => { const { + engine, layoutRef, + dragTransformsRef, itemRefs, documentLookup, ensureDocumentSize, @@ -28,42 +29,22 @@ const useDocumentDrag = () => { onDocumentStackSelect, selectedDocumentIds, markLayoutDirty, - } = useDesktopContext(); + } = options; - const markLayoutDirtyRef = useRef(markLayoutDirty); - useEffect(() => { - markLayoutDirtyRef.current = markLayoutDirty; - }, [markLayoutDirty]); - - const syncLayoutSnapshotRef = useRef(syncLayoutSnapshot); - useEffect(() => { - syncLayoutSnapshotRef.current = syncLayoutSnapshot; - }, [syncLayoutSnapshot]); - - const physicsRef = useRef(null); - if (!physicsRef.current) { - physicsRef.current = createDragPhysics({ - layoutRef, - itemRefs, - markLayoutDirtyRef, - syncLayoutSnapshotRef, - }); - } + const { + canvasPadding = 24, + defaultCanvasWidth = 1024, + defaultCanvasHeight = 680, + debugDrag = false, + } = settings || {}; useEffect( () => () => { - physicsRef.current?.dispose?.(); + engine?.disposeInertiaAnimations?.(); }, - [], + [engine], ); - const { - applyTransform, - finalizeGroupDrag, - cancelInertiaAnimation, - startInertiaAnimation, - } = physicsRef.current; - const tapHandler = usePointerTap({ delay: 220, onSingle: ({ data, event }) => { @@ -87,31 +68,77 @@ const useDocumentDrag = () => { openOverlayForDoc(data.docId, data.originInfo); }, }); - const dragStateRef = useRef(null); - const { canvasPadding, defaultCanvasWidth, defaultCanvasHeight, debugDrag } = settings; - const finishDrag = useCallback( - (pointerId) => { - const state = dragStateRef.current; - if (!state || state.pointerId !== pointerId) { + const setDragTransform = useCallback((docKey, transform) => { + if (!docKey) { + return; + } + const map = dragTransformsRef?.current; + if (!map) { + return; + } + map.set(String(docKey), transform); + }, [dragTransformsRef]); + + const clearDragTransforms = useCallback(() => { + const map = dragTransformsRef?.current; + if (!map || typeof map.clear !== 'function') { + return; + } + map.clear(); + }, [dragTransformsRef]); + + const commitActiveDragTransforms = useCallback((docIds = null) => { + const map = dragTransformsRef?.current; + if (!map || !map.size) { + return; + } + const keys = Array.isArray(docIds) && docIds.length + ? docIds.map((id) => (id != null ? String(id) : null)).filter(Boolean) + : Array.from(map.keys()); + keys.forEach((key) => { + const transform = map.get(key); + if (!transform) { return; } - const capturedTarget = state.capturedTarget; - if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') { - try { - capturedTarget.releasePointerCapture(pointerId); - } catch (error) { - if (debugDrag) { - console.warn('[desk] releasePointerCapture failed', error); + const previous = layoutRef.current.get(key) || {}; + layoutRef.current.set(key, { + ...previous, + centerX: transform.centerX, + centerY: transform.centerY, + rotation: transform.rotation ?? previous.rotation ?? 0, + }); + }); + markLayoutDirty?.(); + }, [dragTransformsRef, layoutRef, markLayoutDirty]); + + const finishDrag = useCallback( + (pointerId, { shouldSync = false, clearTransforms = true } = {}) => { + const state = dragStateRef.current; + if (state && state.pointerId === pointerId) { + const capturedTarget = state.capturedTarget; + if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') { + try { + capturedTarget.releasePointerCapture(pointerId); + } catch (error) { + if (debugDrag) { + console.warn('[desk] releasePointerCapture failed', error); + } } } } dragStateRef.current = null; - setDraggingId((current) => (current === state.docId ? null : current)); - syncLayoutSnapshot(); + setDraggingId(null); + engine?.endDrag?.(); + if (clearTransforms) { + clearDragTransforms(); + } + if (shouldSync) { + syncLayoutSnapshot(true); + } }, - [debugDrag, setDraggingId, syncLayoutSnapshot], + [clearDragTransforms, debugDrag, engine, setDraggingId, syncLayoutSnapshot], ); const handlePointerDown = useCallback( @@ -136,7 +163,7 @@ const useDocumentDrag = () => { return; } - cancelInertiaAnimation(docId); + engine?.cancelInertiaAnimation?.(docKey); const doc = documentLookup.get(docKey); if (!doc) { @@ -193,7 +220,7 @@ const useDocumentDrag = () => { if (isGroupDrag) { groupDocIds.forEach((id) => { if (id !== docKey) { - cancelInertiaAnimation(id); + engine?.cancelInertiaAnimation?.(id); } }); } @@ -300,11 +327,13 @@ const useDocumentDrag = () => { const hasStackSource = Array.isArray(stackDocIdsOption) && stackDocIdsOption.length > 1; dragStateRef.current = { - docId, + docId: docKey, docKey, pointerId: event.pointerId, originCenterX: centerX, originCenterY: centerY, + currentCenterX: centerX, + currentCenterY: centerY, startX: event.clientX, startY: event.clientY, rotation: entry?.rotation ?? 0, @@ -334,7 +363,26 @@ const useDocumentDrag = () => { stackReplace, }; - setDraggingId(docId); + const state = dragStateRef.current; + + clearDragTransforms(); + state.groupItems.forEach((item) => { + if (!item?.docId) { + return; + } + setDragTransform(item.docId, { + centerX: item.currentCenterX, + centerY: item.currentCenterY, + rotation: item.displayRotation ?? item.initialRotation ?? 0, + width: item.width, + height: item.height, + scale: item.docId === state.docKey ? state.dragScale || 1 : 1, + }); + }); + + engine?.beginDrag?.(state.groupDocIds); + + setDraggingId(docKey); if (isGroupDrag) { groupItems.forEach((item) => { @@ -344,22 +392,23 @@ const useDocumentDrag = () => { const node = itemRefs.current.get(item.docId); if (node) { item.displayRotation = item.initialRotation; - node.style.transform = formatTransform( - item.currentCenterX - item.width / 2, - item.currentCenterY - item.height / 2, - item.displayRotation, - 1, - ); + applyDomTransform(node, { + centerX: item.currentCenterX, + centerY: item.currentCenterY, + width: item.width, + height: item.height, + rotation: item.displayRotation ?? 0, + scale: 1, + }); } }); } - }, - [ + }, [ bringToFront, - canvasPadding, - cancelInertiaAnimation, - containerRef, - documentLookup, + canvasPadding, + containerRef, + documentLookup, + engine, ensureDocumentSize, layoutRef, resolveBaseMetrics, @@ -367,8 +416,9 @@ const useDocumentDrag = () => { setDraggingId, debugDrag, itemRefs, - ], -); + clearDragTransforms, + setDragTransform, + ]); const handlePointerMove = useCallback( (event) => { @@ -411,14 +461,11 @@ const useDocumentDrag = () => { } state.moved = true; if ( - state.isGroup - && !state.stackSelectionApplied + !state.stackSelectionApplied && Array.isArray(state.stackDocIds) && state.stackDocIds.length > 0 ) { - if (typeof onDocumentStackSelect === 'function') { - onDocumentStackSelect(state.stackDocIds, event, { replace: state.stackReplace }); - } + safeInvoke(onDocumentStackSelect, state.stackDocIds, event, { replace: state.stackReplace }); state.stackSelectionApplied = true; } if (!state.groupElevated) { @@ -431,11 +478,8 @@ const useDocumentDrag = () => { return aZ - bZ; }); - sortedGroup.forEach((id) => { - bringToFront(id); - }); - - bringToFront(state.docId); + sortedGroup.forEach((id) => bringToFront(id)); + bringToFront(state.docKey); state.groupElevated = true; } } @@ -456,22 +500,8 @@ const useDocumentDrag = () => { const centerX = clamp(desiredCenterX, minCenterX, maxCenterX); const centerY = clamp(desiredCenterY, minCenterY, maxCenterY); - const primaryEntry = layoutRef.current.get(state.docKey) || {}; - const primaryRotation = state.rotation ?? primaryEntry.rotation ?? 0; - layoutRef.current.set(state.docKey, { - ...primaryEntry, - centerX, - centerY, - }); - const primaryNode = itemRefs.current.get(state.docId); - if (primaryNode) { - primaryNode.style.transform = formatTransform( - centerX - docWidth / 2, - centerY - docHeight / 2, - primaryRotation, - state.dragScale || 1, - ); - } + state.currentCenterX = centerX; + state.currentCenterY = centerY; state.groupItems.forEach((item) => { const isPrimary = item.docId === state.docKey; @@ -508,25 +538,18 @@ const useDocumentDrag = () => { item.displayRotation += (item.targetRotation - item.displayRotation) * rotationBlend; } - markLayoutDirty?.(); - - const entryItem = layoutRef.current.get(item.docId) || {}; - layoutRef.current.set(item.docId, { - ...entryItem, + const payload = { centerX: item.currentCenterX, centerY: item.currentCenterY, - rotation: item.displayRotation ?? entryItem.rotation ?? 0, - }); + rotation: item.displayRotation ?? 0, + width: item.width, + height: item.height, + scale: isPrimary ? state.dragScale || 1 : 1, + }; - applyTransform( - item.docId, - item.currentCenterX, - item.currentCenterY, - item.width, - item.height, - item.displayRotation ?? entryItem.rotation ?? 0, - isPrimary ? state.dragScale || 1 : 1, - ); + setDragTransform(item.docId, payload); + const node = itemRefs.current.get(item.docId); + applyDomTransform(node, payload); }); state.lastClientX = event.clientX; @@ -538,21 +561,15 @@ const useDocumentDrag = () => { ? performance.now() : Date.now(); - recalcVisibleDocIds(); return; } if (state.locked) { if (debugDrag) { - console.log('[desk] handlePointerMove: locked drag for doc', state.docId); + console.log('[desk] handlePointerMove: locked drag for doc', state.docKey); } return; } - const entry = layoutRef.current.get(state.docId); - if (!entry) { - return; - } - const deltaX = event.clientX - state.startX; const deltaY = event.clientY - state.startY; @@ -572,7 +589,8 @@ const useDocumentDrag = () => { const pointerCanvasX = event.clientX - containerLeft; const pointerCanvasY = event.clientY - containerTop; - const rotationDeg = entry?.rotation ?? 0; + const entry = layoutRef.current.get(state.docKey) || {}; + const rotationDeg = state.rotation ?? entry.rotation ?? 0; const rotationRad = (rotationDeg * Math.PI) / 180; const cosRot = Math.cos(rotationRad); const sinRot = Math.sin(rotationRad); @@ -581,8 +599,12 @@ const useDocumentDrag = () => { const rotatedOffsetY = state.localPointerOffsetX * sinRot + state.localPointerOffsetY * cosRot; - const previousCenterX = Number.isFinite(entry.centerX) ? entry.centerX : state.originCenterX; - const previousCenterY = Number.isFinite(entry.centerY) ? entry.centerY : state.originCenterY; + const previousCenterX = Number.isFinite(state.currentCenterX) + ? state.currentCenterX + : state.originCenterX; + const previousCenterY = Number.isFinite(state.currentCenterY) + ? state.currentCenterY + : state.originCenterY; const absCos = Math.abs(cosRot); const absSin = Math.abs(sinRot); @@ -614,7 +636,7 @@ const useDocumentDrag = () => { if (distanceSquared < DRAG_HYSTERESIS_SQUARED) { return; } - bringToFront(state.docId); + bringToFront(state.docKey); state.moved = true; } @@ -631,28 +653,21 @@ const useDocumentDrag = () => { currentCenterY = previousCenterY; } - const updated = { ...entry, centerX: currentCenterX, centerY: currentCenterY }; - layoutRef.current.set(state.docId, updated); + state.currentCenterX = currentCenterX; + state.currentCenterY = currentCenterY; - applyTransform( - state.docId, - currentCenterX, - currentCenterY, - state.width, - state.height, - rotationDeg, - state.dragScale || 1, - ); + const transformPayload = { + centerX: currentCenterX, + centerY: currentCenterY, + rotation: rotationDeg, + width: state.width, + height: state.height, + scale: state.dragScale || 1, + }; - const primaryNode = itemRefs.current.get(state.docId); - if (primaryNode) { - primaryNode.style.transform = formatTransform( - currentCenterX - state.width / 2, - currentCenterY - state.height / 2, - rotationDeg, - state.dragScale || 1, - ); - } + setDragTransform(state.docKey, transformPayload); + const primaryNode = itemRefs.current.get(state.docKey); + applyDomTransform(primaryNode, transformPayload); const offsetX = pointerCanvasX - currentCenterX; const offsetY = pointerCanvasY - currentCenterY; @@ -663,8 +678,6 @@ const useDocumentDrag = () => { const pointerInsideCard = Math.abs(pointerLocalX) <= halfWidth && Math.abs(pointerLocalY) <= halfHeight; - markLayoutDirty?.(); - const currentTimestamp = typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp) ? event.timeStamp @@ -696,9 +709,8 @@ const useDocumentDrag = () => { } if (debugDrag) { - console.log('[desk] handlePointerMove: moved doc', state.docId, 'to', currentCenterX, currentCenterY); + console.log('[desk] handlePointerMove: moved doc', state.docKey, 'to', currentCenterX, currentCenterY); } - recalcVisibleDocIds(); }, [ bringToFront, @@ -710,11 +722,9 @@ const useDocumentDrag = () => { containerRef, layoutRef, itemRefs, - applyTransform, - recalcVisibleDocIds, debugDrag, onDocumentStackSelect, - markLayoutDirty, + setDragTransform, ], ); @@ -727,13 +737,15 @@ const useDocumentDrag = () => { } if (state.isGroup) { - finalizeGroupDrag(state); - finishDrag(event.pointerId); + engine?.finalizeGroupDrag?.(state); + commitActiveDragTransforms(state.groupDocIds); + finishDrag(event.pointerId, { shouldSync: true }); recalcVisibleDocIds(); return; } if (state.moved) { + commitActiveDragTransforms([state.docKey]); const inertiaState = { restRotation: state.restRotation, dynamicRotation: state.dynamicRotation, @@ -743,13 +755,13 @@ const useDocumentDrag = () => { height: state.height, dragScale: state.dragScale || 1, }; - const docId = state.docId; - finishDrag(event.pointerId); - startInertiaAnimation(docId, inertiaState); + const docId = state.docKey; + finishDrag(event.pointerId, { shouldSync: true }); + engine?.startInertiaAnimation?.(docId, inertiaState); return; } - const docId = state.docId; + const docId = state.docKey; const metaPressed = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; if (!metaPressed) { bringToFront(docId); @@ -771,10 +783,10 @@ const useDocumentDrag = () => { }, [ bringToFront, + commitActiveDragTransforms, documentLookup, + engine, finishDrag, - finalizeGroupDrag, - startInertiaAnimation, recalcVisibleDocIds, tapHandler, ], @@ -785,12 +797,14 @@ const useDocumentDrag = () => { const state = dragStateRef.current; if (state && state.pointerId === event.pointerId && state.moved) { if (state.isGroup) { - finalizeGroupDrag(state); - finishDrag(event.pointerId); + engine?.finalizeGroupDrag?.(state); + commitActiveDragTransforms(state.groupDocIds); + finishDrag(event.pointerId, { shouldSync: true }); recalcVisibleDocIds(); return; } + commitActiveDragTransforms([state.docKey]); const inertiaState = { restRotation: state.restRotation, dynamicRotation: state.dynamicRotation, @@ -800,14 +814,14 @@ const useDocumentDrag = () => { height: state.height, dragScale: state.dragScale || 1, }; - const docId = state.docId; - finishDrag(event.pointerId); - startInertiaAnimation(docId, inertiaState); + const docId = state.docKey; + finishDrag(event.pointerId, { shouldSync: true }); + engine?.startInertiaAnimation?.(docId, inertiaState); return; } finishDrag(event.pointerId); }, - [finalizeGroupDrag, finishDrag, recalcVisibleDocIds, startInertiaAnimation], + [commitActiveDragTransforms, engine, finishDrag, recalcVisibleDocIds], ); return { diff --git a/frontend/src/desktop/workspaceEngine.js b/frontend/src/desktop/workspaceEngine.js new file mode 100644 index 0000000..65eb177 --- /dev/null +++ b/frontend/src/desktop/workspaceEngine.js @@ -0,0 +1,1166 @@ +import { clamp, formatTransform } from './math.js'; +import { fetchLayoutRecords, upsertLayoutRecords } from './db.js'; + +export const DESK_CANVAS_PADDING = 24; +export const DESK_ROTATION_RANGE = 7; +export const DESK_DEFAULT_CANVAS_WIDTH = 1024; +export const DESK_DEFAULT_CANVAS_HEIGHT = 680; +export const DESK_CARD_MIN = 240; +export const DESK_CARD_MAX = 340; + +const DEFAULT_Z_START = 10; +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; + +export const applyDomTransform = ( + node, + { + centerX, + centerY, + width, + height, + rotation = 0, + scale = 1, + } = {}, +) => { + if (!node) { + return; + } + const w = Number(width) || 0; + const h = Number(height) || 0; + const cx = Number(centerX) || 0; + const cy = Number(centerY) || 0; + const originX = cx - w / 2; + const originY = cy - h / 2; + node.style.transform = formatTransform(originX, originY, rotation || 0, scale || 1); +}; + +export const clampCardDimensions = (width, height) => { + const w = Number(width); + const h = Number(height); + + if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) { + return null; + } + + const low = Math.max(DESK_CARD_MIN / w, DESK_CARD_MIN / h); + const high = Math.min(DESK_CARD_MAX / w, DESK_CARD_MAX / h); + + const candidates = []; + const addCandidate = (scale) => { + if (Number.isFinite(scale) && scale > 0) { + candidates.push(scale); + } + }; + + addCandidate(1); + addCandidate(low); + addCandidate(high); + + const best = candidates.reduce((acc, scale) => { + const scaledWidth = w * scale; + const scaledHeight = h * scale; + const violation = Math.max( + Math.max(DESK_CARD_MIN - scaledWidth, 0), + Math.max(scaledWidth - DESK_CARD_MAX, 0), + Math.max(DESK_CARD_MIN - scaledHeight, 0), + Math.max(scaledHeight - DESK_CARD_MAX, 0), + ); + const deviation = Math.abs(scale - 1); + if (!acc || violation < acc.violation || (violation === acc.violation && deviation < acc.deviation)) { + return { scale, violation, deviation }; + } + return acc; + }, null); + + const scale = best ? best.scale : 1; + return { + width: Math.round(w * scale), + height: Math.round(h * scale), + }; +}; + +export const computeFallbackCardSize = (docId) => { + const baseSeed = seededRandom(`${docId}:fallback-size`); + const aspectSeed = seededRandom(`${docId}:fallback-aspect`); + + const width = DESK_CARD_MIN + baseSeed * (DESK_CARD_MAX - DESK_CARD_MIN); + const isPortrait = aspectSeed < 0.5; + const normalizedSeed = isPortrait ? aspectSeed / 0.5 : (aspectSeed - 0.5) / 0.5; + const aspectRange = 0.75; + const aspect = isPortrait + ? 1 + normalizedSeed * aspectRange + : 1 / (1 + normalizedSeed * aspectRange); + const height = width * aspect; + + return clampCardDimensions(width, height); +}; + +function seededRandom(input) { + const text = String(input); + let hash = 2166136261; + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0) / 4294967295; +} + +function randomRangeFromSeed(seedKey, min, max) { + const span = max - min; + if (span <= 0) return min; + const seed = seededRandom(seedKey); + return min + seed * span; +} + +function buildKey(docId, suffix) { + return `${docId}::${suffix}`; +} + +const signedDistanceToEdge = (edgeStart, edgeEnd, point) => + (edgeEnd.x - edgeStart.x) * (point.y - edgeStart.y) + - (edgeEnd.y - edgeStart.y) * (point.x - edgeStart.x); + +const iterateEdges = (polygon, callback) => { + if (!Array.isArray(polygon) || polygon.length === 0) { + return; + } + for (let index = 0; index < polygon.length; index += 1) { + const current = polygon[index]; + const next = polygon[(index + 1) % polygon.length]; + if (callback(current, next, index) === false) { + break; + } + } +}; + +const forEachVertex = (polygon, callback) => { + if (!Array.isArray(polygon) || polygon.length === 0) { + return; + } + for (let index = 0; index < polygon.length; index += 1) { + const current = polygon[index]; + const prev = polygon[(index - 1 + polygon.length) % polygon.length]; + if (callback(current, prev, index) === false) { + break; + } + } +}; + +const lineIntersection = (p1, p2, cp1, cp2) => { + const A1 = p2.y - p1.y; + const B1 = p1.x - p2.x; + const C1 = A1 * p1.x + B1 * p1.y; + + const A2 = cp2.y - cp1.y; + const B2 = cp1.x - cp2.x; + const C2 = A2 * cp1.x + B2 * cp1.y; + + const det = A1 * B2 - A2 * B1; + if (Math.abs(det) < 1e-6) { + return { x: cp1.x, y: cp1.y }; + } + return { + x: (B2 * C1 - B1 * C2) / det, + y: (A1 * C2 - A2 * C1) / det, + }; +}; + +const clipPolygon = (subject, clipper) => { + if (!Array.isArray(subject) || !subject.length) { + return []; + } + let output = subject; + iterateEdges(clipper, (cp1, cp2) => { + const input = output; + output = []; + if (!Array.isArray(input) || !input.length) { + return false; + } + forEachVertex(input, (current, prev) => { + const currentInside = signedDistanceToEdge(cp1, cp2, current) >= 0; + const prevInside = signedDistanceToEdge(cp1, cp2, prev) >= 0; + if (currentInside) { + if (!prevInside) { + output.push(lineIntersection(prev, current, cp1, cp2)); + } + output.push(current); + } else if (prevInside) { + output.push(lineIntersection(prev, current, cp1, cp2)); + } + return true; + }); + return output.length > 0; + }); + return output; +}; + +const isPointInsideConvex = (point, polygon) => { + if (!polygon?.length) { + return false; + } + let sign = 0; + let inside = true; + iterateEdges(polygon, (a, b) => { + const cross = signedDistanceToEdge(a, b, point); + if (cross === 0) { + return true; + } + const currentSign = cross > 0 ? 1 : -1; + if (sign === 0) { + sign = currentSign; + return true; + } + if (sign !== currentSign) { + inside = false; + return false; + } + return true; + }); + return inside; +}; + +const polygonCentroid = (polygon) => { + if (!polygon?.length) { + return { x: 0, y: 0 }; + } + let area = 0; + let cx = 0; + let cy = 0; + iterateEdges(polygon, (current, next) => { + const cross = current.x * next.y - next.x * current.y; + area += cross; + cx += (current.x + next.x) * cross; + cy += (current.y + next.y) * cross; + }); + if (Math.abs(area) < 1e-6) { + let sumX = 0; + let sumY = 0; + forEachVertex(polygon, (point) => { + sumX += point.x; + sumY += point.y; + }); + return { + x: sumX / polygon.length, + y: sumY / polygon.length, + }; + } + const areaFactor = 1 / (3 * area); + return { + x: cx * areaFactor, + y: cy * areaFactor, + }; +}; +const generateInitialLayout = ( + entries, + { + canvasWidth, + canvasHeight, + padding, + startZ = 0, + rotationRange = DESK_ROTATION_RANGE, + minSpacing = 48, + shelfWidth = 0, + }, +) => { + const layout = new Map(); + let currentZ = startZ; + let maxZ = startZ; + + if (!entries.length) { + return { layout, maxZ }; + } + + const shelfOffset = Math.max(shelfWidth, 0); + const spacingBuffer = Math.max(minSpacing, 0); + const placed = []; + + const resolveBounds = (width, height) => { + const halfWidth = width / 2; + const halfHeight = height / 2; + return { + minCenterX: padding + halfWidth, + maxCenterX: Math.max( + padding + halfWidth, + canvasWidth - shelfOffset - padding - halfWidth, + ), + minCenterY: padding + halfHeight, + maxCenterY: Math.max(padding + halfHeight, canvasHeight - padding - halfHeight), + }; + }; + + const evaluateCandidateSpacing = (x, y, radius) => { + if (!placed.length) { + return Number.POSITIVE_INFINITY; + } + let best = Number.POSITIVE_INFINITY; + for (let i = 0; i < placed.length; i += 1) { + const item = placed[i]; + const dx = item.x - x; + const dy = item.y - y; + const distance = Math.sqrt(dx * dx + dy * dy) - item.radius - radius - spacingBuffer; + if (distance < best) { + best = distance; + } + } + return best; + }; + + entries.forEach((entry) => { + const width = Number(entry.width) || 0; + const height = Number(entry.height) || 0; + if (!entry.id || width <= 0 || height <= 0) { + return; + } + + const { minCenterX, maxCenterX, minCenterY, maxCenterY } = resolveBounds(width, height); + + const radius = Math.sqrt(width * width + height * height) / 2; + + let bestScore = -Infinity; + let bestX = (minCenterX + maxCenterX) / 2; + let bestY = (minCenterY + maxCenterY) / 2; + const samplesPerAxis = 14; + for (let gx = 0; gx < samplesPerAxis; gx += 1) { + const fracX = (gx + 0.5) / samplesPerAxis; + for (let gy = 0; gy < samplesPerAxis; gy += 1) { + const fracY = (gy + 0.5) / samplesPerAxis; + const candidateX = minCenterX + fracX * (maxCenterX - minCenterX); + const candidateY = minCenterY + fracY * (maxCenterY - minCenterY); + const edgeSpacing = Math.min( + candidateX - minCenterX, + maxCenterX - candidateX, + candidateY - minCenterY, + maxCenterY - candidateY, + ) - spacingBuffer * 0.5; + if (edgeSpacing <= 0) { + continue; + } + const neighborSpacing = evaluateCandidateSpacing(candidateX, candidateY, radius); + const score = Math.min(edgeSpacing, neighborSpacing); + if (score > bestScore) { + bestScore = score; + bestX = candidateX; + bestY = candidateY; + } + } + } + const centerX = clamp(bestX, minCenterX, maxCenterX); + const centerY = clamp(bestY, minCenterY, maxCenterY); + + const rotation = randomRangeFromSeed( + buildKey(entry.id, 'rotation'), + -rotationRange, + rotationRange, + ); + + currentZ += 1; + layout.set(entry.id, { + centerX, + centerY, + rotation, + z: currentZ, + width, + height, + }); + maxZ = Math.max(maxZ, currentZ); + + placed.push({ x: centerX, y: centerY, radius }); + }); + + return { layout, maxZ }; +}; + +export class WorkspaceEngine { + constructor({ + allowLayoutPersistence = false, + tenantId = null, + viewId = null, + } = {}) { + this.allowLayoutPersistence = allowLayoutPersistence; + this.tenantId = tenantId; + this.viewId = viewId; + + this.layout = new Map(); + this.layoutSnapshot = new Map(); + this.persistedLayout = new Map(); + this.layoutDirty = false; + this.zCounter = DEFAULT_Z_START; + this.canvasSize = { width: 0, height: 0 }; + this.visibleDocIds = new Set(); + this.draggingId = null; + this.tagDropTargetId = null; + this.pendingTagDocId = null; + this.pendingRemovalTag = null; + this.dragInProgress = false; + this.activeDragDocIds = new Set(); + + this.items = []; + this.documentLookup = new Map(); + this.ensureDocumentSize = () => null; + this.resolveBaseMetrics = () => ({ baseWidth: 0, baseHeight: 0, baseScale: 1 }); + + this.snapshotCache = this.buildSnapshot(); + this.subscribers = new Set(); + + this.loadingPersisted = false; + this.pendingPersistence = null; + this.itemRefs = { current: new Map() }; + this.inertiaAnimations = new Map(); + this.initialLoadDone = false; + } + + updateConfig({ allowLayoutPersistence, tenantId, viewId }) { + const allowChanged = + typeof allowLayoutPersistence === 'boolean' + && allowLayoutPersistence !== this.allowLayoutPersistence; + const tenantChanged = tenantId !== undefined && tenantId !== this.tenantId; + const viewChanged = viewId !== undefined && viewId !== this.viewId; + + if (!allowChanged && !tenantChanged && !viewChanged) { + return; + } + + if (allowChanged) { + this.allowLayoutPersistence = allowLayoutPersistence; + } + if (tenantChanged) { + this.tenantId = tenantId; + } + if (viewChanged) { + this.viewId = viewId; + } + + if (!this.allowLayoutPersistence) { + this.persistedLayout = new Map(); + this.layoutDirty = false; + this.emit(); + return; + } + + if (!this.tenantId || !this.viewId) { + return; + } + + if (!this.initialLoadDone) { + this.loadPersistedLayout(); + } + } + + setItems(items) { + const normalized = Array.isArray(items) ? items : []; + this.items = normalized; + this.ensureLayoutForItems(); + this.recalcVisibleDocIds(); + } + + setDocumentLookup(map) { + this.documentLookup = map instanceof Map ? map : new Map(); + this.recalcVisibleDocIds(); + } + + setEnsureDocumentSize(fn) { + if (typeof fn === 'function') { + this.ensureDocumentSize = fn; + } + } + + setResolveBaseMetrics(fn) { + if (typeof fn === 'function') { + this.resolveBaseMetrics = fn; + } + } + + setItemRefs(ref) { + this.itemRefs = ref || { current: new Map() }; + } + + setCanvasSize(size) { + const width = Number(size?.width) || 0; + const height = Number(size?.height) || 0; + if (this.canvasSize.width === width && this.canvasSize.height === height) { + return; + } + this.canvasSize = { width, height }; + this.ensureLayoutForItems(); + this.recalcVisibleDocIds(); + this.emit(); + } + + setDraggingId(docId) { + const normalized = docId != null ? String(docId) : null; + if (this.draggingId === normalized) { + return; + } + this.draggingId = normalized; + this.emit(); + } + + beginDrag(docIds = []) { + this.dragInProgress = true; + if (Array.isArray(docIds)) { + this.activeDragDocIds = new Set(docIds.map((id) => (id != null ? String(id) : null)).filter(Boolean)); + } else { + this.activeDragDocIds.clear(); + } + } + + endDrag() { + this.dragInProgress = false; + this.activeDragDocIds.clear(); + } + + setTagDropTargetId(docId) { + const normalized = docId != null ? String(docId) : null; + if (this.tagDropTargetId === normalized) { + return; + } + this.tagDropTargetId = normalized; + this.emit(); + } + + setPendingTagDocId(docId) { + const normalized = docId != null ? String(docId) : null; + if (this.pendingTagDocId === normalized) { + return; + } + this.pendingTagDocId = normalized; + this.emit(); + } + + setPendingRemovalTag(payload) { + if (payload === this.pendingRemovalTag) { + return; + } + this.pendingRemovalTag = payload; + this.emit(); + } + + markLayoutDirty() { + this.layoutDirty = true; + } + + getLayout(docId) { + if (docId == null) { + return null; + } + const key = String(docId); + return this.layout.get(key) || null; + } + + updateLayoutEntry(docId, updater) { + if (docId == null) { + return; + } + const key = String(docId); + const previous = this.layout.get(key) || null; + const next = typeof updater === 'function' ? updater(previous || {}) : updater; + if (!next) { + this.layout.delete(key); + } else { + this.layout.set(key, next); + } + this.markLayoutDirty(); + this.syncLayoutSnapshot(); + } + + bringToFront(docId) { + if (docId == null) { + return; + } + const key = String(docId); + const entry = this.layout.get(key); + if (!entry) { + return; + } + this.zCounter += 1; + this.layout.set(key, { ...entry, z: this.zCounter }); + this.markLayoutDirty(); + this.syncLayoutSnapshot(); + this.recalcVisibleDocIds(); + } + + applyTransform(docId, centerX, centerY, width, height, rotation, scale = 1) { + const key = docId != null ? String(docId) : null; + if (!key) { + return; + } + const node = this.itemRefs?.current?.get(key); + applyDomTransform(node, { + centerX, + centerY, + width, + height, + rotation, + scale, + }); + } + + finalizeGroupDrag(dragState) { + if (!dragState?.groupItems) { + return; + } + + dragState.groupItems.forEach((item) => { + if (!item) { + return; + } + const key = item.docId != null ? String(item.docId) : null; + if (!key) { + return; + } + const entry = this.layout.get(key) || {}; + const centerX = item.currentCenterX ?? entry.centerX ?? dragState.originCenterX; + const centerY = item.currentCenterY ?? entry.centerY ?? dragState.originCenterY; + const rotation = item.displayRotation ?? entry.rotation ?? 0; + + this.layout.set(key, { + ...entry, + centerX, + centerY, + rotation, + }); + + this.applyTransform( + key, + centerX, + centerY, + item.width, + item.height, + rotation, + key === dragState.docKey ? dragState.dragScale || 1 : 1, + ); + }); + + this.markLayoutDirty(); + } + + cancelInertiaAnimation(docId) { + const key = docId != null ? String(docId) : null; + if (!key) { + return; + } + if (typeof window === 'undefined') { + this.inertiaAnimations.delete(key); + return; + } + const existing = this.inertiaAnimations.get(key); + if (existing && typeof window.cancelAnimationFrame === 'function') { + window.cancelAnimationFrame(existing.frameId); + } + this.inertiaAnimations.delete(key); + } + + disposeInertiaAnimations() { + if (typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') { + this.inertiaAnimations.forEach((animation) => { + if (animation?.frameId != null) { + window.cancelAnimationFrame(animation.frameId); + } + }); + } + this.inertiaAnimations.clear(); + } + + integrateRotation(simulationState, dt, torque = 0, dampingOverride = null) { + const key = simulationState.docId != null ? String(simulationState.docId) : null; + if (!key) { + return true; + } + const entry = this.layout.get(key); + 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; + this.layout.set(key, { ...entry, rotation }); + this.markLayoutDirty(); + + this.applyTransform( + key, + centerX, + centerY, + simulationState.width, + simulationState.height, + rotation, + simulationState.dragScale || 1, + ); + + const isSettled = Math.abs(angularVelocity) < SETTLE_ANGULAR_VELOCITY; + return isSettled; + } + + startInertiaAnimation(docId, baseState) { + if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { + return; + } + const key = docId != null ? String(docId) : null; + if (!key) { + return; + } + + this.cancelInertiaAnimation(key); + + const now = + typeof performance !== 'undefined' && typeof performance.now === 'function' + ? performance.now() + : Date.now(); + + const simulationState = { + ...baseState, + docId: key, + 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 = this.integrateRotation(simulationState, dt, 0); + if (settled) { + this.inertiaAnimations.delete(key); + this.syncLayoutSnapshot(); + return; + } + simulationState.frameId = window.requestAnimationFrame(step); + }; + + simulationState.frameId = window.requestAnimationFrame(step); + this.inertiaAnimations.set(key, simulationState); + } + + syncLayoutSnapshot(force = false) { + if (this.dragInProgress && !force) { + return; + } + this.layoutSnapshot = new Map(this.layout); + this.emit(); + this.persistLayoutSnapshot(force); + } + + async persistLayoutSnapshot(force = false) { + if (this.dragInProgress && !force) { + return; + } + if (!this.allowLayoutPersistence || !this.tenantId || !this.viewId) { + return; + } + if (!force && !this.layoutDirty) { + return; + } + this.layoutDirty = false; + const snapshot = new Map(this.layoutSnapshot); + const merged = new Map(this.persistedLayout); + snapshot.forEach((entry, docId) => { + if (!docId || !entry) { + return; + } + const centerX = Number(entry.centerX); + const centerY = Number(entry.centerY); + if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) { + return; + } + const rotation = Number.isFinite(Number(entry.rotation)) ? Number(entry.rotation) : 0; + const z = Number.isFinite(Number(entry.z)) ? Number(entry.z) : undefined; + merged.set(docId, { centerX, centerY, rotation, z }); + }); + + this.persistedLayout = merged; + + const records = []; + merged.forEach((entry, docId) => { + if (!docId || !entry) { + return; + } + records.push({ + documentId: docId, + centerX: entry.centerX, + centerY: entry.centerY, + rotation: entry.rotation ?? 0, + zIndex: entry.z ?? 0, + }); + }); + + try { + await upsertLayoutRecords({ tenantId: this.tenantId, viewId: this.viewId, entries: records }); + } catch (error) { + console.warn('[desk] Failed to persist layout snapshot', error); + } + } + + ensureLayoutForItems() { + if (!this.canvasSize.width || !this.canvasSize.height) { + return; + } + if (!this.items.length) { + if (this.layout.size) { + this.layout = new Map(); + this.syncLayoutSnapshot(); + } + return; + } + + const missingSizes = this.items.some((doc) => !this.ensureDocumentSize(doc)); + if (missingSizes) { + return; + } + + const next = new Map(); + let maxZ = this.zCounter; + const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH; + const canvasHeight = this.canvasSize.height || DESK_DEFAULT_CANVAS_HEIGHT; + const docsNeedingLayout = []; + + const currentEntries = new Map(this.layout); + + this.items.forEach((doc) => { + if (!doc?.id) { + return; + } + const docKey = String(doc.id); + const sizeInfo = this.ensureDocumentSize(doc); + if (!sizeInfo) { + return; + } + const { width: docWidth, height: docHeight } = sizeInfo; + const halfWidth = docWidth / 2; + const halfHeight = docHeight / 2; + + const minCenterX = DESK_CANVAS_PADDING + halfWidth; + const maxCenterX = Math.max(minCenterX, canvasWidth - DESK_CANVAS_PADDING - halfWidth); + const minCenterY = DESK_CANVAS_PADDING + halfHeight; + const maxCenterY = Math.max(minCenterY, canvasHeight - DESK_CANVAS_PADDING - halfHeight); + + const persisted = this.persistedLayout.get(docKey) || null; + let existing = currentEntries.get(docKey) || null; + if (persisted) { + existing = existing ? { ...existing, ...persisted } : { ...persisted }; + } + if (existing) { + const defaultCenterX = (minCenterX + maxCenterX) / 2; + const defaultCenterY = (minCenterY + maxCenterY) / 2; + const prevCenterX = typeof existing.centerX === 'number' ? existing.centerX : defaultCenterX; + const prevCenterY = typeof existing.centerY === 'number' ? existing.centerY : defaultCenterY; + const centerX = clamp(prevCenterX, minCenterX, maxCenterX); + const centerY = clamp(prevCenterY, minCenterY, maxCenterY); + const rotation = existing.rotation ?? 0; + const z = existing.z ?? maxZ; + maxZ = Math.max(maxZ, z); + next.set(docKey, { centerX, centerY, rotation, z, width: docWidth, height: docHeight }); + return; + } + + docsNeedingLayout.push({ + id: docKey, + width: docWidth, + height: docHeight, + seedKey: docKey, + }); + }); + + if (docsNeedingLayout.length) { + const { layout: generatedLayout, maxZ: updatedMaxZ } = generateInitialLayout( + docsNeedingLayout, + { + canvasWidth, + canvasHeight, + padding: DESK_CANVAS_PADDING, + startZ: maxZ, + rotationRange: DESK_ROTATION_RANGE, + minSpacing: 48, + shelfWidth: 0, + }, + ); + generatedLayout.forEach((entry, docId) => { + next.set(docId, entry); + }); + maxZ = Math.max(maxZ, updatedMaxZ); + } + + this.layout = next; + this.zCounter = Math.max(this.zCounter, maxZ); + this.syncLayoutSnapshot(); + this.recalcVisibleDocIds(); + } + + recalcVisibleDocIds() { + const ensureSize = this.ensureDocumentSize; + if (typeof ensureSize !== 'function') { + return; + } + + const layoutMap = this.layout; + const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH; + const canvasHeight = this.canvasSize.height || DESK_DEFAULT_CANVAS_HEIGHT; + + if (!layoutMap.size || canvasWidth <= 0 || canvasHeight <= 0) { + if (this.visibleDocIds.size) { + this.visibleDocIds = new Set(); + this.emit(); + } + return; + } + + const viewport = [ + { x: 0, y: 0 }, + { x: canvasWidth, y: 0 }, + { x: canvasWidth, y: canvasHeight }, + { x: 0, y: canvasHeight }, + ]; + + const entries = []; + layoutMap.forEach((entry, docKey) => { + if (!docKey) { + return; + } + const doc = this.documentLookup.get(docKey); + if (!doc) { + return; + } + const sizeInfo = ensureSize(doc); + if (!sizeInfo) { + return; + } + const { width: cardWidth, height: cardHeight } = sizeInfo; + const rotationDeg = Number(entry?.rotation) || 0; + const rotationRad = (rotationDeg * Math.PI) / 180; + const cosRot = Math.cos(rotationRad); + const sinRot = Math.sin(rotationRad); + const halfWidth = cardWidth / 2; + const halfHeight = cardHeight / 2; + const localCorners = [ + { x: -halfWidth, y: -halfHeight }, + { x: halfWidth, y: -halfHeight }, + { x: halfWidth, y: halfHeight }, + { x: -halfWidth, y: halfHeight }, + ]; + const centerX = entry?.centerX ?? DESK_CANVAS_PADDING + cardWidth / 2; + const centerY = entry?.centerY ?? DESK_CANVAS_PADDING + cardHeight / 2; + const corners = localCorners.map(({ x, y }) => ({ + x: centerX + x * cosRot - y * sinRot, + y: centerY + x * sinRot + y * cosRot, + })); + const clipped = clipPolygon(corners, viewport); + if (!clipped.length) { + return; + } + entries.push({ + key: docKey, + z: entry?.z ?? 0, + polygon: clipped, + }); + }); + + if (!entries.length) { + if (this.visibleDocIds.size) { + this.visibleDocIds = new Set(); + this.emit(); + } + return; + } + + entries.sort((a, b) => (b.z || 0) - (a.z || 0)); + + const visiblePolygons = []; + const result = new Set(); + + entries.forEach(({ key, polygon }) => { + if (polygon.length < 3) { + return; + } + + let fullyCovered = true; + for (let i = 0; i < polygon.length; i += 1) { + const point = polygon[i]; + const inside = visiblePolygons.some((poly) => isPointInsideConvex(point, poly)); + if (!inside) { + fullyCovered = false; + break; + } + } + + if (fullyCovered) { + const centroid = polygonCentroid(polygon); + if (!visiblePolygons.some((poly) => isPointInsideConvex(centroid, poly))) { + fullyCovered = false; + } + } + + if (!fullyCovered) { + result.add(key); + visiblePolygons.push(polygon); + } + }); + + const sameSize = result.size === this.visibleDocIds.size; + if (sameSize) { + let identical = true; + result.forEach((id) => { + if (!this.visibleDocIds.has(id)) { + identical = false; + } + }); + if (identical) { + this.visibleDocIds.forEach((id) => { + if (!result.has(id)) { + identical = false; + } + }); + } + if (identical) { + return; + } + } + + this.visibleDocIds = result; + this.emit(); + } + + subscribe(listener) { + this.subscribers.add(listener); + return () => { + this.subscribers.delete(listener); + }; + } + + getSnapshot = () => this.snapshotCache; + + buildSnapshot() { + return { + layout: this.layoutSnapshot, + canvasSize: this.canvasSize, + visibleDocIds: this.visibleDocIds, + draggingId: this.draggingId, + tagDropTargetId: this.tagDropTargetId, + pendingTagDocId: this.pendingTagDocId, + pendingRemovalTag: this.pendingRemovalTag, + }; + } + + emit() { + this.snapshotCache = this.buildSnapshot(); + this.subscribers.forEach((listener) => { + try { + listener(); + } catch (error) { + console.error('WorkspaceEngine listener failed', error); + } + }); + } + + async loadPersistedLayout() { + if (!this.allowLayoutPersistence || !this.tenantId || !this.viewId) { + return; + } + if (this.loadingPersisted || this.initialLoadDone) { + return; + } + this.loadingPersisted = true; + try { + const records = await fetchLayoutRecords({ tenantId: this.tenantId, viewId: this.viewId }); + const map = new Map(); + records.forEach((record) => { + if (!record || !record.documentId) { + return; + } + map.set(String(record.documentId), { + centerX: Number(record.centerX) || 0, + centerY: Number(record.centerY) || 0, + rotation: Number(record.rotation) || 0, + z: Number(record.zIndex) || 0, + }); + }); + this.persistedLayout = map; + this.layoutDirty = false; + if (records.length) { + const maxZ = records.reduce((acc, record) => Math.max(acc, Number(record.zIndex) || 0), DEFAULT_Z_START); + this.zCounter = Math.max(this.zCounter, maxZ); + } + this.layout = new Map(map); + this.layoutSnapshot = new Map(this.layout); + this.ensureLayoutForItems(); + this.initialLoadDone = true; + this.emit(); + } catch (error) { + console.warn('[desk] Failed to load persisted layout', error); + } finally { + this.loadingPersisted = false; + } + } +} + +export const useWorkspaceSnapshot = (engine, useSyncExternalStoreHook) => { + const useSyncExternalStore = useSyncExternalStoreHook; + if (typeof useSyncExternalStore !== 'function') { + throw new Error('useWorkspaceSnapshot requires useSyncExternalStore hook'); + } + return useSyncExternalStore( + (listener) => engine.subscribe(listener), + () => engine.getSnapshot(), + () => engine.getSnapshot(), + ); +}; + +/* istanbul ignore next */ +/* eslint-disable no-undef */ +if (typeof module !== 'undefined' && module && module.exports) { + module.exports = { + WorkspaceEngine, + DESK_CANVAS_PADDING, + DESK_ROTATION_RANGE, + DESK_DEFAULT_CANVAS_WIDTH, + DESK_DEFAULT_CANVAS_HEIGHT, + DESK_CARD_MIN, + DESK_CARD_MAX, + MIN_TIMESTEP, + MAX_TIMESTEP, + MAX_DYNAMIC_ROTATION, + MAX_ANGULAR_VELOCITY, + ANGULAR_DAMPING, + TORQUE_TO_ACCELERATION, + SETTLE_ANGULAR_VELOCITY, + clampCardDimensions, + computeFallbackCardSize, + useWorkspaceSnapshot, + }; +} +/* eslint-enable no-undef */ diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 535cab4..033852b 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -416,6 +416,13 @@ button.danger:hover:not([disabled]) { color: var(--fg); } +.panel-header .icon-button.active:hover:not([disabled]), +.panel-header button.active:hover:not([disabled]), +.panel-header a.icon-button.active:hover { + background: var(--accent-soft); + color: var(--fg); +} + .panel-header .icon-button.ghost, .panel-header button.icon-button.ghost { color: var(--muted); diff --git a/frontend/tests/workspaceEngine.test.js b/frontend/tests/workspaceEngine.test.js new file mode 100644 index 0000000..5f5d317 --- /dev/null +++ b/frontend/tests/workspaceEngine.test.js @@ -0,0 +1,70 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + WorkspaceEngine, + DESK_CANVAS_PADDING, +} = require('../src/desktop/workspaceEngine.js'); + +const makeEngine = () => { + const engine = new WorkspaceEngine(); + engine.setEnsureDocumentSize(() => ({ width: 200, height: 200 })); + engine.setCanvasSize({ width: 1200, height: 800 }); + return engine; +}; + +test('syncLayoutSnapshot clones from layout map', () => { + const engine = makeEngine(); + engine.setItems([{ id: 'doc-1' }]); + engine.ensureLayoutForItems(); + engine.syncLayoutSnapshot(); + + const firstSnapshot = engine.getSnapshot(); + assert(firstSnapshot.layout instanceof Map); + assert(firstSnapshot.layout.get('doc-1')); + + engine.layout.set('doc-1', { + centerX: DESK_CANVAS_PADDING + 150, + centerY: DESK_CANVAS_PADDING + 150, + rotation: 0, + width: 200, + height: 200, + }); + engine.syncLayoutSnapshot(); + + const secondSnapshot = engine.getSnapshot(); + assert.notStrictEqual(secondSnapshot.layout, engine.layout); + assert.equal(secondSnapshot.layout.get('doc-1').centerX, engine.layout.get('doc-1').centerX); +}); + +test('recalcVisibleDocIds respects viewport bounds', () => { + const engine = makeEngine(); + engine.items = [{ id: 'visible' }, { id: 'hidden' }]; + engine.setDocumentLookup(new Map([ + ['visible', { id: 'visible' }], + ['hidden', { id: 'hidden' }], + ])); + + engine.layout = new Map([ + ['visible', { + centerX: DESK_CANVAS_PADDING + 150, + centerY: DESK_CANVAS_PADDING + 150, + rotation: 0, + width: 200, + height: 200, + }], + ['hidden', { + centerX: -500, + centerY: -500, + rotation: 0, + width: 200, + height: 200, + }], + ]); + engine.layoutSnapshot = new Map(engine.layout); + + engine.recalcVisibleDocIds(); + + const snapshot = engine.getSnapshot(); + assert(snapshot.visibleDocIds.has('visible')); + assert(!snapshot.visibleDocIds.has('hidden')); +});