2520 lines
74 KiB
React
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),
|
|
};
|
|
};
|