Files
papercrate/frontend/src/hooks/useAssetNavigator.ts
T
2025-11-15 04:11:22 +01:00

209 lines
5.4 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import { createAssetView } from '../asset_manager';
type Identifier = string | number;
interface DocumentLike {
id?: Identifier;
[key: string]: unknown;
}
interface AssetObject {
ordinal?: number;
url?: string | null;
metadata?: Record<string, unknown> | null;
[key: string]: unknown;
}
interface AssetLike {
id?: Identifier;
cardinality?: number;
url?: string | null;
metadata?: Record<string, unknown> | null;
objects?: AssetObject[];
[key: string]: unknown;
}
type EnsureAssetUrl = (
documentId: Identifier,
asset: AssetLike,
options?: { start?: number; limit?: number; [key: string]: unknown },
) => Promise<unknown>;
type GetAsset = (document: DocumentLike, assetType: string) => AssetLike | null;
interface AssetViewLike {
getCardinality: () => number;
getObjects: () => AssetObject[];
getObject: (ordinal?: number) => AssetObject | null;
getPrimaryUrl: () => string | null;
getPrimaryMetadata: () => Record<string, unknown> | null;
}
interface UseAssetNavigatorOptions {
document?: DocumentLike | null;
assetType: string;
ensureAssetUrl?: EnsureAssetUrl;
getAsset?: GetAsset;
prefetch?: number;
defaultOrdinal?: number;
}
interface AssetNavigatorReturn {
document: DocumentLike | null;
documentId: Identifier | null;
asset: AssetLike | null;
assetType: string;
ordinal: number;
setOrdinal: (next: number) => void;
goPrev: () => void;
goNext: () => void;
canGoPrev: boolean;
canGoNext: boolean;
cardinality: number;
currentObject: AssetObject | null;
currentUrl: string | null;
currentMetadata: AssetObject['metadata'];
objects: AssetObject[];
isLoading: boolean;
}
const clampOrdinalValue = (value: number, cardinality: number, defaultOrdinal: number) => {
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,
}: 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>(
() => createAssetView(asset) as unknown as AssetViewLike,
[asset],
);
const cardinality = view.getCardinality();
const [ordinal, setOrdinalInternal] = useState(defaultOrdinal);
useEffect(() => {
setOrdinalInternal(defaultOrdinal);
}, [documentId, assetType, defaultOrdinal]);
const setOrdinal = useCallback(
(next: number) => {
setOrdinalInternal(clampOrdinalValue(next, cardinality, defaultOrdinal));
},
[cardinality, defaultOrdinal],
);
const goPrev = useCallback(() => {
setOrdinalInternal((prev) => clampOrdinalValue(prev - 1, cardinality, defaultOrdinal));
}, [cardinality, defaultOrdinal]);
const goNext = useCallback(() => {
setOrdinalInternal((prev) => clampOrdinalValue(prev + 1, cardinality, defaultOrdinal));
}, [cardinality, defaultOrdinal]);
const objects = view.getObjects();
const currentObject = view.getObject(ordinal);
const currentUrl = currentObject?.url ?? view.getPrimaryUrl() ?? null;
const currentMetadata = currentObject?.metadata ?? view.getPrimaryMetadata() ?? null;
const canGoPrev = ordinal > 1;
const canGoNext = cardinality ? ordinal < cardinality : true;
const ordinalsNeedingLoad = useMemo<number[]>(() => {
const missing: number[] = [];
if (!asset) {
return missing;
}
const safePrefetch = Math.max(1, prefetch);
const maxOrdinal = cardinality && cardinality > 0
? Math.min(cardinality, ordinal + safePrefetch - 1)
: ordinal + safePrefetch - 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 !== null && fetchEnd !== null ? fetchEnd - fetchStart + 1 : null;
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!documentId || !asset || !ensureAssetUrl) {
setLoading(false);
return;
}
if (fetchStart === null || fetchLimit === null) {
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;