diff --git a/frontend/src/asset_manager.js b/frontend/src/asset_manager.js index 89bc4bb..44054a4 100644 --- a/frontend/src/asset_manager.js +++ b/frontend/src/asset_manager.js @@ -17,7 +17,116 @@ export const getAssetFromVersion = (currentVersion, assetType) => { return getAssetFromGroup(currentVersion.assets, assetType); }; -export const resolveDocumentAssetUrl = (doc, type, { ensureAssetUrl, getAsset, ensureOptions } = {}) => { +const normalizeAssetObjects = (objects) => { + if (!Array.isArray(objects)) { + return []; + } + return objects + .filter((entry) => Number.isInteger(entry?.ordinal)) + .slice() + .sort((a, b) => a.ordinal - b.ordinal); +}; + +const mergeAssetObjects = (existingObjects, incomingObjects) => { + const merged = new Map(); + + normalizeAssetObjects(existingObjects).forEach((entry) => { + merged.set(entry.ordinal, { ...entry }); + }); + + normalizeAssetObjects(incomingObjects).forEach((entry) => { + const current = merged.get(entry.ordinal) || {}; + merged.set(entry.ordinal, { ...current, ...entry }); + }); + + return [...merged.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([, value]) => value); +}; + +export class AssetView { + constructor(asset) { + this.asset = asset || null; + this._objectsRef = null; + this._sortedObjects = []; + } + + getCardinality() { + if (!this.asset) { + return 0; + } + + const reported = Number(this.asset.cardinality); + if (Number.isFinite(reported) && reported > 0) { + return reported; + } + + const objectsCount = this.getObjects().length; + if (objectsCount > 0) { + return objectsCount; + } + + return this.asset.metadata ? 1 : 0; + } + + getObjects() { + if (!this.asset || !Array.isArray(this.asset.objects) || this.asset.objects.length === 0) { + return []; + } + + if (this._objectsRef === this.asset.objects) { + return this._sortedObjects; + } + + this._objectsRef = this.asset.objects; + this._sortedObjects = normalizeAssetObjects(this.asset.objects); + return this._sortedObjects; + } + + getObject(ordinal = 1) { + const fromObjects = this.getObjects().find((entry) => entry.ordinal === ordinal); + if (fromObjects) { + return fromObjects; + } + + if (ordinal === 1 && this.asset) { + if (this.asset.url || this.asset.metadata) { + return { + ordinal: 1, + url: this.asset.url || null, + metadata: this.asset.metadata || null, + expires_at: this.asset.expiresAt ?? null, + }; + } + } + + return null; + } + + getPrimaryObject() { + return this.getObject(1); + } + + getPrimaryMetadata() { + return this.getPrimaryObject()?.metadata || null; + } + + getPrimaryUrl() { + return this.getPrimaryObject()?.url || null; + } + + hasObject(ordinal) { + return Boolean(this.getObject(ordinal)); + } +} + +export const createAssetView = (asset) => new AssetView(asset); + +export const resolveDocumentAssetUrl = ( + doc, + type, + { ensureAssetUrl, getAsset, ensureOptions, objectOrdinal = 1 } = {}, +) => { if (!doc || !type) { return null; } @@ -25,15 +134,32 @@ export const resolveDocumentAssetUrl = (doc, type, { ensureAssetUrl, getAsset, e if (!asset) { return null; } + const view = createAssetView(asset); + const object = view.getObject(objectOrdinal); + const url = object?.url || (objectOrdinal === 1 ? view.getPrimaryUrl() : null); + const expiresAt = typeof object?.expires_at === 'number' + ? object.expires_at + : objectOrdinal === 1 && typeof asset.expiresAt === 'number' + ? asset.expiresAt + : null; const now = Date.now(); - const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null; - const hasFreshUrl = asset.url && (!expiresAt || expiresAt > now); - if (hasFreshUrl) { - return asset.url; + if (url && (!expiresAt || expiresAt > now)) { + return url; } if (doc.id && asset.id && typeof ensureAssetUrl === 'function') { - const force = Boolean(asset.url && expiresAt && expiresAt <= now); - const options = ensureOptions ? { ...ensureOptions, force } : { force }; + const force = Boolean(url && expiresAt && expiresAt <= now); + const options = { + force, + start: objectOrdinal, + limit: 1, + ...(ensureOptions || {}), + }; + if (!options.start) { + options.start = objectOrdinal; + } + if (!options.limit) { + options.limit = 1; + } ensureAssetUrl(doc.id, asset, options).catch(() => {}); } return null; @@ -63,6 +189,10 @@ class AssetManager { } const cached = this.assetCache.get(asset.id); if (!cached) { + const normalized = mergeAssetObjects(null, asset.objects); + if (normalized.length) { + return { ...asset, objects: normalized }; + } return asset; } const merged = { ...cached, ...asset }; @@ -76,6 +206,10 @@ class AssetManager { merged.expiresAt = cachedExpires; } } + const mergedObjects = mergeAssetObjects(cached.objects, asset.objects); + if (mergedObjects.length) { + merged.objects = mergedObjects; + } return merged; } @@ -177,16 +311,45 @@ class AssetManager { return Promise.resolve(asset || null); } - const assetExpiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null; - if (!force && asset?.url && (!assetExpiresAt || assetExpiresAt > Date.now())) { - this.rememberAsset(asset); - return Promise.resolve(asset); + const requestedStart = Number.isInteger(start) && start > 0 ? start : 1; + const requestedLimit = Number.isInteger(limit) && limit > 0 ? limit : 1; + const requestedEnd = requestedStart + requestedLimit - 1; + + const baseAsset = this.assetCache.get(asset.id) || asset; + const view = createAssetView(baseAsset); + const assetExpiresAt = typeof baseAsset.expiresAt === 'number' ? baseAsset.expiresAt : null; + const now = Date.now(); + + const isOrdinalSatisfied = (ordinal) => { + const object = view.getObject(ordinal); + if (!object) { + return false; + } + if (!object.url) { + return false; + } + if (typeof object.expires_at === 'number') { + return object.expires_at > now; + } + if (ordinal === 1 && baseAsset.url && (!assetExpiresAt || assetExpiresAt > now)) { + return true; + } + return true; + }; + + let needsFetch = force; + if (!needsFetch) { + for (let ordinal = requestedStart; ordinal <= requestedEnd; ordinal += 1) { + if (!isOrdinalSatisfied(ordinal)) { + needsFetch = true; + break; + } + } } - const cached = this.assetCache.get(asset.id); - const now = Date.now(); - if (!force && cached && cached.expiresAt && cached.expiresAt > now && cached.url) { - return Promise.resolve({ ...asset, ...cached }); + if (!needsFetch) { + this.rememberAsset(baseAsset); + return Promise.resolve(baseAsset); } const inflightKey = `${documentId}:${asset.id}:${start ?? 'd'}:${limit ?? 'd'}`; @@ -211,19 +374,31 @@ class AssetManager { const request = this.api .get(`/assets/${asset.id}`, requestConfig) .then(({ data }) => { - const objects = Array.isArray(data.objects) ? data.objects : []; - const primaryObject = objects[0] || null; + const incomingObjects = Array.isArray(data.objects) ? data.objects : []; + const cachedEntry = this.assetCache.get(asset.id) || baseAsset; + const mergedObjects = mergeAssetObjects(cachedEntry?.objects, incomingObjects); + const combined = { ...cachedEntry, ...asset, ...data, objects: mergedObjects }; + const view = createAssetView(combined); + const primaryObject = view.getPrimaryObject(); const expiresAt = typeof primaryObject?.expires_at === 'number' ? primaryObject.expires_at : Date.now() + this.assetPresignTtlMs; + const cardinality = (() => { + const reported = Number(data.cardinality ?? asset.cardinality ?? cachedEntry?.cardinality); + const objectsCount = mergedObjects.length; + if (Number.isFinite(reported) && reported > 0) { + return Math.max(reported, objectsCount) || null; + } + return objectsCount || null; + })(); const entry = { - ...asset, - ...data, - objects, - url: primaryObject?.url || null, + ...combined, + cardinality, + url: view.getPrimaryUrl(), expiresAt, }; + this.rememberAsset(entry); return entry; }) diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx index 4698c38..c6c5589 100644 --- a/frontend/src/detail/DetailPanel.jsx +++ b/frontend/src/detail/DetailPanel.jsx @@ -1,15 +1,25 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; -import { DownloadIcon, EditIcon } from '../ui/icons'; +import { DownloadIcon, EditIcon, ArrowLeftIcon, ArrowRightIcon } from '../ui/icons'; import { getTagColorStyle } from '../utils/colors'; import { formatFileSize } from '../utils/format'; -import { resolveDocumentAssetUrl } from '../asset_manager'; +import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; +import { useAssetNavigator } from '../hooks/useAssetNavigator'; import { CORRESPONDENT_ROLES } from '../constants/correspondents'; const MAX_PREVIEW_STACK_ITEMS = 15; const normalizeRole = (role) => (role || '').toLowerCase(); +const derivePreviewOrientation = (metadata) => { + const width = Number(metadata?.width); + const height = Number(metadata?.height); + if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { + return width >= height ? 'landscape' : 'portrait'; + } + return 'landscape'; +}; + const formatRoleLabel = (role) => { const normalized = normalizeRole(role); if (!normalized) return 'Other'; @@ -435,22 +445,34 @@ const DetailPanel = ({ setOcrOpen(false); }, []); + const singlePreviewNavigator = useAssetNavigator({ + document: singleDoc, + assetType: 'preview', + ensureAssetUrl, + getAsset: getDocumentAsset, + prefetch: 3, + }); + const makePreviewItem = useCallback( - (doc) => { + (doc, ordinal = 1) => { if (!doc) return null; - const url = resolveDocumentAssetUrl(doc, 'preview', { - ensureAssetUrl, - getAsset: getDocumentAsset, - }); + const asset = getDocumentAsset(doc, 'preview'); + const assetView = createAssetView(asset); + const object = assetView.getObject(ordinal); + let url = object?.url || null; + if (!url) { + url = resolveDocumentAssetUrl(doc, 'preview', { + ensureAssetUrl, + getAsset: getDocumentAsset, + ensureOptions: { start: ordinal, limit: 1 }, + objectOrdinal: ordinal, + }); + } if (!url) { return null; } - const asset = getDocumentAsset(doc, 'preview'); - const primaryObject = asset?.objects?.[0] || null; - const primaryMetadata = primaryObject?.metadata || asset?.metadata || {}; - const width = Number(primaryMetadata?.width) || 0; - const height = Number(primaryMetadata?.height) || 0; - const orientation = width > 0 && height > 0 ? (width >= height ? 'landscape' : 'portrait') : 'landscape'; + const metadata = object?.metadata || assetView.getPrimaryMetadata() || {}; + const orientation = derivePreviewOrientation(metadata); return { id: doc.id, url, @@ -477,32 +499,74 @@ const DetailPanel = ({ return ordered; }, [selectedDocuments]); + const stackTopDocument = stackDocuments[0] || null; + const stackPreviewNavigator = useAssetNavigator({ + document: stackTopDocument, + assetType: 'preview', + ensureAssetUrl, + getAsset: getDocumentAsset, + prefetch: 3, + }); + const singlePreviewItems = useMemo(() => { if (!singleDoc) return []; - const item = makePreviewItem(singleDoc); - return item ? [item] : []; - }, [singleDoc, makePreviewItem]); - - const stackPreviews = useMemo( - () => - stackDocuments - .map((doc) => makePreviewItem(doc)) - .filter(Boolean), - [stackDocuments, makePreviewItem], - ); - - useEffect(() => { - if (!ensureAssetUrl) { - return; + const url = singlePreviewNavigator.currentUrl; + if (!url) { + return []; } + const orientation = derivePreviewOrientation(singlePreviewNavigator.currentMetadata); + return [ + { + id: singleDoc.id, + url, + orientation, + alt: singleDoc.title || singleDoc.original_name || 'Document preview', + }, + ]; + }, [singleDoc, singlePreviewNavigator.currentUrl, singlePreviewNavigator.currentMetadata]); - stackDocuments.forEach((doc) => { - resolveDocumentAssetUrl(doc, 'preview', { - ensureAssetUrl, - getAsset: getDocumentAsset, - }); - }); - }, [stackDocuments, ensureAssetUrl, getDocumentAsset]); + const stackPreviews = useMemo(() => { + if (!stackDocuments.length) { + return []; + } + return stackDocuments + .map((doc) => { + if (!doc) return null; + if (stackTopDocument && doc.id === stackTopDocument.id) { + const url = stackPreviewNavigator.currentUrl; + if (!url) { + return null; + } + const orientation = derivePreviewOrientation(stackPreviewNavigator.currentMetadata); + return { + id: doc.id, + url, + orientation, + alt: doc.title || doc.original_name || 'Document preview', + }; + } + return makePreviewItem(doc, 1); + }) + .filter(Boolean); + }, [ + stackDocuments, + stackTopDocument, + stackPreviewNavigator.currentUrl, + stackPreviewNavigator.currentMetadata, + makePreviewItem, + ]); + + const singleCardinality = singlePreviewNavigator.cardinality; + const singleEffectiveCardinality = singleCardinality || (singlePreviewNavigator.currentUrl ? 1 : 0); + const singleHasPreview = Boolean(singlePreviewNavigator.currentUrl); + + const topDocId = stackTopDocument?.id || null; + const topCardinality = stackPreviewNavigator.cardinality; + const topEffectiveCardinality = topCardinality || (stackPreviewNavigator.currentUrl ? 1 : 0); + const topHasPreview = Boolean(stackPreviewNavigator.currentUrl); + const topOrdinal = stackPreviewNavigator.ordinal; + const topCanGoPrev = stackPreviewNavigator.canGoPrev; + const topCanGoNext = stackPreviewNavigator.canGoNext; const bulkTagUnion = useMemo(() => { if (!selectedDocuments.length) return []; @@ -661,19 +725,64 @@ const DetailPanel = ({ const hasPageCount = Number.isFinite(pageCountValue) && pageCountValue >= 0; const metadata = singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null; + const effectiveCardinality = singleEffectiveCardinality; + const canGoPrev = singlePreviewNavigator.canGoPrev; + const canGoNext = singlePreviewNavigator.canGoNext; + const hasPreviewImage = singleHasPreview; + const interceptNavPointer = (event) => { + event.preventDefault(); + event.stopPropagation(); + }; + return ( <> -