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 | null; [key: string]: unknown; } interface AssetLike { id?: Identifier; cardinality?: number; url?: string | null; metadata?: Record | null; objects?: AssetObject[]; [key: string]: unknown; } type EnsureAssetUrl = ( documentId: Identifier, asset: AssetLike, options?: { start?: number; limit?: number; [key: string]: unknown }, ) => Promise; type GetAsset = (document: DocumentLike, assetType: string) => AssetLike | null | undefined; interface AssetViewLike { getCardinality: () => number; getObjects: () => AssetObject[]; getObject: (ordinal?: number) => AssetObject | null; getPrimaryUrl: () => string | null; getPrimaryMetadata: () => Record | null; } interface UseAssetNavigatorOptions { document: DocumentLike | null | undefined; assetType: string; ensureAssetUrl?: EnsureAssetUrl; getAsset?: GetAsset; prefetch?: number; defaultOrdinal?: number; } interface AssetNavigatorReturn { document: DocumentLike | null | undefined; 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(() => { if (!document || !getAsset) { return null; } return getAsset(document, assetType) || null; }, [document, assetType, getAsset]); const view = useMemo( () => 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(() => { 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;