177 lines
4.6 KiB
TypeScript
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;
|