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 { createDocumentsTableHeaderActions } from './documents/DocumentsPanel'; import createWorkspaceSurfaceConfig from './documents/workspaceHeader'; 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 './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 DEBUG_DRAG = false; const DEBUG_FOCUS = true; const DEBUG_DROP = true; const signedDistance = (ax, ay, bx, by, px, py) => (bx - ax) * (py - ay) - (by - ay) * (px - ax); const clipPolygonWithEdge = (subject, edgeStart, edgeEnd) => { if (!subject.length) { return []; } const result = []; let prev = subject[subject.length - 1]; let prevInside = signedDistance(edgeStart.x, edgeStart.y, edgeEnd.x, edgeEnd.y, prev.x, prev.y) >= 0; subject.forEach((curr) => { const currInside = signedDistance(edgeStart.x, edgeStart.y, edgeEnd.x, edgeEnd.y, curr.x, curr.y) >= 0; if (currInside !== prevInside) { const dx = curr.x - prev.x; const dy = curr.y - prev.y; const denom = (edgeEnd.x - edgeStart.x) * dy - (edgeEnd.y - edgeStart.y) * dx; if (Math.abs(denom) > 1e-9) { const t = ((edgeStart.x - prev.x) * dy - (edgeStart.y - prev.y) * dx) / denom; result.push({ x: edgeStart.x + t * (edgeEnd.x - edgeStart.x), y: edgeStart.y + t * (edgeEnd.y - edgeStart.y), }); } } if (currInside) { result.push(curr); } prev = curr; prevInside = currInside; }); return result; }; const clipPolygon = (subject, clipShape) => { if (!subject.length) { return []; } let output = subject; let prev = clipShape[clipShape.length - 1]; for (let index = 0; index < clipShape.length; index += 1) { const curr = clipShape[index]; output = clipPolygonWithEdge(output, prev, curr); if (!output.length) { return []; } prev = curr; } return output; }; const isPointInsideConvex = (point, polygon) => { if (!polygon.length) { return false; } let prev = polygon[polygon.length - 1]; for (let index = 0; index < polygon.length; index += 1) { const curr = polygon[index]; if (signedDistance(prev.x, prev.y, curr.x, curr.y, point.x, point.y) < -1e-6) { return false; } prev = curr; } return true; }; const polygonCentroid = (polygon) => { let x = 0; let y = 0; polygon.forEach((point) => { x += point.x; y += point.y; }); const count = polygon.length || 1; return { x: x / count, y: y / count, }; }; const DesktopPreviewCard = ({ doc, title, ensureAssetUrl, getDocumentAsset, prefetch = 3, onNavigatorSnapshot, shouldLoad = true, }) => { const navigator = useAssetNavigator({ document: doc, assetType: 'preview', ensureAssetUrl: shouldLoad ? ensureAssetUrl : null, getAsset: getDocumentAsset, prefetch, }); const { currentUrl, cardinality, canGoPrev, canGoNext, currentMetadata, ordinal } = navigator; const docId = doc?.id ?? null; const metadataWidth = Number(currentMetadata?.width); const metadataHeight = Number(currentMetadata?.height); useEffect(() => { if (!onNavigatorSnapshot || !docId) { return undefined; } const snapshot = { url: currentUrl || null, alt: title, canGoPrev, canGoNext, goPrev: navigator.goPrev, goNext: navigator.goNext, ordinal, width: Number.isFinite(metadataWidth) && metadataWidth > 0 ? metadataWidth : null, height: Number.isFinite(metadataHeight) && metadataHeight > 0 ? metadataHeight : null, }; onNavigatorSnapshot(docId, snapshot); return () => onNavigatorSnapshot(docId, null); }, [ docId, currentUrl, title, canGoPrev, canGoNext, ordinal, metadataWidth, metadataHeight, navigator.goPrev, navigator.goNext, onNavigatorSnapshot, ]); const hasPreview = Boolean(currentUrl); const cardClasses = ['desk-item__card']; if (!hasPreview) cardClasses.push('desk-item__card--empty'); const showNav = hasPreview && (cardinality > 1 || canGoPrev || canGoNext); return (
{ if (event instanceof DragEvent) { event.preventDefault(); } }} > {hasPreview ? ( {title} event.preventDefault()} /> ) : (
DOC
{title}
)} {showNav ? (
) : null}
); }; const generateInitialLayout = ( entries, { canvasWidth, canvasHeight, padding, startZ = 0, rotationRange = ROTATION_RANGE, minSpacing = 48, shelfWidth = 0, }, ) => { const layout = new Map(); let currentZ = startZ; let maxZ = startZ; if (!entries.length) { return { layout, maxZ }; } const shelfOffset = Math.max(shelfWidth, 0); const spacingBuffer = Math.max(minSpacing, 0); const placed = []; const resolveBounds = (width, height) => { const halfWidth = width / 2; const halfHeight = height / 2; return { minCenterX: padding + halfWidth, maxCenterX: Math.max( padding + halfWidth, canvasWidth - shelfOffset - padding - halfWidth, ), minCenterY: padding + halfHeight, maxCenterY: Math.max(padding + halfHeight, canvasHeight - padding - halfHeight), }; }; const evaluateCandidateSpacing = (x, y, radius) => { if (!placed.length) { return Number.POSITIVE_INFINITY; } let best = Number.POSITIVE_INFINITY; for (let i = 0; i < placed.length; i += 1) { const item = placed[i]; const dx = item.x - x; const dy = item.y - y; const distance = Math.sqrt(dx * dx + dy * dy) - item.radius - radius - spacingBuffer; if (distance < best) { best = distance; } } return best; }; entries.forEach((entry) => { const width = Number(entry.width) || 0; const height = Number(entry.height) || 0; if (!entry.id || width <= 0 || height <= 0) { return; } const { minCenterX, maxCenterX, minCenterY, maxCenterY } = resolveBounds(width, height); const radius = Math.sqrt(width * width + height * height) / 2; let bestScore = -Infinity; let bestX = (minCenterX + maxCenterX) / 2; let bestY = (minCenterY + maxCenterY) / 2; const samplesPerAxis = 14; for (let gx = 0; gx < samplesPerAxis; gx += 1) { const fracX = (gx + 0.5) / samplesPerAxis; for (let gy = 0; gy < samplesPerAxis; gy += 1) { const fracY = (gy + 0.5) / samplesPerAxis; const candidateX = minCenterX + fracX * (maxCenterX - minCenterX); const candidateY = minCenterY + fracY * (maxCenterY - minCenterY); const edgeSpacing = Math.min( candidateX - minCenterX, maxCenterX - candidateX, candidateY - minCenterY, maxCenterY - candidateY, ) - spacingBuffer * 0.5; if (edgeSpacing <= 0) { continue; } const neighborSpacing = evaluateCandidateSpacing(candidateX, candidateY, radius); const score = Math.min(edgeSpacing, neighborSpacing); if (score > bestScore) { bestScore = score; bestX = candidateX; bestY = candidateY; } } } const centerX = clamp(bestX, minCenterX, maxCenterX); const centerY = clamp(bestY, minCenterY, maxCenterY); const rotation = randomRangeFromSeed( buildKey(entry.id, 'rotation'), -rotationRange, rotationRange, ); currentZ += 1; layout.set(entry.id, { centerX, centerY, rotation, z: currentZ, width, height, }); maxZ = Math.max(maxZ, currentZ); placed.push({ x: centerX, y: centerY, radius }); }); return { layout, maxZ }; }; const createDragPreview = (node, clientX, clientY) => { if (!(node instanceof HTMLElement)) { return null; } const rect = node.getBoundingClientRect(); const safeClientX = Number.isFinite(clientX) ? clientX : rect.left + rect.width / 2; const safeClientY = Number.isFinite(clientY) ? clientY : rect.top + rect.height / 2; const offsetX = clamp(safeClientX - rect.left, 0, rect.width); const offsetY = clamp(safeClientY - rect.top, 0, rect.height); const clone = node.cloneNode(true); clone.style.position = 'absolute'; clone.style.top = '-9999px'; clone.style.left = '-9999px'; clone.style.pointerEvents = 'none'; clone.style.opacity = '1'; clone.style.transform = 'none'; document.body.appendChild(clone); return { clone, offsetX, offsetY }; }; const cleanupPreview = (previewNode) => { if (previewNode && previewNode.parentNode) { previewNode.parentNode.removeChild(previewNode); } }; const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => { const [metadataMap, setMetadataMap] = useState(() => new Map()); useEffect(() => { let cancelled = false; const docs = Array.isArray(documents) ? documents : []; if (!docs.length) { setMetadataMap(new Map()); return () => { cancelled = true; }; } const fetchMetadataForDoc = async (doc) => { if (!doc?.id) { return null; } const docId = String(doc.id); const resolveAsset = (type) => (typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, type) : null); let asset = resolveAsset('preview') || resolveAsset('thumbnail'); let view = createAssetView(asset); let metadata = view.getPrimaryMetadata(); const hasDimensions = (meta) => Number.isFinite(Number(meta?.width)) && Number.isFinite(Number(meta?.height)) && Number(meta.width) > 0 && Number(meta.height) > 0; if (!hasDimensions(metadata) && ensureAssetUrl && docId && asset?.id) { try { const ensured = await ensureAssetUrl(doc.id, asset, { start: 1, limit: 1 }); if (ensured) { asset = ensured; view = createAssetView(asset); metadata = view.getPrimaryMetadata(); } } catch (error) { console.warn('[desk] ensureDocumentSize metadata fetch failed', error); } } if (!hasDimensions(metadata)) { return null; } const width = Number(metadata.width); const height = Number(metadata.height); if (!Number.isFinite(width) || !Number.isFinite(height)) { return null; } return [docId, { width, height }]; }; Promise.all(docs.map((doc) => fetchMetadataForDoc(doc))) .then((entries) => { if (cancelled) return; const next = new Map(); entries.forEach((entry) => { if (entry) { next.set(entry[0], entry[1]); } }); setMetadataMap(next); }) .catch(() => { if (!cancelled) { setMetadataMap(new Map()); } }); return () => { cancelled = true; }; }, [documents, getDocumentAsset, ensureAssetUrl]); return metadataMap; }; function seededRandom(input) { const text = String(input); let hash = 2166136261; for (let index = 0; index < text.length; index += 1) { hash ^= text.charCodeAt(index); hash = Math.imul(hash, 16777619); } return (hash >>> 0) / 4294967295; } function randomRangeFromSeed(seedKey, min, max) { const span = max - min; if (span <= 0) return min; const seed = seededRandom(seedKey); return min + seed * span; } function buildKey(docId, suffix) { return `${docId}::${suffix}`; } const clampCardDimensions = (width, height) => { const w = Number(width); const h = Number(height); if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) { return null; } const low = Math.max(CARD_MIN / w, CARD_MIN / h); const high = Math.min(CARD_MAX / w, CARD_MAX / h); const candidates = []; const addCandidate = (scale) => { if (Number.isFinite(scale) && scale > 0) { candidates.push(scale); } }; addCandidate(1); addCandidate(low); addCandidate(high); const best = candidates.reduce((acc, scale) => { const scaledWidth = w * scale; const scaledHeight = h * scale; const violation = Math.max( Math.max(CARD_MIN - scaledWidth, 0), Math.max(scaledWidth - CARD_MAX, 0), Math.max(CARD_MIN - scaledHeight, 0), Math.max(scaledHeight - CARD_MAX, 0), ); const deviation = Math.abs(scale - 1); if (!acc || violation < acc.violation || (violation === acc.violation && deviation < acc.deviation)) { return { scale, violation, deviation }; } return acc; }, null); const scale = best ? best.scale : 1; return { width: Math.round(w * scale), height: Math.round(h * scale), }; }; const computeFallbackCardSize = (docId) => { const baseSeed = seededRandom(`${docId}:fallback-size`); const aspectSeed = seededRandom(`${docId}:fallback-aspect`); const width = CARD_MIN + baseSeed * (CARD_MAX - CARD_MIN); const isPortrait = aspectSeed < 0.5; const normalizedSeed = isPortrait ? aspectSeed / 0.5 : (aspectSeed - 0.5) / 0.5; const aspectRange = 0.75; // keeps generated ratio pleasant but varied const aspect = isPortrait ? 1 + normalizedSeed * aspectRange : 1 / (1 + normalizedSeed * aspectRange); const height = width * aspect; return clampCardDimensions(width, height); }; const DesktopWorkspace = ({ documents = [], searchResults = null, onDocumentOpen, onInspectDocument = null, onDocumentPointerSelect = null, onDocumentStackSelect = 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 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 storageKey = useMemo(() => { if (!tenantId || !viewId) { return null; } const normalizedViewId = encodeURIComponent(String(viewId)); return `papercrate.desk-layout.${tenantId}.${normalizedViewId}`; }, [tenantId, viewId]); const initialPersistedLayout = useMemo(() => { if (!storageKey || typeof window === 'undefined') { return new Map(); } try { const raw = window.localStorage.getItem(storageKey); if (!raw) { return new Map(); } const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== 'object') { return new Map(); } const map = new Map(); Object.entries(parsed).forEach(([docId, value]) => { if (!value || typeof value !== 'object') { return; } const centerX = Number(value.centerX); const centerY = Number(value.centerY); if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) { return; } const rotation = Number.isFinite(Number(value.rotation)) ? Number(value.rotation) : 0; const z = Number.isFinite(Number(value.z)) ? Number(value.z) : undefined; map.set(String(docId), { centerX, centerY, rotation, z, }); }); return map; } catch (error) { console.warn('[desk] Failed to parse persisted layout', error); return new Map(); } }, [storageKey]); const persistedLayoutRef = useRef(initialPersistedLayout); useEffect(() => { persistedLayoutRef.current = initialPersistedLayout; }, [initialPersistedLayout]); 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( (snapshot, force = false) => { if (!storageKey || typeof window === 'undefined') { 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 }); }); const payload = {}; merged.forEach((entry, docId) => { if (!docId || !entry) { return; } payload[docId] = entry; }); try { window.localStorage.setItem(storageKey, JSON.stringify(payload)); persistedLayoutRef.current = merged; } catch (error) { console.warn('[desk] Failed to persist desk layout', error); } }, [storageKey], ); 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(); }, []); 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, onDocumentPointerSelect, onDocumentStackSelect, 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, onDocumentPointerSelect, openOverlayForDoc, overlayDisplay, overlayOriginRect, overlayOriginTransform, pendingRemovalTag, pendingTagDocId, recalcVisibleDocIds, resolveBaseMetrics, setDraggingId, syncLayoutSnapshot, tagDropTargetId, visibleDocIds, documentLookup, selectedDocumentIds, onClearSelection, onDocumentStackSelect, 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, onDocumentPointerSelect, onDocumentStackSelect, selectedDocumentIds, onClearSelection, detailPanelOpen, onCloseDetailPanel, } = useDesktopContext(); const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = useDocumentDrag(); 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)); useEffect(() => { if (typeof window === 'undefined' || typeof onClearSelection !== 'function') { return undefined; } const handleKeyDown = (event) => { if (!event || event.defaultPrevented) { return; } const key = event.key; const spacePressed = key === ' ' || key === 'Space' || key === 'Spacebar'; if (!spacePressed) { 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; } } const hasSelection = Array.isArray(selectedDocumentIds) && selectedDocumentIds.length > 0; if (hasSelection) { event.preventDefault(); onClearSelection(); return; } if (detailPanelOpen && typeof onCloseDetailPanel === 'function') { event.preventDefault(); onCloseDetailPanel(); } }; window.addEventListener('keydown', handleKeyDown, true); return () => window.removeEventListener('keydown', handleKeyDown, true); }, [ onClearSelection, selectedDocumentIds, detailPanelOpen, onCloseDetailPanel, ]); return ( <>
{ if (event.target === event.currentTarget && typeof onClearSelection === 'function') { onClearSelection(); } }} >
{ if (event.target === event.currentTarget && typeof onClearSelection === 'function') { onClearSelection(); } }} > {!allSizesReady ? (

Loading previews…

) : items.length === 0 ? (

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

) : ( items.map((doc) => { const sizeInfo = ensureDocumentSize(doc); if (!sizeInfo) { return null; } const { width: cardWidth, height: cardHeight } = sizeInfo; const layout = layoutSnapshot.get(doc.id) ?? layoutRef.current.get(doc.id); if ( !layout || !Number.isFinite(layout.centerX) || !Number.isFinite(layout.centerY) ) { return null; } const layoutCenterX = layout.centerX; const layoutCenterY = layout.centerY; const rotation = layout.rotation || 0; const originX = layoutCenterX - cardWidth / 2; const originY = layoutCenterY - cardHeight / 2; const transform = formatTransform( Math.round(originX), Math.round(originY), rotation, 1, ); const style = { transform, zIndex: layout.z ?? 1, width: Math.round(cardWidth), height: Math.round(cardHeight), }; const docKey = doc?.id != null ? String(doc.id) : null; const shouldLoad = docKey ? visibleDocIds.has(docKey) : false; const dragging = draggingId === doc.id; const tags = Array.isArray(doc.tags) ? doc.tags : []; const 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) => { const alreadySelected = selectedDocumentIds.includes(doc.id); const metaOrCtrlOnly = (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey; let stackDocIds = null; let appliedStackSelection = false; if (metaOrCtrlOnly) { const hits = resolveStackDocIds(event, doc.id); if (Array.isArray(hits) && hits.length > 0) { stackDocIds = hits; const hasStack = hits.length > 1; if (hasStack && alreadySelected && typeof onDocumentStackSelect === 'function') { onDocumentStackSelect(hits, event); appliedStackSelection = true; } } } const shouldInvokePointerSelect = typeof onDocumentPointerSelect === 'function' && ( !metaOrCtrlOnly || !alreadySelected || event.shiftKey || event.altKey || !stackDocIds || stackDocIds.length <= 1 ); if (shouldInvokePointerSelect) { onDocumentPointerSelect(doc.id, event); } handlePointerDown(event, doc.id, { stackDocIds, stackSelectionApplied: appliedStackSelection, }); }} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={handlePointerCancel} 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); } }} >
{tags.length > 0 && ( )}
); }) )}
); }; export default DesktopWorkspace; const DesktopHelpOverlay = ({ open = false, onClose = null }) => { const portalTarget = typeof document !== 'undefined' ? document.body : null; const closeButtonRef = useRef(null); const previousFocusRef = useRef(null); const handleClose = useCallback(() => { if (typeof onClose === 'function') { onClose(); } }, [onClose]); useEffect(() => { if (!open || typeof window === 'undefined') { return undefined; } const handleKeyDown = (event) => { if (!event) { return; } if (event.key === 'Escape') { event.preventDefault(); handleClose(); } }; window.addEventListener('keydown', handleKeyDown, true); return () => window.removeEventListener('keydown', handleKeyDown, true); }, [open, handleClose]); useEffect(() => { if (!open) { const previous = previousFocusRef.current; if (previous && typeof previous.focus === 'function') { previous.focus(); } previousFocusRef.current = null; return; } if (typeof document !== 'undefined') { previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; } if (closeButtonRef.current && typeof closeButtonRef.current.focus === 'function') { closeButtonRef.current.focus(); } }, [open]); if (!open || !portalTarget) { return null; } return createPortal(

Desk view tips

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

  • Single-click a document to open it in the detail panel.
  • Double-click to open the zoomed preview.
  • Drag selected cards to reposition them; build a selection with {' '} Cmd/Ctrl {' '}+ click or Shift-click.
  • Cmd/Ctrl + click with an empty selection scoops up the stack under {' '}the pointer.
  • Space clears the current selection.
  • Drag tags from the sidebar onto a card to assign them, or fling a {' '}tag away to remove it.
, portalTarget, ); }; export const createDesktopSurface = ({ workspaceProps, renderSidebarToggle, parentBreadcrumb, onNavigateParent, detailProps = null, detailOpen = false, }) => { if (!workspaceProps) { return null; } const { currentFolderName, searchResults, onRefresh, viewMode, onViewModeChange, } = workspaceProps; const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; const subtitle = Array.isArray(searchResults) ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}` : null; const actions = createDocumentsTableHeaderActions({ viewMode: viewMode || 'desk', onViewModeChange, onRefresh, onShowDeskHelp: viewMode === 'desk' ? workspaceProps?.onOpenHelp : 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, content: , detail, }); return { ...surfaceConfig, supportsDetail: Boolean(detailProps), }; };