This commit is contained in:
2025-10-11 11:54:57 +02:00
parent 41a9e8f76c
commit 6850918e3d
2 changed files with 178 additions and 20 deletions
+151 -16
View File
@@ -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 <span className="meta">{emptyMessage}</span>;
}
@@ -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 (
<div className="preview-stack preview-stack--stacked">
{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 (
<div
key={item.id || index}
className={`preview-stack__item orientation-${item.orientation || 'landscape'}`}
key={entry.id || index}
className={`preview-stack__item orientation-${entry.orientation || 'landscape'}`}
style={{
zIndex: limited.length - index,
zIndex: preparedItems.length - index,
transform,
}}
aria-hidden={hasMultiple ? 'true' : undefined}
aria-hidden={
hasMultiple && !onItemActivate && !onOpenPreview ? 'true' : undefined
}
>
<img src={item.url} alt={item.alt || ''} className="preview-stack__image" />
<img
src={entry.url}
alt={entry.alt || ''}
className="preview-stack__image"
role={onItemActivate || onOpenPreview ? 'button' : undefined}
tabIndex={onItemActivate || onOpenPreview ? 0 : -1}
onClick={(event) => {
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);
}
}
}}
/>
</div>
);
})}
@@ -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 = ({
<>
<div>
<div className="preview-pane preview-pane--stack">
<PreviewStack items={singlePreviewItems} maxItems={1} emptyMessage="Preview loading…" />
<PreviewStack
items={singlePreviewItems}
maxItems={1}
emptyMessage="Preview loading…"
onItemActivate={handlePreviewActivate}
onOpenPreview={onOpenPreview}
activeItemId={activePreviewId}
/>
</div>
</div>
<h3 style={{ margin: 0 }}>{displayName}</h3>
@@ -816,7 +920,13 @@ const DetailPanel = ({
<>
<div>
<div className="preview-pane preview-pane--stack">
<PreviewStack items={stackPreviews} emptyMessage="No previews available." />
<PreviewStack
items={stackPreviews}
emptyMessage="No previews available."
onItemActivate={handlePreviewActivate}
onOpenPreview={onOpenPreview}
activeItemId={activePreviewId}
/>
</div>
</div>
<h3 style={{ margin: 0 }}>{countLabel}</h3>
@@ -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}
/>
</>
)}
+27 -4
View File
@@ -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 {