39948-vm/frontend/src/hooks/useIconPreload.ts
2026-03-29 16:03:25 +04:00

177 lines
4.6 KiB
TypeScript

/**
* useIconPreload Hook
*
* Preloads icon images for smooth rendering without flash.
* Used in constructor.tsx to preload navigation/tooltip/description icons.
*/
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
import { logger } from '../lib/logger';
interface UseIconPreloadOptions {
/** Array of icon URLs to preload */
iconUrls: string[];
/** Whether preloading is enabled */
enabled?: boolean;
}
interface UseIconPreloadResult {
/** Map of URL to preload status (true if ready) */
preloadedUrlMap: Record<string, boolean>;
/** Check if a specific URL is preloaded */
isPreloaded: (url: string) => boolean;
/** Number of icons currently being preloaded */
pendingCount: number;
/** Whether all icons are preloaded */
allPreloaded: boolean;
}
/**
* Hook for preloading icon images.
* Prevents flash when icons are first rendered by pre-decoding them.
*
* @example
* const iconUrls = elements
* .filter(el => el.iconUrl)
* .map(el => resolveAssetPlaybackUrl(el.iconUrl));
*
* const { isPreloaded } = useIconPreload({ iconUrls });
*
* // Only render element if icon is ready
* if (element.iconUrl && !isPreloaded(element.iconUrl)) return null;
*/
export function useIconPreload({
iconUrls,
enabled = true,
}: UseIconPreloadOptions): UseIconPreloadResult {
const [preloadedUrlMap, setPreloadedUrlMap] = useState<
Record<string, boolean>
>({});
const preloadedUrlsRef = useRef<Set<string>>(new Set());
// Clean up preloaded set when target URLs change
useEffect(() => {
if (typeof window === 'undefined') return;
const targetSet = new Set(iconUrls);
const nextPreloaded = new Set<string>();
// Keep only URLs that are still in the target list
preloadedUrlsRef.current.forEach((url) => {
if (targetSet.has(url)) {
nextPreloaded.add(url);
}
});
preloadedUrlsRef.current = nextPreloaded;
// Update state map
setPreloadedUrlMap(() => {
const nextMap: Record<string, boolean> = {};
nextPreloaded.forEach((url) => {
nextMap[url] = true;
});
return nextMap;
});
}, [iconUrls]);
// Preload icons
useEffect(() => {
if (typeof window === 'undefined') return;
if (!enabled || !iconUrls.length) return;
let isCancelled = false;
const preloadImages: HTMLImageElement[] = [];
iconUrls.forEach((url) => {
// Skip if already preloaded
if (preloadedUrlsRef.current.has(url)) return;
const image = new Image();
const markReady = () => {
if (isCancelled) return;
preloadedUrlsRef.current.add(url);
setPreloadedUrlMap((prev) => {
if (prev[url]) return prev;
return { ...prev, [url]: true };
});
};
image.onload = markReady;
image.onerror = () => {
logger.error('Failed to preload icon asset:', { url });
markReady(); // Mark as ready anyway to avoid blocking
};
image.src = url;
preloadImages.push(image);
});
return () => {
isCancelled = true;
preloadImages.forEach((image) => {
image.onload = null;
image.onerror = null;
});
};
}, [iconUrls, enabled]);
const isPreloaded = useCallback(
(url: string): boolean => {
return Boolean(preloadedUrlMap[url]);
},
[preloadedUrlMap],
);
const pendingCount = useMemo(() => {
return iconUrls.filter((url) => !preloadedUrlMap[url]).length;
}, [iconUrls, preloadedUrlMap]);
const allPreloaded = useMemo(() => {
return iconUrls.every((url) => preloadedUrlMap[url]);
}, [iconUrls, preloadedUrlMap]);
return {
preloadedUrlMap,
isPreloaded,
pendingCount,
allPreloaded,
};
}
/**
* Build icon preload targets from elements.
* Utility to extract icon URLs that need preloading.
*/
export function buildIconPreloadTargets(
elements: Array<{ type: string; iconUrl?: string }>,
typeCheckers: {
isNavigationElementType: (type: string) => boolean;
isTooltipElementType: (type: string) => boolean;
isDescriptionElementType: (type: string) => boolean;
},
): string[] {
const {
isNavigationElementType,
isTooltipElementType,
isDescriptionElementType,
} = typeCheckers;
const urls = elements
.filter(
(element) =>
(isNavigationElementType(element.type) ||
isTooltipElementType(element.type) ||
isDescriptionElementType(element.type)) &&
Boolean(element.iconUrl),
)
.map((element) => resolveAssetPlaybackUrl(element.iconUrl))
.filter(Boolean) as string[];
return Array.from(new Set(urls));
}
export default useIconPreload;