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 (
-

+

{
+ 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 {