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);
|
||||
};
|
||||
|
||||
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;
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div>
|
||||
<div className="preview-pane preview-pane--stack">
|
||||
<PreviewStack
|
||||
items={singlePreviewItems}
|
||||
maxItems={1}
|
||||
emptyMessage="Preview loading…"
|
||||
onItemActivate={handlePreviewActivate}
|
||||
onOpenPreview={onOpenPreview}
|
||||
activeItemId={activePreviewId}
|
||||
/>
|
||||
</div>
|
||||
<div className="preview-pane preview-pane--stack">
|
||||
<PreviewStack
|
||||
items={singlePreviewItems}
|
||||
maxItems={1}
|
||||
emptyMessage="Preview loading…"
|
||||
onItemActivate={handlePreviewActivate}
|
||||
onOpenPreview={onOpenPreview}
|
||||
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>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="doc-title-row">
|
||||
{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 (
|
||||
<>
|
||||
<div>
|
||||
<div className="preview-pane preview-pane--stack">
|
||||
<PreviewStack
|
||||
items={stackPreviews}
|
||||
emptyMessage="No previews available."
|
||||
onItemActivate={handlePreviewActivate}
|
||||
onOpenPreview={onOpenPreview}
|
||||
activeItemId={activePreviewId}
|
||||
/>
|
||||
</div>
|
||||
<div className="preview-pane preview-pane--stack">
|
||||
<PreviewStack
|
||||
items={stackPreviews}
|
||||
emptyMessage="No previews available."
|
||||
onItemActivate={handlePreviewActivate}
|
||||
onOpenPreview={onOpenPreview}
|
||||
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>
|
||||
) : null}
|
||||
</div>
|
||||
<h3 style={{ margin: 0 }}>{countLabel}</h3>
|
||||
<div className="meta">
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
} 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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<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 = (
|
||||
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 (
|
||||
<div
|
||||
@@ -1547,18 +1634,13 @@ const SkeuomorphicWorkspace = ({
|
||||
}}
|
||||
>
|
||||
<div className="skeuo-item__body" style={bodyStyle}>
|
||||
<div className={cardClasses.join(' ')}>
|
||||
{hasPreview ? (
|
||||
<img src={imageUrl} alt={title} />
|
||||
) : (
|
||||
<div className="skeuo-item__empty">
|
||||
<div className="skeuo-item__placeholder">DOC</div>
|
||||
<div className="skeuo-item__title" title={title}>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SkeuoPreviewCard
|
||||
doc={doc}
|
||||
title={title}
|
||||
ensureAssetUrl={ensureAssetUrl}
|
||||
getDocumentAsset={getDocumentAsset}
|
||||
navScale={inverseTagScale}
|
||||
/>
|
||||
{tags.length > 0 && (
|
||||
<div className="skeuo-item__tags" aria-hidden="true" style={tagsStyle}>
|
||||
{tags.map((tag) => {
|
||||
|
||||
+73
-4
@@ -393,7 +393,7 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
.documents-main {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 20rem minmax(0, 1fr) 20rem;
|
||||
grid-template-columns: 20rem minmax(0, 1fr) 30rem;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1590,6 +1590,7 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.thumbnail-preview {
|
||||
@@ -1672,7 +1673,7 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
|
||||
.preview-stack--stacked {
|
||||
width: 100%;
|
||||
min-height: 320px;
|
||||
min-height: 420px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1728,11 +1729,75 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
box-shadow:
|
||||
inset 0 0 0 999px var(--accent-elevated),
|
||||
0 6px 18px var(--accent-elevated-strong);
|
||||
filter: saturate(118%) brightness(1.05);
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -2023,3 +2088,7 @@ form.inline {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
}
|
||||
.preview-pane__nav-button svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
IconTrash,
|
||||
IconLayoutList,
|
||||
IconLayoutGrid,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
} from '@tabler/icons-react';
|
||||
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 {
|
||||
ChevronIcon,
|
||||
TrashIcon,
|
||||
|
||||
Reference in New Issue
Block a user