import { useEffect, useRef, useState, type ImgHTMLAttributes, type ReactNode } from "react";
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import { resolveImageUrl } from "@/services/image-storage";
type CachedResourceImageProps = Omit, "src"> & {
storageKey?: string;
src?: string;
fallback?: ReactNode;
loadingFallback?: ReactNode;
eager?: boolean;
};
/**
* 远程资源图片统一使用稳定的资源文件地址。
* Blob 缓存仍可用于导出、抽帧等字节处理,但不作为媒体展示 src,避免把
* `blob:http(s)://...` 泄露到节点、素材库和浏览器媒体链路中。
*/
export function CachedResourceImage({ storageKey, src = "", fallback = null, loadingFallback = fallback, eager = false, onError, ...props }: CachedResourceImageProps) {
const resourceId = resourceIdFromStorageKey(storageKey);
const remoteResource = Boolean(resourceId);
const localImageResource = Boolean(storageKey && storageKey.startsWith("image:"));
const targetRef = useRef(null);
const [nearViewport, setNearViewport] = useState(eager || !remoteResource);
const [cachedSrc, setCachedSrc] = useState(remoteResource ? "" : src);
const [cacheFailed, setCacheFailed] = useState(false);
useEffect(() => {
if (!remoteResource || eager) {
setNearViewport(true);
return;
}
const image = targetRef.current;
if (!image || typeof IntersectionObserver === "undefined") {
setNearViewport(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setNearViewport(true);
observer.disconnect();
}
},
{ rootMargin: "240px" },
);
observer.observe(image);
return () => observer.disconnect();
}, [eager, remoteResource]);
useEffect(() => {
let cancelled = false;
setCacheFailed(false);
if (remoteResource && resourceId) {
if (!nearViewport) {
setCachedSrc("");
return () => {
cancelled = true;
};
}
setCachedSrc(resourceFileUrl(resourceId));
return () => {
cancelled = true;
};
}
if (localImageResource && storageKey) {
void resolveImageUrl(storageKey, src)
.then((url) => {
if (!cancelled) setCachedSrc(url || src);
})
.catch(() => {
if (!cancelled) setCachedSrc(src);
});
return () => {
cancelled = true;
};
}
setCachedSrc(src);
return () => {
cancelled = true;
};
}, [localImageResource, nearViewport, remoteResource, resourceId, src, storageKey]);
const handleImgError = (e: React.SyntheticEvent) => {
if (localImageResource && storageKey && cachedSrc.startsWith("blob:")) {
void resolveImageUrl(storageKey)
.then((url) => {
if (url && url !== cachedSrc) {
setCachedSrc(url);
return;
}
setCacheFailed(true);
onError?.(e);
})
.catch(() => {
setCacheFailed(true);
onError?.(e);
});
return;
}
setCacheFailed(true);
onError?.(e);
};
if (!remoteResource) {
if (cacheFailed && fallback) return <>{fallback}>;
return
;
}
return (
{cachedSrc && !cacheFailed ?
: cacheFailed ? fallback : loadingFallback}
);
}