6
This commit is contained in:
+151
-16
@@ -557,7 +557,7 @@ const computeStackAngle = (docId, index) => {
|
|||||||
for (let i = 0; i < source.length; i += 1) {
|
for (let i = 0; i < source.length; i += 1) {
|
||||||
hash = (hash * 31 + source.charCodeAt(i)) % 997;
|
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;
|
const sign = index % 2 === 0 ? 1 : -1;
|
||||||
return magnitude * sign;
|
return magnitude * sign;
|
||||||
};
|
};
|
||||||
@@ -568,7 +568,13 @@ const PreviewStack = ({
|
|||||||
items = [],
|
items = [],
|
||||||
maxItems = MAX_PREVIEW_STACK_ITEMS,
|
maxItems = MAX_PREVIEW_STACK_ITEMS,
|
||||||
emptyMessage = 'Preview unavailable',
|
emptyMessage = 'Preview unavailable',
|
||||||
|
onItemActivate,
|
||||||
|
onOpenPreview,
|
||||||
|
activeItemId = null,
|
||||||
}) => {
|
}) => {
|
||||||
|
const angleMapRef = useRef(new Map());
|
||||||
|
const previousOrderRef = useRef([]);
|
||||||
|
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
return <span className="meta">{emptyMessage}</span>;
|
return <span className="meta">{emptyMessage}</span>;
|
||||||
}
|
}
|
||||||
@@ -576,25 +582,106 @@ const PreviewStack = ({
|
|||||||
const limited = items.slice(0, maxItems);
|
const limited = items.slice(0, maxItems);
|
||||||
const hasMultiple = limited.length > 1;
|
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 (
|
return (
|
||||||
<div className="preview-stack preview-stack--stacked">
|
<div className="preview-stack preview-stack--stacked">
|
||||||
{limited.map((item, index) => {
|
{preparedItems.map(({ entry, angle, offset }, index) => {
|
||||||
const angle = hasMultiple ? computeStackAngle(item.id, index) : 0;
|
|
||||||
const offset = hasMultiple ? index * 8 : 0;
|
|
||||||
const transform = hasMultiple
|
const transform = hasMultiple
|
||||||
? `translate(-50%, -50%) rotate(${angle}deg) translateY(${offset}px)`
|
? `translate(-50%, -50%) rotate(${angle}deg) translateY(${offset}px)`
|
||||||
: 'translate(-50%, -50%)';
|
: 'translate(-50%, -50%)';
|
||||||
|
const isFront = index === 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.id || index}
|
key={entry.id || index}
|
||||||
className={`preview-stack__item orientation-${item.orientation || 'landscape'}`}
|
className={`preview-stack__item orientation-${entry.orientation || 'landscape'}`}
|
||||||
style={{
|
style={{
|
||||||
zIndex: limited.length - index,
|
zIndex: preparedItems.length - index,
|
||||||
transform,
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -619,12 +706,22 @@ const DetailPanel = ({
|
|||||||
onBulkReanalyze,
|
onBulkReanalyze,
|
||||||
folderOptions = [],
|
folderOptions = [],
|
||||||
defaultMoveTarget = 'root',
|
defaultMoveTarget = 'root',
|
||||||
|
onPromoteSelection,
|
||||||
|
activePreviewId = null,
|
||||||
}) => {
|
}) => {
|
||||||
const lookup = detailMap && typeof detailMap.get === 'function' ? detailMap : new Map();
|
const lookup = detailMap && typeof detailMap.get === 'function' ? detailMap : new Map();
|
||||||
const selectedCount = selectedDocuments.length;
|
const selectedCount = selectedDocuments.length;
|
||||||
const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null;
|
const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null;
|
||||||
const detail = singleDoc ? lookup.get(singleDoc.id) || null : 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) => {
|
const makePreviewItem = useCallback((doc, detailEntry, fallbackUrl = null) => {
|
||||||
if (!doc) return null;
|
if (!doc) return null;
|
||||||
const url = doc.thumbnail?.url || detailEntry?.document?.thumbnail?.url || fallbackUrl;
|
const url = doc.thumbnail?.url || detailEntry?.document?.thumbnail?.url || fallbackUrl;
|
||||||
@@ -709,7 +806,14 @@ const DetailPanel = ({
|
|||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<div className="preview-pane preview-pane--stack">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<h3 style={{ margin: 0 }}>{displayName}</h3>
|
<h3 style={{ margin: 0 }}>{displayName}</h3>
|
||||||
@@ -816,7 +920,13 @@ const DetailPanel = ({
|
|||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<div className="preview-pane preview-pane--stack">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<h3 style={{ margin: 0 }}>{countLabel}</h3>
|
<h3 style={{ margin: 0 }}>{countLabel}</h3>
|
||||||
@@ -1114,6 +1224,9 @@ function App({ routeFolderId = 'root', routeDocumentId = null, navigate }) {
|
|||||||
active: false,
|
active: false,
|
||||||
folderName: DEFAULT_FOLDER_NAME,
|
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 initializedRef = useRef(false);
|
||||||
const dragCounterRef = useRef(0);
|
const dragCounterRef = useRef(0);
|
||||||
@@ -1169,6 +1282,18 @@ function App({ routeFolderId = 'root', routeDocumentId = null, navigate }) {
|
|||||||
setStatus(message ? { message, variant } : null);
|
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(() => {
|
const currentFolderName = useMemo(() => {
|
||||||
if (selectedFolder === 'root' || !currentFolder) return DEFAULT_FOLDER_NAME;
|
if (selectedFolder === 'root' || !currentFolder) return DEFAULT_FOLDER_NAME;
|
||||||
return currentFolder.name;
|
return currentFolder.name;
|
||||||
@@ -1275,6 +1400,18 @@ function App({ routeFolderId = 'root', routeDocumentId = null, navigate }) {
|
|||||||
[visibleDocumentIds, focusedDocumentId, updateSelectionOrder],
|
[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(
|
const visibleSelectedCount = useMemo(
|
||||||
() => selectedDocumentIds.filter((id) => visibleDocumentIds.includes(id)).length,
|
() => selectedDocumentIds.filter((id) => visibleDocumentIds.includes(id)).length,
|
||||||
[selectedDocumentIds, visibleDocumentIds],
|
[selectedDocumentIds, visibleDocumentIds],
|
||||||
@@ -2057,8 +2194,6 @@ function App({ routeFolderId = 'root', routeDocumentId = null, navigate }) {
|
|||||||
const folderPathCacheRef = useRef(new Map());
|
const folderPathCacheRef = useRef(new Map());
|
||||||
const previewCacheRef = useRef(new Map());
|
const previewCacheRef = useRef(new Map());
|
||||||
const [previewCacheTick, setPreviewCacheTick] = useState(0);
|
const [previewCacheTick, setPreviewCacheTick] = useState(0);
|
||||||
const [previewDocumentId, setPreviewDocumentId] = useState(null);
|
|
||||||
const [previewDocumentLoading, setPreviewDocumentLoading] = useState(false);
|
|
||||||
|
|
||||||
const ensureFolderPathOnServer = useCallback(
|
const ensureFolderPathOnServer = useCallback(
|
||||||
async (baseFolderId, segments) => {
|
async (baseFolderId, segments) => {
|
||||||
@@ -2366,9 +2501,7 @@ const handleDownload = useCallback(
|
|||||||
try {
|
try {
|
||||||
await ensureDocumentDetail(documentId, { force: false });
|
await ensureDocumentDetail(documentId, { force: false });
|
||||||
await ensurePreviewUrl(documentId, { force: false });
|
await ensurePreviewUrl(documentId, { force: false });
|
||||||
setSelectedDocumentIds([documentId]);
|
setActivePreviewId(documentId);
|
||||||
setFocusedDocumentId(documentId);
|
|
||||||
selectionAnchorRef.current = documentId;
|
|
||||||
if (!skipNavigate && navigate) {
|
if (!skipNavigate && navigate) {
|
||||||
navigate(`/documents/${documentId}`, { replace });
|
navigate(`/documents/${documentId}`, { replace });
|
||||||
}
|
}
|
||||||
@@ -3259,6 +3392,8 @@ const handleDownload = useCallback(
|
|||||||
onBulkReanalyze={handleBulkSelectionReanalyze}
|
onBulkReanalyze={handleBulkSelectionReanalyze}
|
||||||
folderOptions={folderOptions}
|
folderOptions={folderOptions}
|
||||||
defaultMoveTarget={defaultMoveTarget}
|
defaultMoveTarget={defaultMoveTarget}
|
||||||
|
onPromoteSelection={promoteSelectionOrder}
|
||||||
|
activePreviewId={activePreviewId}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+27
-4
@@ -200,7 +200,6 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 0.75rem 1.5rem 1.25rem;
|
padding: 0.75rem 1.5rem 1.25rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-workspace {
|
.preview-workspace {
|
||||||
@@ -775,14 +774,17 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
transition: transform 120ms ease;
|
transition: transform 120ms ease;
|
||||||
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.18));
|
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.18));
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.preview-stack .preview-stack__item.orientation-portrait {
|
.preview-stack .preview-stack__item.orientation-portrait {
|
||||||
width: 80%;
|
width: 80%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -794,10 +796,31 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preview-stack__image {
|
.preview-stack__image {
|
||||||
width: 100%;
|
display: block;
|
||||||
height: 100%;
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
background: transparent;
|
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 {
|
.preview-pane--stack {
|
||||||
|
|||||||
Reference in New Issue
Block a user