Files
papercrate/frontend/src/DesktopWorkspace.jsx
T
2025-11-02 22:08:15 +01:00

2520 lines
74 KiB
React

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 (
<div
className={cardClasses.join(' ')}
onDragStart={(event) => {
if (event instanceof DragEvent) {
event.preventDefault();
}
}}
>
{hasPreview ? (
<img
src={currentUrl}
alt={title}
draggable={false}
onDragStart={(event) => event.preventDefault()}
/>
) : (
<div className="desk-item__empty">
<div className="desk-item__placeholder">DOC</div>
<div className="desk-item__title" title={title}>
{title}
</div>
</div>
)}
{showNav ? (
<div className="desk-card__nav">
<button
type="button"
className="desk-card__nav-button"
onClick={(event) => {
preventAll(event);
navigator.goPrev();
}}
onPointerDown={(event) => {
preventAll(event);
}}
onPointerUp={(event) => {
preventAll(event);
}}
onMouseDown={(event) => {
preventAll(event);
}}
onMouseUp={(event) => {
preventAll(event);
}}
disabled={!canGoPrev}
aria-label="Previous preview"
>
<ArrowLeftIcon />
</button>
<button
type="button"
className="desk-card__nav-button"
onClick={(event) => {
preventAll(event);
navigator.goNext();
}}
onPointerDown={(event) => {
preventAll(event);
}}
onPointerUp={(event) => {
preventAll(event);
}}
onMouseDown={(event) => {
preventAll(event);
}}
onMouseUp={(event) => {
preventAll(event);
}}
disabled={!canGoNext}
aria-label="Next preview"
>
<ArrowRightIcon />
</button>
</div>
) : null}
</div>
);
};
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 (
<DesktopProvider value={contextValue}>
<DesktopWorkspaceView />
<DesktopHelpOverlay open={helpOpen} onClose={onHelpClose} />
</DesktopProvider>
);
};
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 (
<>
<div
className="desk-shell"
onPointerDown={(event) => {
if (event.target === event.currentTarget && typeof onClearSelection === 'function') {
onClearSelection();
}
}}
>
<div
className="desk-canvas"
ref={containerRef}
onDragOver={handleCanvasDragOver}
onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop}
onPointerDown={(event) => {
if (event.target === event.currentTarget && typeof onClearSelection === 'function') {
onClearSelection();
}
}}
>
{!allSizesReady ? (
<div className="desk-empty">
<p>Loading previews</p>
</div>
) : items.length === 0 ? (
<div className="desk-empty">
<p>No documents to show here yet. Drop files to make this space come alive.</p>
</div>
) : (
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 (
<div
key={doc.id}
className={itemClasses.join(' ')}
style={style}
role="button"
data-doc-id={doc.id}
data-tag-ids={docTagTokens || undefined}
aria-hidden={matchesFilter ? undefined : 'true'}
ref={(node) => {
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);
}
}}
>
<div className="desk-item__body">
<DesktopPreviewCard
doc={doc}
title={doc.title}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
onNavigatorSnapshot={handleNavigatorSnapshot}
shouldLoad={shouldLoad}
/>
{tags.length > 0 && (
<div className="desk-item__tags" aria-hidden="true">
{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 (
<span
key={tag.id}
className={tagClasses.join(' ')}
style={colorStyle || undefined}
title={tag.label}
draggable
onPointerDown={(event) => handleDocTagPointerDown(event, doc, tag)}
onDragStart={(event) => handleDocTagDragStart(event, doc, tag)}
onDrag={handleDocTagDrag}
onDragEnd={(event) => handleDocTagDragEnd(event)}
>
<span className="tag-chip__label">{tag.label}</span>
</span>
);
})}
</div>
)}
</div>
</div>
);
})
)}
</div>
</div>
<PreviewZoomOverlay
open={Boolean(overlayDisplay?.url)}
display={overlayDisplay}
onClose={closeOverlay}
originRect={overlayOriginRect}
originTransform={overlayOriginTransform}
/>
</>
);
};
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(
<div className="desk-help-overlay" role="dialog" aria-modal="true" aria-labelledby="desk-help-title">
<div className="desk-help-overlay__backdrop" onClick={handleClose} />
<div className="desk-help-overlay__content">
<div className="desk-help-overlay__header">
<h2 id="desk-help-title">Desk view tips</h2>
<button
type="button"
className="icon-button"
onClick={handleClose}
aria-label="Close desk view tips"
ref={closeButtonRef}
>
<CloseIcon />
</button>
</div>
<div className="desk-help-overlay__body">
<p>Use the desk as a freeform workspace for triage and quick comparisons.</p>
<ul className="desk-help-overlay__list">
<li><strong>Single-click</strong> a document to open it in the detail panel.</li>
<li><strong>Double-click</strong> to open the zoomed preview.</li>
<li>
<strong>Drag</strong> selected cards to reposition them; build a selection with
{' '}
<kbd>Cmd</kbd>/<kbd>Ctrl</kbd>
{' '}+ click or Shift-click.
</li>
<li>
<strong>Cmd/Ctrl + click</strong> with an empty selection scoops up the stack under
{' '}the pointer.
</li>
<li><strong>Space</strong> clears the current selection.</li>
<li>
<strong>Drag tags</strong> from the sidebar onto a card to assign them, or fling a
{' '}tag away to remove it.
</li>
</ul>
</div>
<div className="desk-help-overlay__footer">
<button type="button" className="button primary" onClick={handleClose}>
Got it
</button>
</div>
</div>
</div>,
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 ? <DetailPanel {...detailProps} /> : null;
const surfaceConfig = createWorkspaceSurfaceConfig({
key: 'workspace',
variant: 'workspace',
title,
subtitle,
sidebarToggle,
parentBreadcrumb,
onNavigateParent,
actions,
breadcrumbs: workspaceProps?.breadcrumbs || null,
content: <DesktopWorkspace {...workspaceProps} />,
detail,
});
return {
...surfaceConfig,
supportsDetail: Boolean(detailProps),
};
};