diff --git a/frontend/src/DesktopWorkspace.css b/frontend/src/DesktopWorkspace.css index efb4955..dd5a0ec 100644 --- a/frontend/src/DesktopWorkspace.css +++ b/frontend/src/DesktopWorkspace.css @@ -1,4 +1,4 @@ -/* Skeuomorphic workspace styles */ +/* Desktop workspace styles */ .skeuo-main { flex: 1; display: flex; diff --git a/frontend/src/DesktopWorkspace.jsx b/frontend/src/DesktopWorkspace.jsx index 4887a39..6bd9719 100644 --- a/frontend/src/DesktopWorkspace.jsx +++ b/frontend/src/DesktopWorkspace.jsx @@ -32,7 +32,6 @@ const resolveSizeKey = (doc) => const CARD_MIN = 240; const CARD_MAX = 340; -const EMPTY_CARD_ASPECT = 1.4; const TAG_REMOVE_DISTANCE = 160; const DEBUG_DRAG = false; @@ -83,7 +82,7 @@ const resolveTagKey = (tag) => { return key != null ? String(key) : null; }; -const SkeuoPreviewCard = ({ +const DesktopPreviewCard = ({ doc, title, ensureAssetUrl, @@ -359,6 +358,87 @@ const cleanupPreview = (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 ( + // eslint-disable-next-line no-empty + error + ) {} + } + + if (!hasDimensions(metadata)) { + return null; + } + + const width = Number(metadata.width); + const height = Number(metadata.height); + if (!Number.isFinite(width) || !Number.isFinite(height)) { + return null; + } + + return [docId, { width, height }]; + }; + + Promise.all(docs.map((doc) => fetchMetadataForDoc(doc))) + .then((entries) => { + if (cancelled) return; + const next = new Map(); + entries.forEach((entry) => { + if (entry) { + next.set(entry[0], entry[1]); + } + }); + setMetadataMap(next); + }) + .catch(() => { + if (!cancelled) { + setMetadataMap(new Map()); + } + }); + + return () => { + cancelled = true; + }; + }, [documents, getDocumentAsset, ensureAssetUrl]); + + return metadataMap; +}; + const seededRandom = (input) => { const text = String(input); let hash = 2166136261; @@ -379,50 +459,66 @@ const randomRangeFromSeed = (seedKey, min, max) => { const buildKey = (docId, suffix) => `${docId}::${suffix}`; const clampCardDimensions = (width, height) => { - let w = Number(width); - let h = Number(height); + const w = Number(width); + const h = Number(height); if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) { return null; } - const scaleToFit = (maxWidth, maxHeight, currentWidth, currentHeight) => { - if (currentWidth <= maxWidth && currentHeight <= maxHeight) { - return 1; + 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); } - return Math.min(maxWidth / currentWidth, maxHeight / currentHeight); }; - const scaleToFill = (minWidth, minHeight, currentWidth, currentHeight) => { - if (currentWidth >= minWidth && currentHeight >= minHeight) { - return 1; + 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 Math.max(minWidth / currentWidth, minHeight / currentHeight); - }; - - // Step 1: scale down so both dimensions fit within the maximum bounds. - const downScale = scaleToFit(CARD_MAX, CARD_MAX, w, h); - w *= downScale; - h *= downScale; - - // Step 2: if the card is too small, scale up (preserving ratio). - const upScale = scaleToFill(CARD_MIN, CARD_MIN, w, h); - w *= upScale; - h *= upScale; - - // Step 3: Re-apply upper bounds if the scale-up pushed us over. - if (w > CARD_MAX || h > CARD_MAX) { - const adjust = scaleToFit(CARD_MAX, CARD_MAX, w, h); - w *= adjust; - h *= adjust; - } + return acc; + }, null); + const scale = best ? best.scale : 1; return { - width: w, - height: h, + 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, @@ -459,6 +555,7 @@ const DesktopWorkspace = ({ 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) => { @@ -473,7 +570,26 @@ const DesktopWorkspace = ({ useEffect(() => { documentLookupRef.current = documentLookup; }, [documentLookup]); - const applySnapshotDimensions = useCallback(() => {}, []); + + 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; @@ -530,29 +646,14 @@ const DesktopWorkspace = ({ return set; }, [activeTagIds]); - const resolvePreviewAsset = useCallback( - (doc) => { - if (!doc) return null; - return getDocumentAsset(doc, 'thumbnail'); - }, - [getDocumentAsset], - ); - const resolvePreviewDimensions = useCallback( (doc) => { - if (!doc) return null; - - const asset = resolvePreviewAsset(doc); - const view = createAssetView(asset); - const metadata = view.getPrimaryMetadata() || {}; - const width = metadata?.width; - const height = metadata?.height; - if (typeof width === 'number' && typeof height === 'number') { - return { width, height }; + if (!doc?.id) { + return null; } - return null; + return previewMetadata.get(String(doc.id)) || null; }, - [resolvePreviewAsset], + [previewMetadata], ); useEffect(() => { @@ -661,37 +762,60 @@ const DesktopWorkspace = ({ }, [updateRemovalCursor]); const ensureDocumentSize = useCallback((doc) => { - const key = resolveSizeKey(doc); - const cache = docSizeMapRef.current.get(key); - if (cache) { - return cache; + if (!doc?.id) { + return null; } - const intrinsic = resolvePreviewDimensions(doc); - let normalized = null; - if (intrinsic?.width && intrinsic?.height) { - normalized = clampCardDimensions(intrinsic.width, intrinsic.height); - } - - if (!normalized) { - const seed = seededRandom(`${key}:size`); - let width = CARD_MIN + seed * (Math.min(CARD_MAX, CARD_MAX / EMPTY_CARD_ASPECT) - CARD_MIN); - width = clamp(width, CARD_MIN, Math.min(CARD_MAX, CARD_MAX / EMPTY_CARD_ASPECT)); - const height = width * EMPTY_CARD_ASPECT; - normalized = clampCardDimensions(width, height); - } - - if (!normalized) { - normalized = { width: CARD_MIN, height: CARD_MIN }; - } - - docSizeMapRef.current.set(key, normalized); - return normalized; - }, [resolvePreviewDimensions]); + return docSizeMapRef.current.get(String(doc.id)) || null; + }, []); useEffect(() => { - docSizeMapRef.current = new Map(); - setDocSizeVersion((value) => value + 1); - }, [items]); + 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) { @@ -867,7 +991,11 @@ const recalcVisibleDocIds = useCallback(() => { if (!doc) { return; } - const { width: cardWidth, height: cardHeight } = ensureDocumentSize(doc); + 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); @@ -1015,6 +1143,11 @@ const syncLayoutSnapshot = useCallback(() => { return; } + const missingSizes = items.some((doc) => !ensureDocumentSize(doc)); + if (missingSizes) { + return; + } + const previous = layoutRef.current; const next = new Map(); let maxZ = zCounterRef.current; @@ -1023,7 +1156,11 @@ const syncLayoutSnapshot = useCallback(() => { const docsNeedingLayout = []; items.forEach((doc) => { - const { width: docWidth, height: docHeight } = ensureDocumentSize(doc); + const sizeInfo = ensureDocumentSize(doc); + if (!sizeInfo) { + return; + } + const { width: docWidth, height: docHeight } = sizeInfo; const halfWidth = docWidth / 2; const halfHeight = docHeight / 2; @@ -1141,7 +1278,11 @@ const syncLayoutSnapshot = useCallback(() => { if (!originTransform) { const entry = layoutRef.current.get(docId) || null; const doc = documentLookup.get(docKey) || null; - const { width: cardWidth, height: cardHeight } = ensureDocumentSize(doc); + 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; @@ -1620,6 +1761,7 @@ const syncLayoutSnapshot = useCallback(() => { handleCanvasDragLeave, handleCanvasDrop, layoutSnapshot, + docSizeVersion, visibleDocIds, draggingId, tagDropTargetId, @@ -1669,6 +1811,7 @@ const syncLayoutSnapshot = useCallback(() => { items, layoutRef, layoutSnapshot, + docSizeVersion, onDocumentOpen, openOverlayForDoc, overlayDisplay, @@ -1726,11 +1869,17 @@ const DesktopWorkspaceView = () => { closeOverlay, overlayOriginRect, overlayOriginTransform, + docSizeVersion, } = useDesktopContext(); const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = useDocumentDrag(); + const allSizesReady = useMemo( + () => items.every((doc) => ensureDocumentSize(doc)), + [items, ensureDocumentSize, docSizeVersion], + ); + return ( <>
Loading previews…
+No documents to show here yet. Drop files to make this space come alive.