This commit is contained in:
2025-12-04 00:09:32 +01:00
parent c6a627af8a
commit 4b02d1c7d5
9 changed files with 53 additions and 280 deletions
+17 -117
View File
@@ -1,10 +1,10 @@
import type { Identifier } from './types/identifiers';
import type { AssetObject, AssetLike } from './types/assets';
import type { Asset } from './types/assets';
import type { DocumentVersion, Document } from './types/documents';
type Nullable<T> = T | null;
export type { AssetObject, AssetLike, DocumentVersion as DocumentVersionLike, Document };
export type { Asset, DocumentVersion as DocumentVersionLike, Document };
export const resolveAssetExpiresAt = (asset?: { download?: { expires_at: number } | null } | null): number | null =>
asset?.download?.expires_at ?? null;
@@ -14,16 +14,16 @@ export const resolveAssetUrl = (asset?: { download?: { url: string } | null } |
export type EnsureAssetUrl = (
documentId: Identifier,
asset: AssetLike,
asset: Asset,
options?: { force?: boolean;[key: string]: unknown },
) => Promise<unknown>;
export type GetAsset = (document: Document, assetType: string) => Nullable<AssetLike>;
export type GetAsset = (document: Document, assetType: string) => Nullable<Asset>;
export const getAssetFromGroup = (
assets?: AssetLike[] | Record<string, AssetLike> | null,
assets?: Asset[] | Record<string, Asset> | null,
assetType: string = '',
): Nullable<AssetLike> => {
): Nullable<Asset> => {
if (!assetType || !assets) {
return null;
}
@@ -42,97 +42,7 @@ export const getAssetFromVersion = (currentVersion: Nullable<DocumentVersion>, a
return getAssetFromGroup(currentVersion.assets, assetType);
};
const normalizeAssetObjects = (objects?: AssetObject[] | null): AssetObject[] => {
if (!Array.isArray(objects)) {
return [];
}
return objects
.filter((entry) => Number.isInteger(entry?.ordinal))
.slice()
.sort((a, b) => a.ordinal - b.ordinal);
};
export class AssetView {
asset: AssetLike | null;
private _objectsRef: AssetObject[] | null;
private _sortedObjects: AssetObject[];
constructor(asset?: AssetLike | null) {
this.asset = asset || null;
this._objectsRef = null;
this._sortedObjects = [];
}
getCardinality(): number {
if (!this.asset) {
return 0;
}
const objectsCount = this.getObjects().length;
if (objectsCount > 0) {
return objectsCount;
}
if (this.asset.url || this.asset.metadata) {
return 1;
}
return 0;
}
getObjects(): AssetObject[] {
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): AssetObject | null {
const fromObjects = this.getObjects().find((entry) => entry.ordinal === ordinal);
if (fromObjects) {
return fromObjects;
}
if (ordinal === 1 && this.asset) {
const primaryUrl = resolveAssetUrl(this.asset);
if (primaryUrl || this.asset.metadata) {
return {
ordinal: 1,
url: primaryUrl || null,
metadata: this.asset.metadata || null,
expires_at: resolveAssetExpiresAt(this.asset),
};
}
}
return null;
}
getPrimaryObject(): AssetObject | null {
return this.getObject(1);
}
getPrimaryMetadata(): Record<string, unknown> | null {
return this.getPrimaryObject()?.metadata || null;
}
getPrimaryUrl(): string | null {
return this.getPrimaryObject()?.url || null;
}
hasObject(ordinal: number): boolean {
return Boolean(this.getObject(ordinal));
}
}
export const createAssetView = (asset?: AssetLike | null): AssetView => new AssetView(asset);
export const resolveDocumentAssetUrl = (
doc: Nullable<Document>,
@@ -154,10 +64,8 @@ export const resolveDocumentAssetUrl = (
if (!asset) {
return null;
}
const view = createAssetView(asset);
const object = view.getPrimaryObject();
const url = object?.url || view.getPrimaryUrl();
const expiresAt = object?.expires_at ?? resolveAssetExpiresAt(asset);
const url = resolveAssetUrl(asset);
const expiresAt = resolveAssetExpiresAt(asset);
const now = Date.now();
if (url && (!expiresAt || expiresAt > now)) {
return url;
@@ -174,22 +82,22 @@ export const resolveDocumentAssetUrl = (
};
class AssetManager {
fetchAsset: ((id: Identifier) => Promise<AssetLike | null>) | null;
fetchAsset: ((id: Identifier) => Promise<Asset | null>) | null;
assetCache: Map<Identifier, AssetLike>;
assetInflight: Map<string, Promise<AssetLike | null>>;
assetCache: Map<Identifier, Asset>;
assetInflight: Map<string, Promise<Asset | null>>;
constructor({ fetchAsset }: { fetchAsset: ((id: Identifier) => Promise<AssetLike | null>) | null }) {
constructor({ fetchAsset }: { fetchAsset: ((id: Identifier) => Promise<Asset | null>) | null }) {
this.fetchAsset = fetchAsset;
this.assetCache = new Map();
this.assetInflight = new Map();
}
setFetchAsset(fetchAsset: ((id: Identifier) => Promise<AssetLike | null>) | null) {
setFetchAsset(fetchAsset: ((id: Identifier) => Promise<Asset | null>) | null) {
this.fetchAsset = fetchAsset;
}
rememberAsset(entry?: Nullable<AssetLike>) {
rememberAsset(entry?: Nullable<Asset>) {
if (entry?.id) {
this.assetCache.set(entry.id, entry);
}
@@ -197,26 +105,18 @@ class AssetManager {
ensureAsset(
documentId?: Identifier | null,
asset?: Nullable<AssetLike>,
asset?: Nullable<Asset>,
{ force = false }: { force?: boolean } = {},
): Promise<Nullable<AssetLike>> {
): Promise<Nullable<Asset>> {
if (!documentId || !asset?.id) {
return Promise.resolve(asset);
}
const baseAsset = this.assetCache.get(asset.id) || asset;
const view = createAssetView(baseAsset);
const assetExpiresAt = resolveAssetExpiresAt(baseAsset);
const now = Date.now();
const isPrimarySatisfied = () => {
const object = view.getObject(1);
if (object?.url) {
const objectExpiresAt = object.expires_at ?? null;
if (!objectExpiresAt || objectExpiresAt > now) {
return true;
}
}
const assetUrl = resolveAssetUrl(baseAsset);
if (assetUrl && (!assetExpiresAt || assetExpiresAt > now)) {
return true;
@@ -243,7 +143,7 @@ class AssetManager {
return Promise.reject(new Error('AssetManager fetcher is not configured.'));
}
const request: Promise<AssetLike | null> = this.fetchAsset(asset.id)
const request: Promise<Asset | null> = this.fetchAsset(asset.id)
.then((data) => {
if (!data) return null;
const cachedEntry = this.assetCache.get(asset.id) || baseAsset;
+17 -18
View File
@@ -1,24 +1,18 @@
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import type { JSX } from 'react';
import { useAssetNavigator } from '../hooks/useAssetNavigator';
import { resolveDocumentAssetUrl } from '../asset_manager';
import type { Identifier } from '../types/identifiers';
import type { Document } from '../types/documents';
interface AssetLike {
id?: Identifier;
url?: string | null;
expires_at?: number | null;
metadata?: Record<string, unknown> | null;
[key: string]: unknown;
}
import type { Asset } from '../types/assets';
type EnsureAssetUrl = (
documentId: Identifier,
asset: AssetLike,
asset: Asset,
options?: { force?: boolean;[key: string]: unknown },
) => Promise<unknown>;
type GetDocumentAsset = (document: Document | null, assetType: string) => AssetLike | null;
type GetDocumentAsset = (document: Document | null, assetType: string) => Asset | null;
interface NavigatorSnapshot {
url: string | null;
@@ -44,14 +38,19 @@ const DesktopPreviewCard = ({
onNavigatorSnapshot,
shouldLoad = true,
}: DesktopPreviewCardProps): JSX.Element => {
const navigator = useAssetNavigator({
document: doc,
assetType: 'thumbnail',
ensureAssetUrl: shouldLoad ? ensureAssetUrl : null,
getAsset: getDocumentAsset,
});
const currentUrl = useMemo(() => {
if (!doc) return null;
return resolveDocumentAssetUrl(doc, 'thumbnail', {
ensureAssetUrl: shouldLoad && ensureAssetUrl ? ensureAssetUrl : undefined,
getAsset: getDocumentAsset,
});
}, [doc, ensureAssetUrl, getDocumentAsset, shouldLoad]);
const { currentUrl, currentMetadata } = navigator;
const currentMetadata = useMemo(() => {
if (!doc || !getDocumentAsset) return null;
const asset = getDocumentAsset(doc, 'thumbnail');
return asset?.metadata || null;
}, [doc, getDocumentAsset]);
const docId = doc?.id ?? null;
const metadataWidth = Number((currentMetadata as { width?: number } | null)?.width);
@@ -2,10 +2,7 @@ import { useEffect, useState, useRef } from 'react';
import type { DocumentId } from '../../types/identifiers';
import type { Document } from '../../types/documents';
interface AssetLike {
id?: string;
[key: string]: unknown;
}
import type { Asset } from '../../types/assets';
interface PreviewMetadataEntry {
docId: DocumentId;
@@ -13,8 +10,8 @@ interface PreviewMetadataEntry {
height: number;
}
type GetDocumentAsset = (doc: Document, type: string) => AssetLike | null;
type EnsureAssetUrl = (docId: DocumentId, asset: AssetLike, options?: { force?: boolean }) => Promise<AssetLike | null>;
type GetDocumentAsset = (doc: Document, type: string) => Asset | null;
type EnsureAssetUrl = (docId: DocumentId, asset: Asset, options?: { force?: boolean }) => Promise<Asset | null>;
const usePreviewMetadata = (
documents: Document[] | null,
@@ -8,7 +8,7 @@ import {
} from '../asset_manager';
import { DEFAULT_THUMBNAIL_SIZE } from '../constants/documents';
import type {
AssetLike as AssetManagerAssetLike,
Asset as AssetManagerAsset,
EnsureAssetUrl as AssetManagerEnsureAssetUrl,
GetAsset as AssetManagerGetAsset,
} from '../asset_manager';
@@ -72,7 +72,7 @@ const getPageCount = (doc?: Document | null) => {
return Number.isFinite(count) ? Number(count) : null;
};
type AssetLike = AssetManagerAssetLike;
type Asset = AssetManagerAsset;
type EnsureAssetUrl = AssetManagerEnsureAssetUrl;
type GetDocumentAsset = AssetManagerGetAsset;
@@ -97,7 +97,7 @@ const DocumentThumbnailImage = ({
const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, documentId);
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
const thumbnailAsset = useMemo<AssetLike | null>(
const thumbnailAsset = useMemo<Asset | null>(
() => getAssetFromVersion(document?.current_version, 'thumbnail'),
[document?.current_version],
);
-113
View File
@@ -1,113 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { resolveAssetUrl } from '../asset_manager';
import type { Identifier } from '../types/identifiers';
import type { Document } from '../types/documents';
type AssetObject = {
url?: string | null;
metadata?: Record<string, unknown> | null;
expires_at?: number | null;
[key: string]: unknown;
};
type AssetLike = {
id?: Identifier;
url?: string | null;
expires_at?: number | null;
metadata?: Record<string, unknown> | null;
objects?: AssetObject[];
[key: string]: unknown;
};
type EnsureAssetUrl = (
documentId: Identifier,
asset: AssetLike,
options?: { force?: boolean;[key: string]: unknown },
) => Promise<unknown>;
type GetAsset = (document: Document, assetType: string) => AssetLike | null;
type AssetViewLike = {
url: string | null;
metadata: Record<string, unknown> | null;
};
interface UseAssetNavigatorOptions {
document?: Document | null;
assetType: string;
ensureAssetUrl?: EnsureAssetUrl | null;
getAsset?: GetAsset;
}
interface AssetNavigatorReturn {
document: Document | null;
documentId: Identifier | null;
asset: AssetLike | null;
assetType: string;
currentUrl: string | null;
currentMetadata: Record<string, unknown> | null;
isLoading: boolean;
}
export const useAssetNavigator = ({
document,
assetType,
ensureAssetUrl,
getAsset,
}: UseAssetNavigatorOptions): AssetNavigatorReturn => {
const documentId = (document?.id ?? null) as Identifier | null;
const asset = useMemo<AssetLike | null>(() => {
if (!document || !getAsset) {
return null;
}
return getAsset(document, assetType) || null;
}, [document, assetType, getAsset]);
const view = useMemo<AssetViewLike>(
() => ({
url: resolveAssetUrl(asset),
metadata: (asset?.metadata as Record<string, unknown> | null) || null,
}),
[asset],
);
const currentUrl = view.url || null;
const currentMetadata = view.metadata || null;
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!ensureAssetUrl || !documentId || !asset) {
return undefined;
}
if (currentUrl) {
return undefined;
}
let cancelled = false;
setIsLoading(true);
ensureAssetUrl(documentId, asset, { force: true })
.catch(() => { })
.finally(() => {
if (!cancelled) {
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [asset, currentUrl, documentId, ensureAssetUrl]);
return {
document,
documentId,
asset,
assetType,
currentUrl,
currentMetadata,
isLoading,
};
};
export default useAssetNavigator;
+4 -3
View File
@@ -27,14 +27,15 @@ import useViewerLayoutMode from './useViewerLayoutMode';
import { usePanelResizeBindings } from '../app/PanelManagerContext';
import type { DocumentId, FolderId } from '../types/identifiers';
import type { Document } from '../types/documents';
import type { AssetLike } from '../types/assets';
import type { Asset } from '../types/assets';
type SidebarMode = 'overlay' | 'inline';
interface DocumentViewerPanelProps extends DocumentSummarySectionProps {
document: Document | null;
ensureAssetUrl?: (docId: DocumentId, asset: AssetLike, options?: { force?: boolean }) => Promise<unknown>;
getDocumentAsset?: (doc: Document | null, type: string) => AssetLike | null;
ensureAssetUrl?: (documentId: DocumentId, asset: Asset, options?: { force?: boolean }) => Promise<unknown>;
getDocumentAsset?: (doc: Document | null, type: string) => Asset | null;
ensurePreviewData?: (docId: DocumentId, options?: { signal?: AbortSignal }) => Promise<Document | null>;
notifyApiError?: (error: unknown, fallbackMessage?: string) => void;
sidebarToggle?: ReactNode;
+1 -12
View File
@@ -1,21 +1,10 @@
import type { Identifier } from './identifiers';
import type { Download } from './common';
export interface AssetObject {
ordinal?: number;
url?: string | null;
metadata?: Record<string, unknown> | null;
expires_at?: number;
[key: string]: unknown;
}
export interface AssetLike {
export interface Asset {
id?: Identifier;
asset_type?: string;
cardinality?: number | null;
download?: Download | null;
metadata?: Record<string, unknown> | null;
assets?: Record<string, AssetLike> | AssetLike[] | null;
objects?: AssetObject[] | null;
[key: string]: unknown;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import type { Identifier } from './identifiers';
import type { AssetLike } from './assets';
import type { Asset } from './assets';
import type { Download } from './common';
export interface DocumentTag {
@@ -15,7 +15,7 @@ export interface DocumentCorrespondent {
}
export interface DocumentVersion {
assets?: Record<string, AssetLike> | AssetLike[] | null;
assets?: Record<string, Asset> | Asset[] | null;
metadata?: Record<string, unknown> & { page_count?: number } | null;
size_bytes?: number | string | null;
checksum?: string | null;
+6 -6
View File
@@ -6,16 +6,16 @@ import type {
Document,
DocumentVersion,
} from '../types/documents';
import type { AssetLike } from '../types/assets';
import type { Asset } from '../types/assets';
export type { Document, DocumentVersion, AssetLike };
export type { Document, DocumentVersion, Asset };
export type EnsurePreviewData = (id: string) => Promise<Document | null>;
export type EnsureAssetUrl = (
id: string,
asset: AssetLike,
asset: Asset,
options?: { force?: boolean },
) => Promise<AssetLike | null>;
) => Promise<Asset | null>;
export type GetDocumentAsset = AssetManagerGetAsset;
interface ResolveTextContentUrlOptions {
@@ -25,7 +25,7 @@ interface ResolveTextContentUrlOptions {
ensureAssetUrl?: EnsureAssetUrl;
}
const pickAsset = (doc?: Document | null, getDocumentAsset?: GetDocumentAsset): AssetLike | null => {
const pickAsset = (doc?: Document | null, getDocumentAsset?: GetDocumentAsset): Asset | null => {
if (!doc || !getDocumentAsset) {
return null;
}
@@ -60,7 +60,7 @@ export async function resolveTextContentUrl({
const baseUrl = resolveAssetUrl(asset);
const hasUrl = Boolean(baseUrl);
let entry: AssetLike = asset;
let entry: Asset = asset;
if (ensureAssetUrl) {
const ensureOptions = { force: !hasUrl };
const ensured = await ensureAssetUrl(docRef.id!, asset, ensureOptions);