diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx index 4d3f0e0..4500be1 100644 --- a/frontend/src/detail/DetailPanel.jsx +++ b/frontend/src/detail/DetailPanel.jsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { DownloadIcon, EditIcon, diff --git a/frontend/src/skeuomorphic_ws.css b/frontend/src/skeuomorphic_ws.css index 992ef54..b2b9921 100644 --- a/frontend/src/skeuomorphic_ws.css +++ b/frontend/src/skeuomorphic_ws.css @@ -55,7 +55,6 @@ display: flex; flex-direction: column; align-items: center; - transition: width 0.28s ease, height 0.28s ease; } .skeuo-item:focus-visible { diff --git a/frontend/src/skeuomorphic_ws.jsx b/frontend/src/skeuomorphic_ws.jsx index 6b9f858..a5ed2c4 100644 --- a/frontend/src/skeuomorphic_ws.jsx +++ b/frontend/src/skeuomorphic_ws.jsx @@ -60,9 +60,12 @@ const SkeuoPreviewCard = ({ prefetch, }); - const { currentUrl, cardinality, canGoPrev, canGoNext } = navigator; + 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; @@ -74,6 +77,9 @@ const SkeuoPreviewCard = ({ 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); @@ -83,6 +89,9 @@ const SkeuoPreviewCard = ({ title, canGoPrev, canGoNext, + ordinal, + metadataWidth, + metadataHeight, navigator.goPrev, navigator.goNext, onNavigatorSnapshot, @@ -555,6 +564,37 @@ const clamp = (value, min, max) => { return value; }; +const clampCardDimensions = (width, height) => { + let nextWidth = Number(width); + let nextHeight = Number(height); + if (!Number.isFinite(nextWidth) || nextWidth <= 0 || !Number.isFinite(nextHeight) || nextHeight <= 0) { + return null; + } + + const scaleDown = Math.min(1, CARD_MAX / nextWidth, CARD_MAX / nextHeight); + nextWidth *= scaleDown; + nextHeight *= scaleDown; + + const minDim = Math.min(nextWidth, nextHeight); + if (minDim > 0 && minDim < CARD_MIN) { + const scaleUp = CARD_MIN / minDim; + nextWidth *= scaleUp; + nextHeight *= scaleUp; + + const adjust = Math.min(1, CARD_MAX / nextWidth, CARD_MAX / nextHeight); + nextWidth *= adjust; + nextHeight *= adjust; + } + + nextWidth = clamp(nextWidth, CARD_MIN, CARD_MAX); + nextHeight = clamp(nextHeight, CARD_MIN, CARD_MAX); + + return { + width: nextWidth, + height: nextHeight, + }; +}; + const formatTransform = (x, y, rotation = 0, scale = 1) => `translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`; @@ -602,43 +642,90 @@ const SkeuomorphicWorkspace = ({ 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 handleNavigatorSnapshot = useCallback((docId, snapshot) => { - if (!docId) { - return; - } - setPreviewSnapshots((previous) => { - const prevSnapshot = previous.get(docId); - if (!snapshot) { - if (!previous.has(docId)) { + const applySnapshotDimensions = useCallback( + (docId, snapshot) => { + if (!docId || !snapshot) { + return; + } + + const doc = documentLookupRef.current.get(docId); + if (!doc) { + return; + } + + const width = Number(snapshot.width); + const height = Number(snapshot.height); + if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) { + return; + } + + const normalized = clampCardDimensions(width, height); + if (!normalized) { + return; + } + + const key = resolveSizeKey(doc); + const existing = docSizeMapRef.current.get(key); + if (existing && existing.width === normalized.width && existing.height === normalized.height) { + return; + } + + docSizeMapRef.current.set(key, normalized); + setDocSizeVersion((value) => value + 1); + }, + [], + ); + const handleNavigatorSnapshot = useCallback( + (docId, snapshot) => { + if (!docId) { + return; + } + + setPreviewSnapshots((previous) => { + const prevSnapshot = previous.get(docId); + if (!snapshot) { + if (!previous.has(docId)) { + return previous; + } + const next = new Map(previous); + next.delete(docId); + 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; } - const next = new Map(previous); - next.delete(docId); + next.set(docId, snapshot); return next; + }); + + if (snapshot) { + applySnapshotDimensions(docId, snapshot); } - 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; - if (sameSnapshot) { - return previous; - } - next.set(docId, snapshot); - return next; - }); - }, []); + }, + [applySnapshotDimensions], + ); const activeTagSet = useMemo(() => { if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) { return new Set(); @@ -663,6 +750,15 @@ const SkeuomorphicWorkspace = ({ const resolvePreviewDimensions = useCallback( (doc) => { if (!doc) return null; + + const snapshot = previewSnapshots.get(doc.id); + if (snapshot && snapshot.width && snapshot.height) { + return { + width: snapshot.width, + height: snapshot.height, + }; + } + const asset = resolvePreviewAsset(doc); const view = createAssetView(asset); const metadata = view.getPrimaryMetadata() || {}; @@ -673,7 +769,7 @@ const SkeuomorphicWorkspace = ({ } return null; }, - [resolvePreviewAsset], + [previewSnapshots, resolvePreviewAsset], ); useEffect(() => { @@ -780,48 +876,26 @@ const SkeuomorphicWorkspace = ({ if (cache) { return cache; } - let width; - let height; - const intrinsic = resolvePreviewDimensions(doc); + let normalized = null; if (intrinsic?.width && intrinsic?.height) { - width = intrinsic.width; - height = intrinsic.height; - } else { + normalized = clampCardDimensions(intrinsic.width, intrinsic.height); + } + + if (!normalized) { const seed = seededRandom(`${key}:size`); - width = CARD_MIN + seed * (Math.min(CARD_MAX, CARD_MAX / EMPTY_CARD_ASPECT) - CARD_MIN); + 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)); - height = width * EMPTY_CARD_ASPECT; + const height = width * EMPTY_CARD_ASPECT; + normalized = clampCardDimensions(width, height); } - if (!Number.isFinite(width) || width <= 0) { - width = CARD_MIN; - } - if (!Number.isFinite(height) || height <= 0) { - height = CARD_MIN; + if (!normalized) { + normalized = { width: CARD_MIN, height: CARD_MIN }; } - const scaleDown = Math.min(1, CARD_MAX / width, CARD_MAX / height); - width *= scaleDown; - height *= scaleDown; - - const minDim = Math.min(width, height); - if (minDim < CARD_MIN) { - const scaleUp = CARD_MIN / minDim; - width *= scaleUp; - height *= scaleUp; - - const adjust = Math.min(1, CARD_MAX / width, CARD_MAX / height); - width *= adjust; - height *= adjust; - } - - width = clamp(width, CARD_MIN, CARD_MAX); - height = clamp(height, CARD_MIN, CARD_MAX); - - const size = { width, height }; - docSizeMapRef.current.set(key, size); - return size; + docSizeMapRef.current.set(key, normalized); + return normalized; }, [resolvePreviewDimensions]); const documentLookup = useMemo(() => { @@ -834,8 +908,13 @@ const SkeuomorphicWorkspace = ({ return map; }, [items]); + useEffect(() => { + documentLookupRef.current = documentLookup; + }, [documentLookup]); + useEffect(() => { docSizeMapRef.current = new Map(); + setDocSizeVersion((value) => value + 1); }, [items]); const overlayDisplay = useMemo(() => { @@ -1020,6 +1099,7 @@ const SkeuomorphicWorkspace = ({ items, canvasSize.width, canvasSize.height, + docSizeVersion, ensureDocumentSize, syncLayoutSnapshot, ]);