From 6850918e3d05f8bbb75973a463aed2bf2ca704e0 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sat, 11 Oct 2025 11:54:57 +0200 Subject: [PATCH] 6 --- frontend/src/index.jsx | 167 ++++++++++++++++++++++++++++++++++++---- frontend/src/styles.css | 31 +++++++- 2 files changed, 178 insertions(+), 20 deletions(-) diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 9463f90..9480abc 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -557,7 +557,7 @@ const computeStackAngle = (docId, index) => { for (let i = 0; i < source.length; i += 1) { hash = (hash * 31 + source.charCodeAt(i)) % 997; } - const magnitude = (hash % 15) + 1; // 1..15 + const magnitude = Math.max(3, (hash % 13) + 3); // 3..15 const sign = index % 2 === 0 ? 1 : -1; return magnitude * sign; }; @@ -568,7 +568,13 @@ const PreviewStack = ({ items = [], maxItems = MAX_PREVIEW_STACK_ITEMS, emptyMessage = 'Preview unavailable', + onItemActivate, + onOpenPreview, + activeItemId = null, }) => { + const angleMapRef = useRef(new Map()); + const previousOrderRef = useRef([]); + if (!items.length) { return {emptyMessage}; } @@ -576,25 +582,106 @@ const PreviewStack = ({ const limited = items.slice(0, maxItems); const hasMultiple = limited.length > 1; + const preparedItems = useMemo(() => { + const angleMap = angleMapRef.current; + const previousOrder = previousOrderRef.current; + const prevFrontId = previousOrder.length ? previousOrder[0] : null; + const currentFrontId = limited.length ? limited[0].id : null; + const currentIds = new Set(); + + limited.forEach((entry, index) => { + const id = entry.id; + currentIds.add(id); + if (!angleMap.has(id)) { + const initialAngle = index === 0 ? 0 : computeStackAngle(id, index); + angleMap.set(id, initialAngle); + } + }); + + if ( + onItemActivate && + activeItemId && + currentFrontId === activeItemId && + prevFrontId && + prevFrontId !== activeItemId + ) { + const frontAngle = angleMap.get(prevFrontId); + const activeAngle = angleMap.get(activeItemId); + angleMap.set( + prevFrontId, + typeof activeAngle === 'number' ? activeAngle : computeStackAngle(prevFrontId, 1), + ); + angleMap.set(activeItemId, typeof frontAngle === 'number' ? frontAngle : 0); + } else if (currentFrontId && (!angleMap.has(currentFrontId) || angleMap.get(currentFrontId) !== 0)) { + angleMap.set(currentFrontId, 0); + } + + angleMap.forEach((_, id) => { + if (!currentIds.has(id)) { + angleMap.delete(id); + } + }); + + const result = limited.map((entry, index) => { + const angle = angleMap.get(entry.id); + return { + entry, + angle: typeof angle === 'number' ? angle : computeStackAngle(entry.id, index), + offset: hasMultiple ? index * 8 : 0, + }; + }); + + previousOrderRef.current = limited.map((entry) => entry.id); + + return result; + }, [limited, hasMultiple, activeItemId, onItemActivate]); + return (
- {limited.map((item, index) => { - const angle = hasMultiple ? computeStackAngle(item.id, index) : 0; - const offset = hasMultiple ? index * 8 : 0; + {preparedItems.map(({ entry, angle, offset }, index) => { const transform = hasMultiple ? `translate(-50%, -50%) rotate(${angle}deg) translateY(${offset}px)` : 'translate(-50%, -50%)'; + const isFront = index === 0; return (
- {item.alt + {entry.alt { + event.stopPropagation(); + if (isFront && onOpenPreview) { + onOpenPreview(entry.id); + } else if (onItemActivate) { + onItemActivate(entry.id); + } + }} + onKeyDown={(event) => { + if (!onItemActivate && !onOpenPreview) return; + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + event.stopPropagation(); + if (isFront && onOpenPreview) { + onOpenPreview(entry.id); + } else { + onItemActivate?.(entry.id); + } + } + }} + />
); })} @@ -619,12 +706,22 @@ const DetailPanel = ({ onBulkReanalyze, folderOptions = [], defaultMoveTarget = 'root', + onPromoteSelection, + activePreviewId = null, }) => { const lookup = detailMap && typeof detailMap.get === 'function' ? detailMap : new Map(); const selectedCount = selectedDocuments.length; const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null; const detail = singleDoc ? lookup.get(singleDoc.id) || null : null; + const handlePreviewActivate = useCallback( + (docId) => { + if (!docId) return; + onPromoteSelection?.(docId); + }, + [onPromoteSelection], + ); + const makePreviewItem = useCallback((doc, detailEntry, fallbackUrl = null) => { if (!doc) return null; const url = doc.thumbnail?.url || detailEntry?.document?.thumbnail?.url || fallbackUrl; @@ -709,7 +806,14 @@ const DetailPanel = ({ <>
- +

{displayName}

@@ -816,7 +920,13 @@ const DetailPanel = ({ <>
- +

{countLabel}

@@ -1114,6 +1224,9 @@ function App({ routeFolderId = 'root', routeDocumentId = null, navigate }) { active: false, folderName: DEFAULT_FOLDER_NAME, }); + const [activePreviewId, setActivePreviewId] = useState(routeDocumentId || null); + const [previewDocumentId, setPreviewDocumentId] = useState(null); + const [previewDocumentLoading, setPreviewDocumentLoading] = useState(false); const initializedRef = useRef(false); const dragCounterRef = useRef(0); @@ -1169,6 +1282,18 @@ function App({ routeFolderId = 'root', routeDocumentId = null, navigate }) { setStatus(message ? { message, variant } : null); }, []); + useEffect(() => { + if (!selectedDocumentIds.length) { + if (activePreviewId !== null) { + setActivePreviewId(null); + } + return; + } + if (!selectedDocumentIds.includes(activePreviewId)) { + setActivePreviewId(selectedDocumentIds[selectedDocumentIds.length - 1]); + } + }, [selectedDocumentIds, activePreviewId]); + const currentFolderName = useMemo(() => { if (selectedFolder === 'root' || !currentFolder) return DEFAULT_FOLDER_NAME; return currentFolder.name; @@ -1275,6 +1400,18 @@ function App({ routeFolderId = 'root', routeDocumentId = null, navigate }) { [visibleDocumentIds, focusedDocumentId, updateSelectionOrder], ); + const promoteSelectionOrder = useCallback( + (docId) => { + if (!docId) return; + if (!selectedDocumentIds.includes(docId)) return; + updateSelectionOrder(selectedDocumentIds, [docId]); + selectionAnchorRef.current = docId; + setFocusedDocumentId(docId); + setActivePreviewId(docId); + }, + [selectedDocumentIds, updateSelectionOrder], + ); + const visibleSelectedCount = useMemo( () => selectedDocumentIds.filter((id) => visibleDocumentIds.includes(id)).length, [selectedDocumentIds, visibleDocumentIds], @@ -2057,8 +2194,6 @@ function App({ routeFolderId = 'root', routeDocumentId = null, navigate }) { const folderPathCacheRef = useRef(new Map()); const previewCacheRef = useRef(new Map()); const [previewCacheTick, setPreviewCacheTick] = useState(0); - const [previewDocumentId, setPreviewDocumentId] = useState(null); - const [previewDocumentLoading, setPreviewDocumentLoading] = useState(false); const ensureFolderPathOnServer = useCallback( async (baseFolderId, segments) => { @@ -2366,9 +2501,7 @@ const handleDownload = useCallback( try { await ensureDocumentDetail(documentId, { force: false }); await ensurePreviewUrl(documentId, { force: false }); - setSelectedDocumentIds([documentId]); - setFocusedDocumentId(documentId); - selectionAnchorRef.current = documentId; + setActivePreviewId(documentId); if (!skipNavigate && navigate) { navigate(`/documents/${documentId}`, { replace }); } @@ -3259,6 +3392,8 @@ const handleDownload = useCallback( onBulkReanalyze={handleBulkSelectionReanalyze} folderOptions={folderOptions} defaultMoveTarget={defaultMoveTarget} + onPromoteSelection={promoteSelectionOrder} + activePreviewId={activePreviewId} /> )} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 20f9708..79703a9 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -200,7 +200,6 @@ button.icon-button.ghost:hover:not([disabled]) { flex-direction: column; padding: 0.75rem 1.5rem 1.25rem; min-height: 0; - overflow: hidden; } .preview-workspace { @@ -775,14 +774,17 @@ button.icon-button.ghost:hover:not([disabled]) { left: 50%; width: 100%; height: 100%; + display: flex; + align-items: center; + justify-content: center; transition: transform 120ms ease; filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.18)); transform-origin: center; border-radius: 6px; overflow: hidden; + pointer-events: none; } - .preview-stack .preview-stack__item.orientation-portrait { width: 80%; height: 100%; @@ -794,10 +796,31 @@ button.icon-button.ghost:hover:not([disabled]) { } .preview-stack__image { - width: 100%; - height: 100%; + display: block; + width: auto; + height: auto; + max-width: 100%; + max-height: 100%; object-fit: contain; background: transparent; + cursor: pointer; + transition: + outline-color 120ms ease, + box-shadow 120ms ease, + filter 120ms ease, + background-color 120ms ease; + outline: 2px solid transparent; + outline-offset: -2px; + pointer-events: auto; +} + +.preview-stack__image:hover, +.preview-stack__image:focus-visible { + outline-color: rgba(24, 119, 242, 0.85); + box-shadow: + inset 0 0 0 999px rgba(24, 119, 242, 0.18), + 0 6px 18px rgba(24, 119, 242, 0.24); + filter: saturate(118%) brightness(1.05); } .preview-pane--stack {