Files
ttstd-infinite-canvas/web/src/components/cached-resource-image.tsx

118 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ImgHTMLAttributes<HTMLImageElement>, "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<HTMLSpanElement>(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<HTMLImageElement, Event>) => {
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 <img {...props} src={cachedSrc} onError={handleImgError} />;
}
return (
<span ref={targetRef} className="cached-resource-image-shell">
{cachedSrc && !cacheFailed ? <img {...props} src={cachedSrc} onError={handleImgError} /> : cacheFailed ? fallback : loadingFallback}
</span>
);
}