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 ( <> -
-
- -
+
+ + {hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? ( +
+ + +
+ ) : null}
{isEditingTitle ? ( @@ -873,19 +982,66 @@ const DetailPanel = ({ const renderBulk = () => { const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`; const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—'; + const topDoc = stackTopDocument; + const topDocIdLocal = topDocId; + const topOrdinal = stackPreviewNavigator.ordinal; + const topCardinalityLocal = topEffectiveCardinality; + const topHasPreview = Boolean(stackPreviewNavigator.currentUrl); + const topCanGoPrev = stackPreviewNavigator.canGoPrev; + const topCanGoNext = stackPreviewNavigator.canGoNext; + const interceptTopNavPointer = (event) => { + event.preventDefault(); + event.stopPropagation(); + }; return ( <> -
-
- -
+
+ + {topDocIdLocal && topHasPreview && (topCardinalityLocal > 1 || topCanGoPrev || topCanGoNext) ? ( +
+ + +
+ ) : null}

{countLabel}

diff --git a/frontend/src/documents/DocumentsTable.jsx b/frontend/src/documents/DocumentsTable.jsx index 629a03f..599f1b2 100644 --- a/frontend/src/documents/DocumentsTable.jsx +++ b/frontend/src/documents/DocumentsTable.jsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react'; -import { getAssetFromVersion, resolveDocumentAssetUrl } from '../asset_manager'; +import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; import { getTagColorStyle } from '../utils/colors'; import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon } from '../ui/icons'; @@ -21,9 +21,12 @@ const DocumentThumbnailImage = ({ maxSize = LIST_ICON_SIZE, }) => { const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1)); - const thumbnailAsset = useMemo(() => getAssetFromVersion(document?.current_version, 'thumbnail'), [document?.current_version]); - const primaryObject = thumbnailAsset?.objects?.[0] || null; - const primaryMetadata = primaryObject?.metadata || thumbnailAsset?.metadata || {}; + const thumbnailAsset = useMemo( + () => getAssetFromVersion(document?.current_version, 'thumbnail'), + [document?.current_version], + ); + const thumbnailView = useMemo(() => createAssetView(thumbnailAsset), [thumbnailAsset]); + const primaryMetadata = thumbnailView.getPrimaryMetadata() || {}; const assetWidth = Number(primaryMetadata?.width); const assetHeight = Number(primaryMetadata?.height); diff --git a/frontend/src/hooks/useAssetNavigator.js b/frontend/src/hooks/useAssetNavigator.js new file mode 100644 index 0000000..a4a9f3e --- /dev/null +++ b/frontend/src/hooks/useAssetNavigator.js @@ -0,0 +1,133 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { createAssetView } from '../asset_manager'; + +const clampOrdinalValue = (value, cardinality, defaultOrdinal) => { + const raw = Number.isFinite(value) ? value : defaultOrdinal; + let next = Math.max(1, Math.floor(raw)); + if (cardinality && cardinality > 0) { + next = Math.min(next, cardinality); + } + return next; +}; + +export const useAssetNavigator = ({ + document, + assetType, + ensureAssetUrl, + getAsset, + prefetch = 2, + defaultOrdinal = 1, +}) => { + const documentId = document?.id || null; + + const asset = useMemo(() => { + if (!document || typeof getAsset !== 'function') { + return null; + } + return getAsset(document, assetType); + }, [document, assetType, getAsset]); + + const view = useMemo(() => createAssetView(asset), [asset]); + const cardinality = view.getCardinality(); + + const [ordinal, setOrdinalInternal] = useState(defaultOrdinal); + + useEffect(() => { + setOrdinalInternal(defaultOrdinal); + }, [documentId, assetType, defaultOrdinal]); + + const setOrdinal = useCallback( + (next) => { + setOrdinalInternal((prev) => { + const target = typeof next === 'function' ? next(prev) : next; + return clampOrdinalValue(target, cardinality, defaultOrdinal); + }); + }, + [cardinality, defaultOrdinal], + ); + + const goPrev = useCallback(() => setOrdinal((value) => value - 1), [setOrdinal]); + const goNext = useCallback(() => setOrdinal((value) => value + 1), [setOrdinal]); + + const objects = view.getObjects(); + const currentObject = view.getObject(ordinal); + const currentUrl = currentObject?.url || view.getPrimaryUrl(); + const currentMetadata = currentObject?.metadata || view.getPrimaryMetadata() || null; + + const canGoPrev = ordinal > 1; + const canGoNext = cardinality ? ordinal < cardinality : true; + + const ordinalsNeedingLoad = useMemo(() => { + const missing = []; + if (!asset) { + return missing; + } + const maxOrdinal = cardinality && cardinality > 0 + ? Math.min(cardinality, ordinal + Math.max(1, prefetch) - 1) + : ordinal + Math.max(1, prefetch) - 1; + + for (let ord = ordinal; ord <= maxOrdinal; ord += 1) { + const object = view.getObject(ord); + if (!object?.url) { + missing.push(ord); + } + } + + return missing; + }, [asset, view, ordinal, prefetch, cardinality]); + + const fetchStart = ordinalsNeedingLoad.length ? ordinalsNeedingLoad[0] : null; + const fetchEnd = ordinalsNeedingLoad.length ? ordinalsNeedingLoad[ordinalsNeedingLoad.length - 1] : null; + const fetchLimit = fetchStart && fetchEnd ? fetchEnd - fetchStart + 1 : null; + + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!documentId || !asset || !ensureAssetUrl) { + setLoading(false); + return; + } + if (!fetchStart || !fetchLimit) { + setLoading(false); + return; + } + + let cancelled = false; + setLoading(true); + ensureAssetUrl(documentId, asset, { + start: fetchStart, + limit: fetchLimit, + }) + .catch(() => {}) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [documentId, asset, ensureAssetUrl, fetchStart, fetchLimit]); + + return { + document, + documentId, + asset, + assetType, + ordinal, + setOrdinal, + goPrev, + goNext, + canGoPrev, + canGoNext, + cardinality, + currentObject, + currentUrl, + currentMetadata, + objects, + isLoading: loading, + }; +}; + +export default useAssetNavigator; diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 3917037..2d3738a 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -21,7 +21,7 @@ import { matchPath, } from 'react-router-dom'; import './styles.css'; -import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl } from './asset_manager'; +import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from './asset_manager'; import useApiError from './hooks/useApiError'; import SkeuomorphicWorkspace from './skeuomorphic_ws'; import DetailPanel from './detail/DetailPanel'; @@ -413,8 +413,27 @@ const AppLayout = () => { const isAssetEquivalent = (lhs, rhs) => { if (!lhs || !rhs) return false; - const lhsPrimaryMetadata = lhs?.objects?.[0]?.metadata || lhs?.metadata; - const rhsPrimaryMetadata = rhs?.objects?.[0]?.metadata || rhs?.metadata; + const lhsView = createAssetView(lhs); + const rhsView = createAssetView(rhs); + const lhsPrimaryMetadata = lhsView.getPrimaryMetadata() || lhs?.metadata; + const rhsPrimaryMetadata = rhsView.getPrimaryMetadata() || rhs?.metadata; + const lhsCardinality = lhsView.getCardinality() || lhs?.cardinality || null; + const rhsCardinality = rhsView.getCardinality() || rhs?.cardinality || null; + const lhsObjects = lhsView.getObjects(); + const rhsObjects = rhsView.getObjects(); + const objectsComparable = lhsObjects.length === rhsObjects.length + && lhsObjects.every((entry, index) => { + const other = rhsObjects[index]; + if (!other) return false; + if (entry.ordinal !== other.ordinal) return false; + if (entry.url && other.url && entry.url === other.url) { + return true; + } + if (!entry.url && !other.url) { + return JSON.stringify(entry.metadata || null) === JSON.stringify(other.metadata || null); + } + return entry.url === other.url; + }); return ( lhs.id === rhs.id && lhs.url === rhs.url && @@ -422,7 +441,9 @@ const AppLayout = () => { lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height && lhs.mime_type === rhs.mime_type && lhs.asset_type === rhs.asset_type && - lhs.created_at === rhs.created_at + lhs.created_at === rhs.created_at && + lhsCardinality === rhsCardinality && + objectsComparable ); }; @@ -2354,13 +2375,17 @@ const AppLayout = () => { ); const ensureAssetUrl = useCallback( - async (documentId, asset, { force = false } = {}) => { + async (documentId, asset, { force = false, start = null, limit = null } = {}) => { if (!documentId || !asset?.id) { return null; } try { - const entry = await assetManager.ensureAsset(documentId, asset, { force }); + const entry = await assetManager.ensureAsset(documentId, asset, { + force, + start, + limit, + }); if (!entry) { return null; diff --git a/frontend/src/skeuomorphic_ws.css b/frontend/src/skeuomorphic_ws.css index 1c8c5f0..33e6912 100644 --- a/frontend/src/skeuomorphic_ws.css +++ b/frontend/src/skeuomorphic_ws.css @@ -189,6 +189,59 @@ user-select: none; } + +.skeuo-card__nav { + position: absolute; + bottom: calc(3em * 0.707 * var(--nav-scale)); + left: 50%; + transform: translateX(-50%) scale(calc(0.707 * var(--nav-scale, 1))); + transform-origin: center; + display: flex; + gap: 2rem; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; +} + +.skeuo-item__card:hover .skeuo-card__nav { + opacity: 1; + pointer-events: auto; +} + +.skeuo-card__nav-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 3.3em; + height: 3.3em; + padding: 0.36em; + border-radius: 3.3em; + border: none; + background: rgba(0, 0, 0, 0.55); + color: #fff; + cursor: pointer; + transition: background 0.15s ease, opacity 0.15s ease; +} + +.skeuo-card__nav-button:hover:not([disabled]) { + background: rgba(0, 0, 0, 0.75); +} + +.skeuo-card__nav-button:disabled { + opacity: 0.4; + cursor: default; +} + +.skeuo-card__nav-button:focus-visible { + outline: 2px solid var(--accent, #2684ff); + outline-offset: 2px; +} + +.skeuo-card__nav-button svg { + width: 100%; + height: 100%; +} + .skeuo-item__tags .skeuo-tag.is-tear-pending { opacity: 0.4; } @@ -210,6 +263,7 @@ body.skeuo-cursor-remove * { justify-content: center; box-shadow: 0 24px 72px rgba(0, 0, 0, 0.24); overflow: hidden; + --nav-scale: 1; } .skeuo-item__card--empty { diff --git a/frontend/src/skeuomorphic_ws.jsx b/frontend/src/skeuomorphic_ws.jsx index 2a91643..cdc1d95 100644 --- a/frontend/src/skeuomorphic_ws.jsx +++ b/frontend/src/skeuomorphic_ws.jsx @@ -6,7 +6,9 @@ import React, { useRef, useState, } from 'react'; -import { resolveDocumentAssetUrl } from './asset_manager'; +import { resolveDocumentAssetUrl, createAssetView } from './asset_manager'; +import { useAssetNavigator } from './hooks/useAssetNavigator'; +import { ArrowLeftIcon, ArrowRightIcon } from './ui/icons'; import { getReadableTextColor } from './utils/colors'; import './skeuomorphic_ws.css'; @@ -43,6 +45,107 @@ const resolveTagKey = (tag) => { return key != null ? String(key) : null; }; +const SkeuoPreviewCard = ({ + doc, + title, + ensureAssetUrl, + getDocumentAsset, + navScale = 1, + prefetch = 3, +}) => { + const navigator = useAssetNavigator({ + document: doc, + assetType: 'preview', + ensureAssetUrl, + getAsset: getDocumentAsset, + prefetch, + }); + + const { currentUrl, cardinality, canGoPrev, canGoNext } = navigator; + const hasPreview = Boolean(currentUrl); + const cardClasses = ['skeuo-item__card']; + if (!hasPreview) cardClasses.push('skeuo-item__card--empty'); + const showNav = hasPreview && (cardinality > 1 || canGoPrev || canGoNext); + const navStyle = useMemo(() => ({ '--nav-scale': navScale }), [navScale]); + + return ( +
+ {hasPreview ? ( + {title} + ) : ( +
+
DOC
+
+ {title} +
+
+ )} + {showNav ? ( +
+ + +
+ ) : null} +
+ ); +}; + const generateInitialLayout = ( entries, { @@ -502,10 +605,10 @@ const SkeuomorphicWorkspace = ({ (doc) => { if (!doc) return null; const asset = resolvePreviewAsset(doc); - const primaryObject = asset?.objects?.[0] || null; - const primaryMetadata = primaryObject?.metadata || asset?.metadata || {}; - const width = primaryMetadata?.width; - const height = primaryMetadata?.height; + const view = createAssetView(asset); + const metadata = view.getPrimaryMetadata() || {}; + const width = metadata?.width; + const height = metadata?.height; if (typeof width === 'number' && typeof height === 'number') { return { width, height }; } @@ -514,17 +617,6 @@ const SkeuomorphicWorkspace = ({ [resolvePreviewAsset], ); - const resolvePreviewUrl = useCallback( - (doc) => { - if (!doc) return null; - return resolveDocumentAssetUrl(doc, 'preview', { - ensureAssetUrl, - getAsset: getDocumentAsset, - }); - }, - [ensureAssetUrl, getDocumentAsset], - ); - useEffect(() => { if (!ensureAssetUrl) { return; @@ -1491,9 +1583,6 @@ const SkeuomorphicWorkspace = ({ const totalScale = zoomScale > 0 ? zoomScale : 1; const inverseTagScale = totalScale > 0 ? 1 / totalScale : 1; const tagsStyle = { '--tag-scale': inverseTagScale }; - const previewUrl = resolvePreviewUrl(doc); - const imageUrl = previewUrl; - const hasPreview = Boolean(imageUrl); const title = doc.title || doc.original_name || 'Document'; const dragging = draggingId === doc.id; const tags = Array.isArray(doc.tags) ? doc.tags : []; @@ -1510,8 +1599,6 @@ const SkeuomorphicWorkspace = ({ if (dropActive) itemClasses.push('is-tag-target'); if (dropPending) itemClasses.push('is-tag-pending'); if (!matchesFilter) itemClasses.push('is-filtered-out'); - const cardClasses = ['skeuo-item__card']; - if (!hasPreview) cardClasses.push('skeuo-item__card--empty'); const docTagTokens = docTagKeys.join(' '); return (
-
- {hasPreview ? ( - {title} - ) : ( -
-
DOC
-
- {title} -
-
- )} -
+ {tags.length > 0 && (