frontend: multi-page assets
This commit is contained in:
+196
-21
@@ -17,7 +17,116 @@ export const getAssetFromVersion = (currentVersion, assetType) => {
|
|||||||
return getAssetFromGroup(currentVersion.assets, 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) {
|
if (!doc || !type) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -25,15 +134,32 @@ export const resolveDocumentAssetUrl = (doc, type, { ensureAssetUrl, getAsset, e
|
|||||||
if (!asset) {
|
if (!asset) {
|
||||||
return null;
|
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 now = Date.now();
|
||||||
const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null;
|
if (url && (!expiresAt || expiresAt > now)) {
|
||||||
const hasFreshUrl = asset.url && (!expiresAt || expiresAt > now);
|
return url;
|
||||||
if (hasFreshUrl) {
|
|
||||||
return asset.url;
|
|
||||||
}
|
}
|
||||||
if (doc.id && asset.id && typeof ensureAssetUrl === 'function') {
|
if (doc.id && asset.id && typeof ensureAssetUrl === 'function') {
|
||||||
const force = Boolean(asset.url && expiresAt && expiresAt <= now);
|
const force = Boolean(url && expiresAt && expiresAt <= now);
|
||||||
const options = ensureOptions ? { ...ensureOptions, force } : { force };
|
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(() => {});
|
ensureAssetUrl(doc.id, asset, options).catch(() => {});
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -63,6 +189,10 @@ class AssetManager {
|
|||||||
}
|
}
|
||||||
const cached = this.assetCache.get(asset.id);
|
const cached = this.assetCache.get(asset.id);
|
||||||
if (!cached) {
|
if (!cached) {
|
||||||
|
const normalized = mergeAssetObjects(null, asset.objects);
|
||||||
|
if (normalized.length) {
|
||||||
|
return { ...asset, objects: normalized };
|
||||||
|
}
|
||||||
return asset;
|
return asset;
|
||||||
}
|
}
|
||||||
const merged = { ...cached, ...asset };
|
const merged = { ...cached, ...asset };
|
||||||
@@ -76,6 +206,10 @@ class AssetManager {
|
|||||||
merged.expiresAt = cachedExpires;
|
merged.expiresAt = cachedExpires;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const mergedObjects = mergeAssetObjects(cached.objects, asset.objects);
|
||||||
|
if (mergedObjects.length) {
|
||||||
|
merged.objects = mergedObjects;
|
||||||
|
}
|
||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,16 +311,45 @@ class AssetManager {
|
|||||||
return Promise.resolve(asset || null);
|
return Promise.resolve(asset || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetExpiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null;
|
const requestedStart = Number.isInteger(start) && start > 0 ? start : 1;
|
||||||
if (!force && asset?.url && (!assetExpiresAt || assetExpiresAt > Date.now())) {
|
const requestedLimit = Number.isInteger(limit) && limit > 0 ? limit : 1;
|
||||||
this.rememberAsset(asset);
|
const requestedEnd = requestedStart + requestedLimit - 1;
|
||||||
return Promise.resolve(asset);
|
|
||||||
|
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);
|
if (!needsFetch) {
|
||||||
const now = Date.now();
|
this.rememberAsset(baseAsset);
|
||||||
if (!force && cached && cached.expiresAt && cached.expiresAt > now && cached.url) {
|
return Promise.resolve(baseAsset);
|
||||||
return Promise.resolve({ ...asset, ...cached });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const inflightKey = `${documentId}:${asset.id}:${start ?? 'd'}:${limit ?? 'd'}`;
|
const inflightKey = `${documentId}:${asset.id}:${start ?? 'd'}:${limit ?? 'd'}`;
|
||||||
@@ -211,19 +374,31 @@ class AssetManager {
|
|||||||
const request = this.api
|
const request = this.api
|
||||||
.get(`/assets/${asset.id}`, requestConfig)
|
.get(`/assets/${asset.id}`, requestConfig)
|
||||||
.then(({ data }) => {
|
.then(({ data }) => {
|
||||||
const objects = Array.isArray(data.objects) ? data.objects : [];
|
const incomingObjects = Array.isArray(data.objects) ? data.objects : [];
|
||||||
const primaryObject = objects[0] || null;
|
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'
|
const expiresAt = typeof primaryObject?.expires_at === 'number'
|
||||||
? primaryObject.expires_at
|
? primaryObject.expires_at
|
||||||
: Date.now() + this.assetPresignTtlMs;
|
: 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 = {
|
const entry = {
|
||||||
...asset,
|
...combined,
|
||||||
...data,
|
cardinality,
|
||||||
objects,
|
url: view.getPrimaryUrl(),
|
||||||
url: primaryObject?.url || null,
|
|
||||||
expiresAt,
|
expiresAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.rememberAsset(entry);
|
this.rememberAsset(entry);
|
||||||
return entry;
|
return entry;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
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 { getTagColorStyle } from '../utils/colors';
|
||||||
import { formatFileSize } from '../utils/format';
|
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';
|
import { CORRESPONDENT_ROLES } from '../constants/correspondents';
|
||||||
|
|
||||||
const MAX_PREVIEW_STACK_ITEMS = 15;
|
const MAX_PREVIEW_STACK_ITEMS = 15;
|
||||||
|
|
||||||
const normalizeRole = (role) => (role || '').toLowerCase();
|
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 formatRoleLabel = (role) => {
|
||||||
const normalized = normalizeRole(role);
|
const normalized = normalizeRole(role);
|
||||||
if (!normalized) return 'Other';
|
if (!normalized) return 'Other';
|
||||||
@@ -435,22 +445,34 @@ const DetailPanel = ({
|
|||||||
setOcrOpen(false);
|
setOcrOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const makePreviewItem = useCallback(
|
const singlePreviewNavigator = useAssetNavigator({
|
||||||
(doc) => {
|
document: singleDoc,
|
||||||
if (!doc) return null;
|
assetType: 'preview',
|
||||||
const url = resolveDocumentAssetUrl(doc, 'preview', {
|
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getAsset: getDocumentAsset,
|
getAsset: getDocumentAsset,
|
||||||
|
prefetch: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const makePreviewItem = useCallback(
|
||||||
|
(doc, ordinal = 1) => {
|
||||||
|
if (!doc) return null;
|
||||||
|
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) {
|
if (!url) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const asset = getDocumentAsset(doc, 'preview');
|
const metadata = object?.metadata || assetView.getPrimaryMetadata() || {};
|
||||||
const primaryObject = asset?.objects?.[0] || null;
|
const orientation = derivePreviewOrientation(metadata);
|
||||||
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';
|
|
||||||
return {
|
return {
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
url,
|
url,
|
||||||
@@ -477,32 +499,74 @@ const DetailPanel = ({
|
|||||||
return ordered;
|
return ordered;
|
||||||
}, [selectedDocuments]);
|
}, [selectedDocuments]);
|
||||||
|
|
||||||
const singlePreviewItems = useMemo(() => {
|
const stackTopDocument = stackDocuments[0] || null;
|
||||||
if (!singleDoc) return [];
|
const stackPreviewNavigator = useAssetNavigator({
|
||||||
const item = makePreviewItem(singleDoc);
|
document: stackTopDocument,
|
||||||
return item ? [item] : [];
|
assetType: 'preview',
|
||||||
}, [singleDoc, makePreviewItem]);
|
|
||||||
|
|
||||||
const stackPreviews = useMemo(
|
|
||||||
() =>
|
|
||||||
stackDocuments
|
|
||||||
.map((doc) => makePreviewItem(doc))
|
|
||||||
.filter(Boolean),
|
|
||||||
[stackDocuments, makePreviewItem],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ensureAssetUrl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
stackDocuments.forEach((doc) => {
|
|
||||||
resolveDocumentAssetUrl(doc, 'preview', {
|
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getAsset: getDocumentAsset,
|
getAsset: getDocumentAsset,
|
||||||
|
prefetch: 3,
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}, [stackDocuments, ensureAssetUrl, getDocumentAsset]);
|
const singlePreviewItems = useMemo(() => {
|
||||||
|
if (!singleDoc) 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]);
|
||||||
|
|
||||||
|
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(() => {
|
const bulkTagUnion = useMemo(() => {
|
||||||
if (!selectedDocuments.length) return [];
|
if (!selectedDocuments.length) return [];
|
||||||
@@ -661,9 +725,17 @@ const DetailPanel = ({
|
|||||||
const hasPageCount = Number.isFinite(pageCountValue) && pageCountValue >= 0;
|
const hasPageCount = Number.isFinite(pageCountValue) && pageCountValue >= 0;
|
||||||
const metadata =
|
const metadata =
|
||||||
singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
|
||||||
<div className="preview-pane preview-pane--stack">
|
<div className="preview-pane preview-pane--stack">
|
||||||
<PreviewStack
|
<PreviewStack
|
||||||
items={singlePreviewItems}
|
items={singlePreviewItems}
|
||||||
@@ -673,7 +745,44 @@ const DetailPanel = ({
|
|||||||
onOpenPreview={onOpenPreview}
|
onOpenPreview={onOpenPreview}
|
||||||
activeItemId={activePreviewId}
|
activeItemId={activePreviewId}
|
||||||
/>
|
/>
|
||||||
|
{hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? (
|
||||||
|
<div className="preview-pane__nav preview-pane__nav--overlay">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="preview-pane__nav-button preview-pane__nav-button--prev"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
singlePreviewNavigator.goPrev();
|
||||||
|
}}
|
||||||
|
onPointerDown={interceptNavPointer}
|
||||||
|
onPointerUp={interceptNavPointer}
|
||||||
|
onMouseDown={interceptNavPointer}
|
||||||
|
onMouseUp={interceptNavPointer}
|
||||||
|
disabled={!canGoPrev}
|
||||||
|
aria-label="Previous preview"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="preview-pane__nav-button preview-pane__nav-button--next"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
singlePreviewNavigator.goNext();
|
||||||
|
}}
|
||||||
|
onPointerDown={interceptNavPointer}
|
||||||
|
onPointerUp={interceptNavPointer}
|
||||||
|
onMouseDown={interceptNavPointer}
|
||||||
|
onMouseUp={interceptNavPointer}
|
||||||
|
disabled={!canGoNext}
|
||||||
|
aria-label="Next preview"
|
||||||
|
>
|
||||||
|
<ArrowRightIcon />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="doc-title-row">
|
<div className="doc-title-row">
|
||||||
{isEditingTitle ? (
|
{isEditingTitle ? (
|
||||||
@@ -873,10 +982,20 @@ const DetailPanel = ({
|
|||||||
const renderBulk = () => {
|
const renderBulk = () => {
|
||||||
const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
||||||
const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—';
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
|
||||||
<div className="preview-pane preview-pane--stack">
|
<div className="preview-pane preview-pane--stack">
|
||||||
<PreviewStack
|
<PreviewStack
|
||||||
items={stackPreviews}
|
items={stackPreviews}
|
||||||
@@ -885,7 +1004,44 @@ const DetailPanel = ({
|
|||||||
onOpenPreview={onOpenPreview}
|
onOpenPreview={onOpenPreview}
|
||||||
activeItemId={activePreviewId}
|
activeItemId={activePreviewId}
|
||||||
/>
|
/>
|
||||||
|
{topDocIdLocal && topHasPreview && (topCardinalityLocal > 1 || topCanGoPrev || topCanGoNext) ? (
|
||||||
|
<div className="preview-pane__nav preview-pane__nav--overlay">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="preview-pane__nav-button preview-pane__nav-button--prev"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
stackPreviewNavigator.goPrev();
|
||||||
|
}}
|
||||||
|
onPointerDown={interceptTopNavPointer}
|
||||||
|
onPointerUp={interceptTopNavPointer}
|
||||||
|
onMouseDown={interceptTopNavPointer}
|
||||||
|
onMouseUp={interceptTopNavPointer}
|
||||||
|
disabled={!topCanGoPrev}
|
||||||
|
aria-label="Previous preview"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="preview-pane__nav-button preview-pane__nav-button--next"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
stackPreviewNavigator.goNext();
|
||||||
|
}}
|
||||||
|
onPointerDown={interceptTopNavPointer}
|
||||||
|
onPointerUp={interceptTopNavPointer}
|
||||||
|
onMouseDown={interceptTopNavPointer}
|
||||||
|
onMouseUp={interceptTopNavPointer}
|
||||||
|
disabled={!topCanGoNext}
|
||||||
|
aria-label="Next preview"
|
||||||
|
>
|
||||||
|
<ArrowRightIcon />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<h3 style={{ margin: 0 }}>{countLabel}</h3>
|
<h3 style={{ margin: 0 }}>{countLabel}</h3>
|
||||||
<div className="meta">
|
<div className="meta">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
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 { getTagColorStyle } from '../utils/colors';
|
||||||
import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon } from '../ui/icons';
|
import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon } from '../ui/icons';
|
||||||
|
|
||||||
@@ -21,9 +21,12 @@ const DocumentThumbnailImage = ({
|
|||||||
maxSize = LIST_ICON_SIZE,
|
maxSize = LIST_ICON_SIZE,
|
||||||
}) => {
|
}) => {
|
||||||
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
|
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
|
||||||
const thumbnailAsset = useMemo(() => getAssetFromVersion(document?.current_version, 'thumbnail'), [document?.current_version]);
|
const thumbnailAsset = useMemo(
|
||||||
const primaryObject = thumbnailAsset?.objects?.[0] || null;
|
() => getAssetFromVersion(document?.current_version, 'thumbnail'),
|
||||||
const primaryMetadata = primaryObject?.metadata || thumbnailAsset?.metadata || {};
|
[document?.current_version],
|
||||||
|
);
|
||||||
|
const thumbnailView = useMemo(() => createAssetView(thumbnailAsset), [thumbnailAsset]);
|
||||||
|
const primaryMetadata = thumbnailView.getPrimaryMetadata() || {};
|
||||||
const assetWidth = Number(primaryMetadata?.width);
|
const assetWidth = Number(primaryMetadata?.width);
|
||||||
const assetHeight = Number(primaryMetadata?.height);
|
const assetHeight = Number(primaryMetadata?.height);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
+31
-6
@@ -21,7 +21,7 @@ import {
|
|||||||
matchPath,
|
matchPath,
|
||||||
} from 'react-router-dom';
|
} from 'react-router-dom';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl } from './asset_manager';
|
import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from './asset_manager';
|
||||||
import useApiError from './hooks/useApiError';
|
import useApiError from './hooks/useApiError';
|
||||||
import SkeuomorphicWorkspace from './skeuomorphic_ws';
|
import SkeuomorphicWorkspace from './skeuomorphic_ws';
|
||||||
import DetailPanel from './detail/DetailPanel';
|
import DetailPanel from './detail/DetailPanel';
|
||||||
@@ -413,8 +413,27 @@ const AppLayout = () => {
|
|||||||
|
|
||||||
const isAssetEquivalent = (lhs, rhs) => {
|
const isAssetEquivalent = (lhs, rhs) => {
|
||||||
if (!lhs || !rhs) return false;
|
if (!lhs || !rhs) return false;
|
||||||
const lhsPrimaryMetadata = lhs?.objects?.[0]?.metadata || lhs?.metadata;
|
const lhsView = createAssetView(lhs);
|
||||||
const rhsPrimaryMetadata = rhs?.objects?.[0]?.metadata || rhs?.metadata;
|
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 (
|
return (
|
||||||
lhs.id === rhs.id &&
|
lhs.id === rhs.id &&
|
||||||
lhs.url === rhs.url &&
|
lhs.url === rhs.url &&
|
||||||
@@ -422,7 +441,9 @@ const AppLayout = () => {
|
|||||||
lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height &&
|
lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height &&
|
||||||
lhs.mime_type === rhs.mime_type &&
|
lhs.mime_type === rhs.mime_type &&
|
||||||
lhs.asset_type === rhs.asset_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(
|
const ensureAssetUrl = useCallback(
|
||||||
async (documentId, asset, { force = false } = {}) => {
|
async (documentId, asset, { force = false, start = null, limit = null } = {}) => {
|
||||||
if (!documentId || !asset?.id) {
|
if (!documentId || !asset?.id) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entry = await assetManager.ensureAsset(documentId, asset, { force });
|
const entry = await assetManager.ensureAsset(documentId, asset, {
|
||||||
|
force,
|
||||||
|
start,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -189,6 +189,59 @@
|
|||||||
user-select: none;
|
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 {
|
.skeuo-item__tags .skeuo-tag.is-tear-pending {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
@@ -210,6 +263,7 @@ body.skeuo-cursor-remove * {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.24);
|
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.24);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
--nav-scale: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-item__card--empty {
|
.skeuo-item__card--empty {
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} 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 { getReadableTextColor } from './utils/colors';
|
||||||
import './skeuomorphic_ws.css';
|
import './skeuomorphic_ws.css';
|
||||||
|
|
||||||
@@ -43,6 +45,107 @@ const resolveTagKey = (tag) => {
|
|||||||
return key != null ? String(key) : null;
|
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 (
|
||||||
|
<div className={cardClasses.join(' ')}>
|
||||||
|
{hasPreview ? (
|
||||||
|
<img src={currentUrl} alt={title} />
|
||||||
|
) : (
|
||||||
|
<div className="skeuo-item__empty">
|
||||||
|
<div className="skeuo-item__placeholder">DOC</div>
|
||||||
|
<div className="skeuo-item__title" title={title}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showNav ? (
|
||||||
|
<div className="skeuo-card__nav" style={navStyle}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="skeuo-card__nav-button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
navigator.goPrev();
|
||||||
|
}}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onPointerUp={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onMouseUp={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
disabled={!canGoPrev}
|
||||||
|
aria-label="Previous preview"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="skeuo-card__nav-button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
navigator.goNext();
|
||||||
|
}}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onPointerUp={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onMouseUp={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
disabled={!canGoNext}
|
||||||
|
aria-label="Next preview"
|
||||||
|
>
|
||||||
|
<ArrowRightIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const generateInitialLayout = (
|
const generateInitialLayout = (
|
||||||
entries,
|
entries,
|
||||||
{
|
{
|
||||||
@@ -502,10 +605,10 @@ const SkeuomorphicWorkspace = ({
|
|||||||
(doc) => {
|
(doc) => {
|
||||||
if (!doc) return null;
|
if (!doc) return null;
|
||||||
const asset = resolvePreviewAsset(doc);
|
const asset = resolvePreviewAsset(doc);
|
||||||
const primaryObject = asset?.objects?.[0] || null;
|
const view = createAssetView(asset);
|
||||||
const primaryMetadata = primaryObject?.metadata || asset?.metadata || {};
|
const metadata = view.getPrimaryMetadata() || {};
|
||||||
const width = primaryMetadata?.width;
|
const width = metadata?.width;
|
||||||
const height = primaryMetadata?.height;
|
const height = metadata?.height;
|
||||||
if (typeof width === 'number' && typeof height === 'number') {
|
if (typeof width === 'number' && typeof height === 'number') {
|
||||||
return { width, height };
|
return { width, height };
|
||||||
}
|
}
|
||||||
@@ -514,17 +617,6 @@ const SkeuomorphicWorkspace = ({
|
|||||||
[resolvePreviewAsset],
|
[resolvePreviewAsset],
|
||||||
);
|
);
|
||||||
|
|
||||||
const resolvePreviewUrl = useCallback(
|
|
||||||
(doc) => {
|
|
||||||
if (!doc) return null;
|
|
||||||
return resolveDocumentAssetUrl(doc, 'preview', {
|
|
||||||
ensureAssetUrl,
|
|
||||||
getAsset: getDocumentAsset,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[ensureAssetUrl, getDocumentAsset],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ensureAssetUrl) {
|
if (!ensureAssetUrl) {
|
||||||
return;
|
return;
|
||||||
@@ -1491,9 +1583,6 @@ const SkeuomorphicWorkspace = ({
|
|||||||
const totalScale = zoomScale > 0 ? zoomScale : 1;
|
const totalScale = zoomScale > 0 ? zoomScale : 1;
|
||||||
const inverseTagScale = totalScale > 0 ? 1 / totalScale : 1;
|
const inverseTagScale = totalScale > 0 ? 1 / totalScale : 1;
|
||||||
const tagsStyle = { '--tag-scale': inverseTagScale };
|
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 title = doc.title || doc.original_name || 'Document';
|
||||||
const dragging = draggingId === doc.id;
|
const dragging = draggingId === doc.id;
|
||||||
const tags = Array.isArray(doc.tags) ? doc.tags : [];
|
const tags = Array.isArray(doc.tags) ? doc.tags : [];
|
||||||
@@ -1510,8 +1599,6 @@ const SkeuomorphicWorkspace = ({
|
|||||||
if (dropActive) itemClasses.push('is-tag-target');
|
if (dropActive) itemClasses.push('is-tag-target');
|
||||||
if (dropPending) itemClasses.push('is-tag-pending');
|
if (dropPending) itemClasses.push('is-tag-pending');
|
||||||
if (!matchesFilter) itemClasses.push('is-filtered-out');
|
if (!matchesFilter) itemClasses.push('is-filtered-out');
|
||||||
const cardClasses = ['skeuo-item__card'];
|
|
||||||
if (!hasPreview) cardClasses.push('skeuo-item__card--empty');
|
|
||||||
const docTagTokens = docTagKeys.join(' ');
|
const docTagTokens = docTagKeys.join(' ');
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -1547,18 +1634,13 @@ const SkeuomorphicWorkspace = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="skeuo-item__body" style={bodyStyle}>
|
<div className="skeuo-item__body" style={bodyStyle}>
|
||||||
<div className={cardClasses.join(' ')}>
|
<SkeuoPreviewCard
|
||||||
{hasPreview ? (
|
doc={doc}
|
||||||
<img src={imageUrl} alt={title} />
|
title={title}
|
||||||
) : (
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
<div className="skeuo-item__empty">
|
getDocumentAsset={getDocumentAsset}
|
||||||
<div className="skeuo-item__placeholder">DOC</div>
|
navScale={inverseTagScale}
|
||||||
<div className="skeuo-item__title" title={title}>
|
/>
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{tags.length > 0 && (
|
{tags.length > 0 && (
|
||||||
<div className="skeuo-item__tags" aria-hidden="true" style={tagsStyle}>
|
<div className="skeuo-item__tags" aria-hidden="true" style={tagsStyle}>
|
||||||
{tags.map((tag) => {
|
{tags.map((tag) => {
|
||||||
|
|||||||
+73
-4
@@ -393,7 +393,7 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
.documents-main {
|
.documents-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 20rem minmax(0, 1fr) 20rem;
|
grid-template-columns: 20rem minmax(0, 1fr) 30rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -1590,6 +1590,7 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumbnail-preview {
|
.thumbnail-preview {
|
||||||
@@ -1672,7 +1673,7 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
|
|
||||||
.preview-stack--stacked {
|
.preview-stack--stacked {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 320px;
|
min-height: 420px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1728,11 +1729,75 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 0 0 999px var(--accent-elevated),
|
inset 0 0 0 999px var(--accent-elevated),
|
||||||
0 6px 18px var(--accent-elevated-strong);
|
0 6px 18px var(--accent-elevated-strong);
|
||||||
filter: saturate(118%) brightness(1.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-pane--stack {
|
.preview-pane--stack {
|
||||||
min-height: 260px;
|
min-height: 460px;
|
||||||
|
position: relative;
|
||||||
|
--preview-nav-scale: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane__nav-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.2em;
|
||||||
|
height: 2.2em;
|
||||||
|
padding: 0.45em;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, opacity 0.15s ease;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane__nav-button:hover:not([disabled]) {
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane__nav-button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane__nav-button:focus-visible {
|
||||||
|
outline: 2px solid var(--accent, #2684ff);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane__nav {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane__nav--overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) scale(var(--preview-nav-scale, 1));
|
||||||
|
margin-top: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 20;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane__nav--overlay .preview-pane__nav-button {
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: scale(calc(1 / var(--preview-nav-scale, 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane--stack:hover .preview-pane__nav--overlay,
|
||||||
|
.skeuo-item__card:hover .preview-pane__nav--overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-card__nav {
|
||||||
|
bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bulk-tags {
|
.bulk-tags {
|
||||||
@@ -2023,3 +2088,7 @@ form.inline {
|
|||||||
width: 0.9rem;
|
width: 0.9rem;
|
||||||
height: 0.9rem;
|
height: 0.9rem;
|
||||||
}
|
}
|
||||||
|
.preview-pane__nav-button svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
IconTrash,
|
IconTrash,
|
||||||
IconLayoutList,
|
IconLayoutList,
|
||||||
IconLayoutGrid,
|
IconLayoutGrid,
|
||||||
|
IconArrowLeft,
|
||||||
|
IconArrowRight,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import FolderSvg from '../assets/folder.svg';
|
import FolderSvg from '../assets/folder.svg';
|
||||||
|
|
||||||
@@ -100,6 +102,24 @@ export const ViewGridIcon = ({ className, size = '1em', stroke = 1.6, ...rest })
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const ArrowLeftIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconArrowLeft
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ArrowRightIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconArrowRight
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
ChevronIcon,
|
ChevronIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
|
|||||||
Reference in New Issue
Block a user