import { useEffect, useState } from 'react'; import { ipcChannels } from '../../shared/ipc/contracts'; import type { AssetId, ProjectId } from '../../shared/types'; import { getDndApi } from './dndApi'; /** Module-level кэш assetId → url; сбрасывается при смене/закрытии проекта. */ const urlCache = new Map(); const invalidateListeners = new Set<() => void>(); let sessionProjectHooked = false; let lastSessionProjectId: ProjectId | null | undefined; function ensureSessionProjectInvalidation(): void { if (sessionProjectHooked) return; sessionProjectHooked = true; try { getDndApi().on(ipcChannels.session.stateChanged, ({ state }) => { const next = state.project?.id ?? null; if (lastSessionProjectId === undefined) { lastSessionProjectId = next; return; } if (lastSessionProjectId !== next) { lastSessionProjectId = next; invalidateAssetUrlCache(); } }); } catch { /* вне Electron / тесты */ } } export function peekAssetUrlCache(assetId: AssetId): string | null | undefined { return urlCache.has(assetId) ? (urlCache.get(assetId) ?? null) : undefined; } export function invalidateAssetUrlCache(): void { urlCache.clear(); for (const fn of invalidateListeners) { try { fn(); } catch { /* ignore */ } } } /** * Возвращает `dnd://` / file URL для превью. Пока загрузка или сменился id — `null`. * Повторные запросы того же id не ходят в IPC, пока кэш не инвалидирован. */ export function useAssetUrl(assetId: AssetId | null | undefined): string | null { ensureSessionProjectInvalidation(); const id = assetId ?? null; const [entry, setEntry] = useState<{ assetId: AssetId; url: string | null } | null>(() => { if (id === null) return null; const hit = peekAssetUrlCache(id); return hit === undefined ? null : { assetId: id, url: hit }; }); const [epoch, setEpoch] = useState(0); useEffect(() => { const onInvalidate = () => setEpoch((n) => n + 1); invalidateListeners.add(onInvalidate); return () => { invalidateListeners.delete(onInvalidate); }; }, []); useEffect(() => { if (id === null) { setEntry(null); return undefined; } const hit = peekAssetUrlCache(id); if (hit !== undefined) { setEntry({ assetId: id, url: hit }); return undefined; } let cancelled = false; void getDndApi() .invoke(ipcChannels.project.assetFileUrl, { assetId: id }) .then((r) => { urlCache.set(id, r.url); if (!cancelled) setEntry({ assetId: id, url: r.url }); }); return () => { cancelled = true; }; }, [id, epoch]); if (id === null) { return null; } if (entry?.assetId !== id) { return null; } return entry.url; } /** @deprecated use `useAssetUrl` */ export function useAssetImageUrl(assetId: AssetId | null | undefined): string | null { return useAssetUrl(assetId); }