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 ? (
-

event.preventDefault()}
- />
- ) : (
-
- )}
- {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 ? (
-
- ) : 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 && (
-
- {correspondents.map((correspondent) => (
-
-
- {correspondent.name}
-
-
- ))}
-
- )}
- {tags.length > 0 && (
-
- {tags.map((tag) => {
- if (
- pendingRemovalTag &&
- pendingRemovalTag.docId === doc.id &&
- pendingRemovalTag.tagId === tag.id
- ) {
- return null;
- }
- const colorStyle = getTagColorStyle(tag.color);
- const pendingRemoval =
- pendingRemovalTag &&
- pendingRemovalTag.docId === doc.id &&
- pendingRemovalTag.tagId === tag.id;
- const tagClasses = ['badge', 'tag-chip', 'tag-chip--draggable'];
- if (pendingRemoval) tagClasses.push('tag-chip--tear-pending');
- return (
- handleDocTagPointerDown(event, doc, tag)}
- onDragStart={(event) => handleDocTagDragStart(event, doc, tag)}
- onDrag={handleDocTagDrag}
- onDragEnd={(event) => handleDocTagDragEnd(event)}
- >
- {tag.label}
-
- );
- })}
-
- )}
-
-
- );
- })
- )}
-
-
-
- >
- );
-};
-
-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 && (
+
+ {correspondents.map((correspondent) => (
+
+ {correspondent.name}
+
+ ))}
+
+ )}
+ {tags.length > 0 && (
+
+ {tags.map((tag) => {
+ if (pendingRemovalTag && pendingRemovalTag.docId === doc.id && pendingRemovalTag.tagId === tag.id) {
+ return null;
+ }
+ const colorStyle = getTagColorStyle(tag.color);
+ const pendingRemoval =
+ pendingRemovalTag && pendingRemovalTag.docId === doc.id && pendingRemovalTag.tagId === tag.id;
+ const tagClasses = ['badge', 'tag-chip', 'tag-chip--draggable'];
+ if (pendingRemoval) tagClasses.push('tag-chip--tear-pending');
+ return (
+ onDocTagPointerDown(event, doc, tag)}
+ onDragStart={(event) => onDocTagDragStart(event, doc, tag)}
+ onDrag={onDocTagDrag}
+ onDragEnd={(event) => onDocTagDragEnd(event)}
+ >
+ {tag.label}
+
+ );
+ })}
+
+ )}
+
+
+ );
+};
+
+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 ? (
+

event.preventDefault()}
+ />
+ ) : (
+
+ )}
+ {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 ? (
+
+ ) : 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'));
+});