Files
papercrate/frontend/src/DesktopWorkspace.jsx
T
2025-10-29 22:46:44 +01:00

2061 lines
61 KiB
React

import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { resolveDocumentAssetUrl, createAssetView } from './asset_manager';
import { useAssetNavigator } from './hooks/useAssetNavigator';
import { ArrowLeftIcon, ArrowRightIcon, RefreshIcon } from './ui/icons';
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 './DesktopWorkspace.css';
const CANVAS_PADDING = 24;
const ROTATION_RANGE = 7;
const DEFAULT_CANVAS_WIDTH = 1024;
const DEFAULT_CANVAS_HEIGHT = 680;
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
const resolveSizeKey = (doc) =>
doc?.id || doc?.document_id || doc?.uuid || doc?.original_name || doc?.title || 'doc';
const CARD_MIN = 240;
const CARD_MAX = 340;
const TAG_REMOVE_DISTANCE = 160;
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 readTransferData = (dataTransfer, mimeTypes) => {
if (!dataTransfer) {
return null;
}
for (let index = 0; index < mimeTypes.length; index += 1) {
const type = mimeTypes[index];
try {
const raw = dataTransfer.getData(type);
if (raw) {
return raw;
}
} catch (error) {
if (DEBUG_DROP) {
console.warn('[skeuo] readTransferData failed for type', type, error);
}
}
}
return null;
};
const parseTagTransferPayload = (event) => {
const raw = readTransferData(event?.dataTransfer, [
'application/x-papercrate-tag',
'text/papercrate-tag',
]);
if (!raw) {
return null;
}
try {
return JSON.parse(raw);
} catch (error) {
console.warn('[skeuo] parseTagTransferPayload failed', error);
}
return null;
};
const resolveTagKey = (tag) => {
if (!tag) {
return null;
}
const key = tag.id ?? tag.uuid ?? tag.slug ?? tag.label;
return key != null ? String(key) : null;
};
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 = ['skeuo-item__card'];
if (!hasPreview) cardClasses.push('skeuo-item__card--empty');
const showNav = hasPreview && (cardinality > 1 || canGoPrev || canGoNext);
return (
<div className={cardClasses.join(' ')}>
{hasPreview ? (
<img src={currentUrl} alt={title} />
) : (
<div className="skeuo-item__empty">
<div className="skeuo-item__placeholder">DOC</div>
<div className="skeuo-item__title" title={title}>
{title}
</div>
</div>
)}
{showNav ? (
<div className="skeuo-card__nav">
<button
type="button"
className="skeuo-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="skeuo-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('[skeuo] 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,
onAssignTagToDocument = null,
onRemoveTagFromDocument = null,
ensureAssetUrl = null,
getDocumentAsset = () => null,
activeTagIds = [],
}) => {
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 [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 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('[skeuo] focusCanvas -> attempting focus', canvas);
}
canvas.focus({ preventScroll: true });
if (DEBUG_FOCUS) {
console.log('[skeuo] focusCanvas: applied focus. activeElement:', document?.activeElement);
}
} catch (error) {
if (DEBUG_FOCUS) {
console.warn('[skeuo] focusTarget failed to focus canvas', error);
}
}
};
if (typeof window === 'undefined') {
focusTarget();
return;
}
if (DEBUG_FOCUS) {
console.log('[skeuo] requestCanvasFocus -> scheduling deferred focus');
}
if (typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(() => {
if (DEBUG_FOCUS) {
console.log('[skeuo] requestCanvasFocus -> executing deferred focus (rAF)');
}
focusTarget();
});
} else {
setTimeout(() => {
if (DEBUG_FOCUS) {
console.log('[skeuo] 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('skeuo-cursor-remove');
} else {
body.classList.remove('skeuo-cursor-remove');
}
}, []);
useEffect(
() => () => {
updateRemovalCursor(false);
},
[updateRemovalCursor],
);
const isTagTransfer = useCallback((event) => {
const types = event.dataTransfer?.types;
if (!types) return false;
return TAG_MIME_TYPES.some((type) =>
typeof types.includes === 'function'
? types.includes(type)
: Array.from(types).includes(type),
);
}, []);
const handleTagDragEnd = useCallback(() => {
updateRemovalCursor(false);
setTagDropTargetId(null);
}, [updateRemovalCursor]);
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 || doc?.original_name || 'Document preview';
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 syncLayoutSnapshot = useCallback(() => {
setLayoutSnapshot(new Map(layoutRef.current));
}, []);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return () => {};
const nodeEnv = typeof globalThis !== 'undefined' ? globalThis.process?.env?.NODE_ENV : undefined;
if (nodeEnv !== 'production') {
console.log('[skeuo] 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 existing = previous.get(doc.id);
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: resolveSizeKey(doc),
});
});
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);
syncLayoutSnapshot();
recalcVisibleDocIds();
},
[syncLayoutSnapshot, recalcVisibleDocIds],
);
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?.('.skeuo-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('[skeuo] handleTagDropOnDoc: drop ignored', { doc, hasTransfer: isTagTransfer(event) });
}
return;
}
preventAll(event);
if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: drop accepted for doc', doc.id, 'event', event);
}
setTagDropTargetId(null);
const payload = parseTagTransferPayload(event);
if (!payload && DEBUG_DROP) {
console.warn('[skeuo] handleTagDropOnDoc: failed to parse payload');
}
if (!payload?.id) {
if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: missing tag id payload', payload);
}
requestCanvasFocus();
return;
}
const tagId = payload.id;
const sourceDocId = payload.sourceDocId || null;
if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: parsed payload', { tagId, sourceDocId });
}
if (sourceDocId && sourceDocId === doc.id) {
markActiveTagDropHandled(tagId, sourceDocId);
if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: drop from same doc ignored', tagId);
}
requestCanvasFocus();
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('[skeuo] handleTagDropOnDoc: tag already assigned', tagId);
}
requestCanvasFocus();
return;
}
const movingBetweenDocuments = Boolean(sourceDocId && sourceDocId !== doc.id);
if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: movingBetweenDocuments', movingBetweenDocuments);
}
setPendingTagDocId(doc.id);
try {
await onAssignTagToDocument({ documentId: doc.id, tagId, tag: payload });
markActiveTagDropHandled(tagId, sourceDocId);
if (DEBUG_DROP) {
console.log('[skeuo] 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('[skeuo] handleTagDropOnDoc: removed tag from source doc', sourceDocId);
}
} finally {
setPendingRemovalTag(null);
}
}
} finally {
setPendingTagDocId(null);
if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: finalizing drop for tag', tagId);
}
requestCanvasFocus();
}
},
[
isTagTransfer,
markActiveTagDropHandled,
onAssignTagToDocument,
onRemoveTagFromDocument,
requestCanvasFocus,
],
);
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('[skeuo] Failed to set drag effect', error);
}
const payload = JSON.stringify({ id: tag.id, label: tag.label, sourceDocId: doc.id });
try {
event.dataTransfer?.setData('application/x-papercrate-tag', payload);
event.dataTransfer?.setData('text/papercrate-tag', payload);
event.dataTransfer?.setData('text/plain', tag.label || 'Tag');
} catch (error) {
console.warn('[skeuo] Failed to populate drag data for tag', error);
}
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('[skeuo] Failed to set drag image', error);
}
}
}
draggingTagRef.current = {
sourceDocId: doc.id,
tagId: tag.id,
tagLabel: tag.label || 'Tag',
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();
const state = draggingTagRef.current;
if (!state) {
return;
}
draggingTagRef.current = null;
const node = state.element;
const showNode = () => {
if (node instanceof HTMLElement) {
node.classList.remove('is-drag-hidden');
}
};
cleanupPreview(state.previewClone);
const scheduleShowNode = () => {
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(showNode);
} else {
setTimeout(showNode, 0);
}
};
const dropEffect = event?.dataTransfer?.dropEffect || 'none';
console.log('[skeuo] dragEnd dropEffect', dropEffect, 'dropHandled', state.dropHandled);
const shouldRemove =
!state.dropHandled &&
dropEffect === 'none' &&
state.sourceDocId &&
typeof onRemoveTagFromDocument === 'function' &&
(state.distance || 0) >= TAG_REMOVE_DISTANCE;
if (!shouldRemove) {
console.log('[skeuo] dragEnd -> no removal. distance:', state.distance);
scheduleShowNode();
requestCanvasFocus();
updateRemovalCursor(false);
return;
}
requestCanvasFocus();
updateRemovalCursor(false);
setPendingRemovalTag({ docId: state.sourceDocId, tagId: state.tagId });
void (async () => {
try {
await onRemoveTagFromDocument(state.sourceDocId, state.tagId);
console.log('[skeuo] dragEnd -> removed tag due to fling');
} catch (error) {
console.error('Failed to remove tag after drag', error);
scheduleShowNode();
} finally {
setPendingRemovalTag(null);
}
})();
},
[requestCanvasFocus, handleTagDragEnd, onRemoveTagFromDocument, updateRemovalCursor],
);
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,
ensureAssetUrl,
getDocumentAsset,
handleNavigatorSnapshot,
activeTagSet,
handleTagDragEnterDoc,
handleTagDragOverDoc,
handleTagDragLeaveDoc,
handleTagDropOnDoc,
handleDocTagPointerDown,
handleDocTagDragStart,
handleDocTagDrag,
handleDocTagDragEnd,
overlayDisplay,
closeOverlay,
overlayOriginRect,
overlayOriginTransform,
}),
[
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,
openOverlayForDoc,
overlayDisplay,
overlayOriginRect,
overlayOriginTransform,
pendingRemovalTag,
pendingTagDocId,
recalcVisibleDocIds,
resolveBaseMetrics,
setDraggingId,
syncLayoutSnapshot,
tagDropTargetId,
visibleDocIds,
documentLookup,
],
);
return (
<DesktopProvider value={contextValue}>
<DesktopWorkspaceView />
</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,
} = useDesktopContext();
const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } =
useDocumentDrag();
const allSizesReady = items.every((doc) => ensureDocumentSize(doc));
return (
<>
<div className="skeuo-shell">
<div
className="skeuo-canvas"
ref={containerRef}
onDragOver={handleCanvasDragOver}
onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop}
>
{!allSizesReady ? (
<div className="skeuo-empty">
<p>Loading previews</p>
</div>
) : items.length === 0 ? (
<div className="skeuo-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 title = doc.title || doc.original_name || 'Document';
const dragging = draggingId === doc.id;
const tags = Array.isArray(doc.tags) ? doc.tags : [];
const docTagKeys = tags
.map((tag) => resolveTagKey(tag))
.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 = ['skeuo-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 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) => handlePointerDown(event, doc.id)}
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="skeuo-item__body">
<DesktopPreviewCard
doc={doc}
title={title}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
onNavigatorSnapshot={handleNavigatorSnapshot}
shouldLoad={shouldLoad}
/>
{tags.length > 0 && (
<div className="skeuo-item__tags" aria-hidden="true">
{tags.map((tag) => {
const key = tag.id || tag.label || String(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={key}
className={tagClasses.join(' ')}
style={colorStyle || undefined}
title={tag.label || 'Tag'}
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 || 'Tag'}</span>
</span>
);
})}
</div>
)}
</div>
</div>
);
})
)}
</div>
</div>
<PreviewZoomOverlay
open={Boolean(overlayDisplay?.url)}
display={overlayDisplay}
onClose={closeOverlay}
originRect={overlayOriginRect}
originTransform={overlayOriginTransform}
/>
</>
);
};
export default DesktopWorkspace;
export const createDesktopSurface = ({ workspaceProps, renderSidebarToggle }) => {
if (!workspaceProps) {
return null;
}
const { currentFolderName, searchResults, onRefresh, onExit } = workspaceProps;
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
const subtitle = Array.isArray(searchResults)
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
: null;
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
const leading = sidebarToggle ? <>{sidebarToggle}</> : null;
const actions = (
<>
<button
type="button"
className="icon-button"
onClick={onRefresh}
aria-label="Refresh"
title="Refresh"
>
<RefreshIcon />
</button>
<button type="button" className="secondary" onClick={onExit}>
Back to List
</button>
</>
);
return {
key: 'workspace',
variant: 'workspace',
header: { title, subtitle, leading, actions },
content: <DesktopWorkspace {...workspaceProps} />,
supportsDetail: false,
};
};