dde860716e
- 新增 projectResourceMediaPreviewCategory:从卡片预览类型派生原生侧线上 category(art / audio),与画布分区栏目轴解耦 - read_local_project_media_preview 调用点不再传 job.resource.category:该字段自 6 类资产分类轴落地后是画布栏目(unclassified / ui-interaction / …),传给原生侧会被 read_local_project_media_preview_at 以「媒体预览类别只支持 art 或 audio」直接拒绝 - 该回归导致所有走媒体分支的卡片(GIF / SVG / AVIF / BMP / MP4 / WebM / MOV 与音频)读不出预览,只显示占位图标 - previewReadErrorMessage 与 resourceReadKindLabel 改用卡片预览类型判定文案,不再依赖已换语义的 category - 废弃 useProjectResourceCardPreviews 对 projectResourceDisplayKind 的引用,错误文案与读取路由共用同一分类口径 - 预览 Hook 用例补「扩展名图片按 art 分支发出请求」与「音频仅在播放意图后按 audio 分支发出请求」两条断言,直接钉住原生侧线上取值 - 应用层媒体用例补 icon.svg 的 category 必须是 art 的断言,并修正原先描述「媒体读取改传画布栏目」的过期注释
781 lines
24 KiB
TypeScript
781 lines
24 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
|
|
import type { ProjectResourceCanvasLayoutMode } from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
|
import {
|
|
cancelLocalProjectResourcePreviewScope,
|
|
createProjectResourcePreviewRequestId,
|
|
createProjectResourcePreviewScopeId,
|
|
isProjectResourcePreviewCancellation,
|
|
} from '../../services/projectResourcePreviewTransport';
|
|
import {
|
|
IDLE_PROJECT_RESOURCE_CARD_PREVIEW,
|
|
PROJECT_RESOURCE_CARD_PREVIEW_ACTIVE_QUEUE_RESERVE,
|
|
PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT,
|
|
PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT,
|
|
PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY,
|
|
PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT,
|
|
projectResourceCardPreviewEvictionIdentities,
|
|
projectResourceCardPreviewIdentity,
|
|
projectResourceCardPreviewImageDimensions,
|
|
type ProjectResourceCardPreviewKind,
|
|
projectResourceCardPreviewKind,
|
|
type ProjectResourceCardPreviewPayload,
|
|
type ProjectResourceCardPreviewState,
|
|
type ProjectResourceCardPreviewTransportPayload,
|
|
projectResourceMediaPreviewCategory,
|
|
} from './resourceCardPreviewModel';
|
|
import type { ProjectResource } from './resourceProjectionModel';
|
|
|
|
type PreviewRequestReason = 'visible' | 'detail' | 'play';
|
|
|
|
type PreviewJob = {
|
|
scopeKey: string;
|
|
scopeId: string;
|
|
scopeEpoch: number;
|
|
identity: string;
|
|
resource: ProjectResource;
|
|
reason: PreviewRequestReason;
|
|
};
|
|
|
|
type MaterializedPreview = {
|
|
preview: ProjectResourceCardPreviewPayload;
|
|
retainedBytes: number;
|
|
objectUrl: string | null;
|
|
};
|
|
|
|
type CachedPreview = {
|
|
retainedBytes: number;
|
|
objectUrl: string | null;
|
|
};
|
|
|
|
type ObservedPreviewCard = {
|
|
identity: string;
|
|
resource: ProjectResource;
|
|
};
|
|
|
|
function resourceReadKindLabel(
|
|
resource: ProjectResource,
|
|
kind: ProjectResourceCardPreviewKind,
|
|
) {
|
|
if (kind === 'document') {
|
|
return '文档';
|
|
}
|
|
if (kind === 'code') {
|
|
return '游戏代码';
|
|
}
|
|
return '资源';
|
|
}
|
|
|
|
function previewReadErrorMessage(
|
|
resource: ProjectResource,
|
|
kind: ProjectResourceCardPreviewKind,
|
|
error: unknown,
|
|
): { error: string; retryable: boolean } {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const kindLabel = resourceReadKindLabel(resource, kind);
|
|
if (message.includes('项目权限策略要求用户确认')) {
|
|
return {
|
|
error: `当前项目策略要求先确认读取${kindLabel},确认后请关闭详情并重试`,
|
|
retryable: true,
|
|
};
|
|
}
|
|
if (message.includes('项目权限策略拒绝执行')) {
|
|
return {
|
|
error: `当前项目策略不允许读取${kindLabel},调整策略后请关闭详情并重试`,
|
|
retryable: true,
|
|
};
|
|
}
|
|
if (message.includes('发生漂移') || message.includes('发生替换')) {
|
|
return {
|
|
error: '资源读取期间发生变化,请关闭详情并重试',
|
|
retryable: true,
|
|
};
|
|
}
|
|
if (
|
|
message.includes('不能超过') ||
|
|
message.includes('UTF-8') ||
|
|
message.includes('只支持') ||
|
|
message.includes('脚本或外部资源引用') ||
|
|
message.includes('图片尺寸过大') ||
|
|
message.includes('图片尺寸溢出') ||
|
|
message.includes('图片结构无效') ||
|
|
message.includes('内容无效') ||
|
|
message.includes('文件为空') ||
|
|
message.includes('图片不能为空') ||
|
|
message.includes('签名与登记类型不一致')
|
|
) {
|
|
return {
|
|
error: message
|
|
.replace('image.inspect', '图片预览')
|
|
.replace(',暂时无法在客户端预览', ',无法在客户端预览'),
|
|
retryable: false,
|
|
};
|
|
}
|
|
return {
|
|
error: '资源暂时无法读取,请关闭详情并重试',
|
|
retryable: true,
|
|
};
|
|
}
|
|
|
|
const previewRequestPriority: Record<PreviewRequestReason, number> = {
|
|
visible: 0,
|
|
detail: 1,
|
|
play: 2,
|
|
};
|
|
|
|
function materializeProjectResourceCardPreview(
|
|
transport: ProjectResourceCardPreviewTransportPayload,
|
|
): MaterializedPreview {
|
|
if (!transport.dataUrl) {
|
|
return {
|
|
preview: {
|
|
path: transport.path,
|
|
mediaType: transport.mediaType,
|
|
byteLen: transport.byteLen,
|
|
pixelWidth: transport.pixelWidth,
|
|
pixelHeight: transport.pixelHeight,
|
|
content: transport.content,
|
|
},
|
|
retainedBytes:
|
|
transport.content === undefined
|
|
? 0
|
|
: new TextEncoder().encode(transport.content).byteLength,
|
|
objectUrl: null,
|
|
};
|
|
}
|
|
const imageDimensions = projectResourceCardPreviewImageDimensions(transport);
|
|
const prefix = `data:${transport.mediaType};base64,`;
|
|
if (!transport.dataUrl.startsWith(prefix)) {
|
|
throw new Error('媒体预览内容无效:data URL 类型与媒体类型不一致');
|
|
}
|
|
let decoded: string;
|
|
try {
|
|
decoded = window.atob(transport.dataUrl.slice(prefix.length));
|
|
} catch {
|
|
throw new Error('媒体预览内容无效:base64 解码失败');
|
|
}
|
|
const bytes = new Uint8Array(decoded.length);
|
|
for (let index = 0; index < decoded.length; index += 1) {
|
|
bytes[index] = decoded.charCodeAt(index);
|
|
}
|
|
const blob = new Blob([bytes], { type: transport.mediaType });
|
|
if (blob.size > PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT) {
|
|
throw new Error('媒体预览不能超过客户端缓存字节预算');
|
|
}
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
return {
|
|
preview: {
|
|
path: transport.path,
|
|
mediaType: transport.mediaType,
|
|
byteLen: transport.byteLen,
|
|
pixelWidth: imageDimensions?.pixelWidth,
|
|
pixelHeight: imageDimensions?.pixelHeight,
|
|
sourceUrl: objectUrl,
|
|
},
|
|
retainedBytes: blob.size,
|
|
objectUrl,
|
|
};
|
|
}
|
|
|
|
function nextPreviewJob(queue: PreviewJob[]) {
|
|
let nextIndex = 0;
|
|
for (let index = 1; index < queue.length; index += 1) {
|
|
if (
|
|
previewRequestPriority[queue[index]!.reason] >
|
|
previewRequestPriority[queue[nextIndex]!.reason]
|
|
) {
|
|
nextIndex = index;
|
|
}
|
|
}
|
|
return queue.splice(nextIndex, 1)[0];
|
|
}
|
|
|
|
export function useProjectResourceCardPreviews(input: {
|
|
projectPath: string;
|
|
projectId: string;
|
|
mode: ProjectResourceCanvasLayoutMode;
|
|
resources: ProjectResource[];
|
|
canvasRef: React.RefObject<HTMLDivElement | null>;
|
|
// 可见性判定容器。卡片渲染在资源画本场景里时,传真正包含卡片的容器;
|
|
// 缺省回退到 canvasRef。
|
|
intersectionRootRef?: React.RefObject<HTMLDivElement | null>;
|
|
eagerPreviewLimit?: number;
|
|
previewVersionByResourceId?: ReadonlyMap<string, string>;
|
|
}) {
|
|
const scopeKey = JSON.stringify([input.projectPath, input.projectId]);
|
|
const identityByResourceId = useMemo(
|
|
() =>
|
|
new Map(
|
|
input.resources.map((resource) => [
|
|
resource.id,
|
|
projectResourceCardPreviewIdentity({
|
|
projectPath: input.projectPath,
|
|
projectId: input.projectId,
|
|
previewVersion:
|
|
input.previewVersionByResourceId?.get(resource.id) ?? '',
|
|
resource,
|
|
}),
|
|
]),
|
|
),
|
|
[
|
|
input.previewVersionByResourceId,
|
|
input.projectId,
|
|
input.projectPath,
|
|
input.resources,
|
|
],
|
|
);
|
|
const [previews, setPreviews] = useState<
|
|
Map<string, ProjectResourceCardPreviewState>
|
|
>(new Map());
|
|
const [imageDimensionsByIdentity, setImageDimensionsByIdentity] = useState<
|
|
Map<
|
|
string,
|
|
NonNullable<ReturnType<typeof projectResourceCardPreviewImageDimensions>>
|
|
>
|
|
>(new Map());
|
|
const [initialScopeId] = useState(createProjectResourcePreviewScopeId);
|
|
const previewsRef = useRef(previews);
|
|
const imageDimensionsByIdentityRef = useRef(imageDimensionsByIdentity);
|
|
const scopeKeyRef = useRef(scopeKey);
|
|
const scopeIdRef = useRef(initialScopeId);
|
|
const scopeInitializedRef = useRef(false);
|
|
const scopeEpochRef = useRef(0);
|
|
const identityByResourceIdRef = useRef(identityByResourceId);
|
|
const validIdentitiesRef = useRef(new Set(identityByResourceId.values()));
|
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
const observedCardsRef = useRef(new Map<HTMLElement, ObservedPreviewCard>());
|
|
const queueRef = useRef<PreviewJob[]>([]);
|
|
const queuedIdentitiesRef = useRef(new Map<string, number>());
|
|
const pendingIdentitiesRef = useRef(new Map<string, number>());
|
|
const cacheOrderRef = useRef(new Map<string, true>());
|
|
const cachedPreviewsRef = useRef(new Map<string, CachedPreview>());
|
|
const cacheBytesRef = useRef(0);
|
|
const activeReadsRef = useRef({ scopeEpoch: 0, count: 0 });
|
|
const protectedIdentityRef = useRef<string | null>(null);
|
|
const drainQueueRef = useRef<() => void>(() => undefined);
|
|
|
|
scopeKeyRef.current = scopeKey;
|
|
identityByResourceIdRef.current = identityByResourceId;
|
|
validIdentitiesRef.current = new Set(identityByResourceId.values());
|
|
|
|
const isCurrentJob = useCallback((job: PreviewJob) => {
|
|
return (
|
|
scopeEpochRef.current === job.scopeEpoch &&
|
|
scopeKeyRef.current === job.scopeKey &&
|
|
scopeIdRef.current === job.scopeId &&
|
|
identityByResourceIdRef.current.get(job.resource.id) === job.identity
|
|
);
|
|
}, []);
|
|
|
|
const disposeCachedPreview = useCallback((identity: string) => {
|
|
const cached = cachedPreviewsRef.current.get(identity);
|
|
if (!cached) {
|
|
return;
|
|
}
|
|
cachedPreviewsRef.current.delete(identity);
|
|
cacheBytesRef.current = Math.max(
|
|
0,
|
|
cacheBytesRef.current - cached.retainedBytes,
|
|
);
|
|
if (cached.objectUrl) {
|
|
URL.revokeObjectURL(cached.objectUrl);
|
|
}
|
|
}, []);
|
|
|
|
const disposeAllCachedPreviews = useCallback(() => {
|
|
for (const identity of Array.from(cachedPreviewsRef.current.keys())) {
|
|
disposeCachedPreview(identity);
|
|
}
|
|
cacheOrderRef.current.clear();
|
|
cacheBytesRef.current = 0;
|
|
}, [disposeCachedPreview]);
|
|
|
|
const publishPreview = useCallback(
|
|
(
|
|
identity: string,
|
|
state: ProjectResourceCardPreviewState,
|
|
materialized?: MaterializedPreview,
|
|
) => {
|
|
const next = new Map(previewsRef.current);
|
|
if (state.status === 'loaded') {
|
|
const dimensions = projectResourceCardPreviewImageDimensions(
|
|
state.preview,
|
|
);
|
|
if (dimensions) {
|
|
const nextImageDimensions = new Map(
|
|
imageDimensionsByIdentityRef.current,
|
|
);
|
|
nextImageDimensions.set(identity, dimensions);
|
|
imageDimensionsByIdentityRef.current = nextImageDimensions;
|
|
setImageDimensionsByIdentity(nextImageDimensions);
|
|
}
|
|
}
|
|
cacheOrderRef.current.delete(identity);
|
|
disposeCachedPreview(identity);
|
|
next.set(identity, state);
|
|
if (state.status === 'loaded' || state.status === 'failed') {
|
|
const cached = materialized
|
|
? {
|
|
retainedBytes: materialized.retainedBytes,
|
|
objectUrl: materialized.objectUrl,
|
|
}
|
|
: { retainedBytes: 0, objectUrl: null };
|
|
cachedPreviewsRef.current.set(identity, cached);
|
|
cacheBytesRef.current += cached.retainedBytes;
|
|
cacheOrderRef.current.set(identity, true);
|
|
const evictedIdentities = projectResourceCardPreviewEvictionIdentities(
|
|
Array.from(cacheOrderRef.current.keys()).map((cachedIdentity) => ({
|
|
identity: cachedIdentity,
|
|
retainedBytes:
|
|
cachedPreviewsRef.current.get(cachedIdentity)?.retainedBytes ?? 0,
|
|
})),
|
|
protectedIdentityRef.current,
|
|
);
|
|
for (const evictedIdentity of evictedIdentities) {
|
|
cacheOrderRef.current.delete(evictedIdentity);
|
|
disposeCachedPreview(evictedIdentity);
|
|
next.delete(evictedIdentity);
|
|
}
|
|
}
|
|
previewsRef.current = next;
|
|
setPreviews(next);
|
|
},
|
|
[disposeCachedPreview],
|
|
);
|
|
|
|
const readPreview = useCallback(
|
|
async (
|
|
job: PreviewJob,
|
|
): Promise<ProjectResourceCardPreviewTransportPayload> => {
|
|
const kind = projectResourceCardPreviewKind(job.resource);
|
|
if (
|
|
(kind === 'document' || kind === 'code') &&
|
|
job.resource.content !== undefined
|
|
) {
|
|
return {
|
|
path: job.resource.path,
|
|
mediaType: job.resource.mediaType,
|
|
byteLen: new TextEncoder().encode(job.resource.content).byteLength,
|
|
content: job.resource.content,
|
|
};
|
|
}
|
|
const invoke = window.__TAURI__?.core?.invoke;
|
|
if (!invoke) {
|
|
throw new Error(
|
|
kind === 'document' || kind === 'code'
|
|
? '文档预览需要在客户端内打开'
|
|
: '媒体预览需要在客户端内打开',
|
|
);
|
|
}
|
|
if (kind === 'raster-image') {
|
|
return invoke<ProjectResourceCardPreviewTransportPayload>(
|
|
'read_local_project_image_preview',
|
|
{
|
|
projectPath: input.projectPath,
|
|
relativePath: job.resource.path,
|
|
scopeId: job.scopeId,
|
|
requestId: createProjectResourcePreviewRequestId(),
|
|
},
|
|
);
|
|
}
|
|
if (kind === 'document' || kind === 'code') {
|
|
return invoke<ProjectResourceCardPreviewTransportPayload>(
|
|
'read_local_project_text_preview',
|
|
{
|
|
projectPath: input.projectPath,
|
|
relativePath: job.resource.path,
|
|
scopeId: job.scopeId,
|
|
requestId: createProjectResourcePreviewRequestId(),
|
|
},
|
|
);
|
|
}
|
|
return invoke<ProjectResourceCardPreviewTransportPayload>(
|
|
'read_local_project_media_preview',
|
|
{
|
|
projectPath: input.projectPath,
|
|
relativePath: job.resource.path,
|
|
category: projectResourceMediaPreviewCategory(job.resource),
|
|
scopeId: job.scopeId,
|
|
requestId: createProjectResourcePreviewRequestId(),
|
|
},
|
|
);
|
|
},
|
|
[input.projectPath],
|
|
);
|
|
|
|
const drainQueue = useCallback(() => {
|
|
while (
|
|
activeReadsRef.current.scopeEpoch === scopeEpochRef.current &&
|
|
activeReadsRef.current.count <
|
|
PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY &&
|
|
queueRef.current.length > 0
|
|
) {
|
|
const job = nextPreviewJob(queueRef.current);
|
|
if (!job) {
|
|
break;
|
|
}
|
|
if (queuedIdentitiesRef.current.get(job.identity) === job.scopeEpoch) {
|
|
queuedIdentitiesRef.current.delete(job.identity);
|
|
}
|
|
if (!isCurrentJob(job)) {
|
|
continue;
|
|
}
|
|
activeReadsRef.current.count += 1;
|
|
pendingIdentitiesRef.current.set(job.identity, job.scopeEpoch);
|
|
const jobPreviewKind = projectResourceCardPreviewKind(job.resource);
|
|
publishPreview(job.identity, { status: 'loading' });
|
|
void readPreview(job)
|
|
.then((transport) => {
|
|
if (isCurrentJob(job)) {
|
|
const materialized =
|
|
materializeProjectResourceCardPreview(transport);
|
|
if (!isCurrentJob(job)) {
|
|
if (materialized.objectUrl) {
|
|
URL.revokeObjectURL(materialized.objectUrl);
|
|
}
|
|
return;
|
|
}
|
|
publishPreview(
|
|
job.identity,
|
|
{ status: 'loaded', preview: materialized.preview },
|
|
materialized,
|
|
);
|
|
}
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (
|
|
isCurrentJob(job) &&
|
|
!isProjectResourcePreviewCancellation(error)
|
|
) {
|
|
const failure = previewReadErrorMessage(
|
|
job.resource,
|
|
jobPreviewKind,
|
|
error,
|
|
);
|
|
publishPreview(job.identity, {
|
|
status: 'failed',
|
|
...failure,
|
|
});
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (
|
|
pendingIdentitiesRef.current.get(job.identity) === job.scopeEpoch
|
|
) {
|
|
pendingIdentitiesRef.current.delete(job.identity);
|
|
}
|
|
if (activeReadsRef.current.scopeEpoch === job.scopeEpoch) {
|
|
activeReadsRef.current.count = Math.max(
|
|
0,
|
|
activeReadsRef.current.count - 1,
|
|
);
|
|
drainQueueRef.current();
|
|
}
|
|
});
|
|
}
|
|
}, [isCurrentJob, publishPreview, readPreview]);
|
|
drainQueueRef.current = drainQueue;
|
|
|
|
const touchCachedPreview = useCallback((identity: string) => {
|
|
if (!cacheOrderRef.current.has(identity)) {
|
|
return;
|
|
}
|
|
cacheOrderRef.current.delete(identity);
|
|
cacheOrderRef.current.set(identity, true);
|
|
}, []);
|
|
|
|
const requestPreview = useCallback(
|
|
(
|
|
resource: ProjectResource,
|
|
identity: string,
|
|
reason: PreviewRequestReason,
|
|
) => {
|
|
if (identityByResourceIdRef.current.get(resource.id) !== identity) {
|
|
return;
|
|
}
|
|
const kind = projectResourceCardPreviewKind(resource);
|
|
if (
|
|
kind === 'version' ||
|
|
kind === 'placeholder' ||
|
|
(kind === 'audio' && reason !== 'play')
|
|
) {
|
|
return;
|
|
}
|
|
const current = previewsRef.current.get(identity);
|
|
if (current?.status === 'loaded') {
|
|
touchCachedPreview(identity);
|
|
return;
|
|
}
|
|
if (current?.status === 'failed') {
|
|
if (reason === 'visible' || !current.retryable) {
|
|
touchCachedPreview(identity);
|
|
return;
|
|
}
|
|
}
|
|
if (
|
|
current?.status === 'loading' ||
|
|
pendingIdentitiesRef.current.get(identity) === scopeEpochRef.current
|
|
) {
|
|
return;
|
|
}
|
|
const queuedJob =
|
|
queuedIdentitiesRef.current.get(identity) === scopeEpochRef.current
|
|
? queueRef.current.find((job) => job.identity === identity)
|
|
: undefined;
|
|
if (queuedJob) {
|
|
if (
|
|
previewRequestPriority[reason] >
|
|
previewRequestPriority[queuedJob.reason]
|
|
) {
|
|
queuedJob.reason = reason;
|
|
}
|
|
return;
|
|
}
|
|
const visibleQueueLimit =
|
|
PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT -
|
|
PROJECT_RESOURCE_CARD_PREVIEW_ACTIVE_QUEUE_RESERVE;
|
|
if (
|
|
reason === 'visible' &&
|
|
queueRef.current.length >= visibleQueueLimit
|
|
) {
|
|
return;
|
|
}
|
|
if (
|
|
queueRef.current.length >= PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT
|
|
) {
|
|
let replaceIndex = -1;
|
|
for (let index = queueRef.current.length - 1; index >= 0; index -= 1) {
|
|
if (queueRef.current[index]?.reason === 'visible') {
|
|
replaceIndex = index;
|
|
break;
|
|
}
|
|
}
|
|
if (reason === 'visible' || replaceIndex < 0) {
|
|
return;
|
|
}
|
|
const [replacedJob] = queueRef.current.splice(replaceIndex, 1);
|
|
if (replacedJob) {
|
|
queuedIdentitiesRef.current.delete(replacedJob.identity);
|
|
}
|
|
}
|
|
cacheOrderRef.current.delete(identity);
|
|
queuedIdentitiesRef.current.set(identity, scopeEpochRef.current);
|
|
queueRef.current.push({
|
|
scopeKey: scopeKeyRef.current,
|
|
scopeId: scopeIdRef.current,
|
|
scopeEpoch: scopeEpochRef.current,
|
|
identity,
|
|
resource,
|
|
reason,
|
|
});
|
|
drainQueueRef.current();
|
|
},
|
|
[touchCachedPreview],
|
|
);
|
|
|
|
const observePreview = useCallback(
|
|
(element: HTMLElement, resource: ProjectResource, identity: string) => {
|
|
const binding = { resource, identity };
|
|
observedCardsRef.current.set(element, binding);
|
|
observerRef.current?.observe(element);
|
|
return () => {
|
|
observerRef.current?.unobserve(element);
|
|
if (observedCardsRef.current.get(element) === binding) {
|
|
observedCardsRef.current.delete(element);
|
|
}
|
|
};
|
|
},
|
|
[],
|
|
);
|
|
|
|
const failPreview = useCallback(
|
|
(identity: string, error: string, retryable = false) => {
|
|
if (!validIdentitiesRef.current.has(identity)) {
|
|
return;
|
|
}
|
|
publishPreview(identity, { status: 'failed', error, retryable });
|
|
},
|
|
[publishPreview],
|
|
);
|
|
|
|
const protectPreview = useCallback((identity: string | null) => {
|
|
protectedIdentityRef.current = identity;
|
|
}, []);
|
|
|
|
useLayoutEffect(() => {
|
|
if (scopeInitializedRef.current) {
|
|
const previousScopeId = scopeIdRef.current;
|
|
scopeIdRef.current = createProjectResourcePreviewScopeId();
|
|
cancelLocalProjectResourcePreviewScope(previousScopeId);
|
|
} else {
|
|
scopeInitializedRef.current = true;
|
|
}
|
|
scopeEpochRef.current += 1;
|
|
activeReadsRef.current = {
|
|
scopeEpoch: scopeEpochRef.current,
|
|
count: 0,
|
|
};
|
|
queueRef.current = [];
|
|
queuedIdentitiesRef.current.clear();
|
|
pendingIdentitiesRef.current.clear();
|
|
disposeAllCachedPreviews();
|
|
cacheOrderRef.current.clear();
|
|
protectedIdentityRef.current = null;
|
|
previewsRef.current = new Map();
|
|
setPreviews(new Map());
|
|
imageDimensionsByIdentityRef.current = new Map();
|
|
setImageDimensionsByIdentity(new Map());
|
|
}, [disposeAllCachedPreviews, scopeKey]);
|
|
|
|
useEffect(() => {
|
|
const validIdentities = new Set(identityByResourceId.values());
|
|
const current = previewsRef.current;
|
|
const next = new Map(
|
|
Array.from(current).filter(([identity]) => validIdentities.has(identity)),
|
|
);
|
|
for (const identity of Array.from(cacheOrderRef.current.keys())) {
|
|
if (!validIdentitiesRef.current.has(identity)) {
|
|
cacheOrderRef.current.delete(identity);
|
|
disposeCachedPreview(identity);
|
|
}
|
|
}
|
|
if (next.size !== current.size) {
|
|
previewsRef.current = next;
|
|
setPreviews(next);
|
|
}
|
|
const currentImageDimensions = imageDimensionsByIdentityRef.current;
|
|
const nextImageDimensions = new Map(
|
|
Array.from(currentImageDimensions).filter(([identity]) =>
|
|
validIdentities.has(identity),
|
|
),
|
|
);
|
|
if (nextImageDimensions.size !== currentImageDimensions.size) {
|
|
imageDimensionsByIdentityRef.current = nextImageDimensions;
|
|
setImageDimensionsByIdentity(nextImageDimensions);
|
|
}
|
|
}, [disposeCachedPreview, identityByResourceId]);
|
|
|
|
useEffect(() => {
|
|
const eagerPreviewLimit = Math.max(
|
|
0,
|
|
Math.min(
|
|
PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT,
|
|
Math.floor(input.eagerPreviewLimit ?? 0),
|
|
),
|
|
);
|
|
let requested = 0;
|
|
for (const resource of input.resources) {
|
|
if (requested >= eagerPreviewLimit) {
|
|
break;
|
|
}
|
|
const identity = identityByResourceId.get(resource.id);
|
|
const kind = projectResourceCardPreviewKind(resource);
|
|
if (
|
|
identity &&
|
|
kind !== 'version' &&
|
|
kind !== 'placeholder' &&
|
|
kind !== 'audio'
|
|
) {
|
|
requestPreview(resource, identity, 'visible');
|
|
requested += 1;
|
|
}
|
|
}
|
|
}, [
|
|
identityByResourceId,
|
|
input.eagerPreviewLimit,
|
|
input.resources,
|
|
requestPreview,
|
|
]);
|
|
|
|
const imageDimensionsByResourceId = useMemo(() => {
|
|
const result = new Map<
|
|
string,
|
|
NonNullable<ReturnType<typeof projectResourceCardPreviewImageDimensions>>
|
|
>();
|
|
for (const resource of input.resources) {
|
|
const identity = identityByResourceId.get(resource.id);
|
|
const kind = projectResourceCardPreviewKind(resource);
|
|
if (kind !== 'raster-image' && kind !== 'media-image') {
|
|
continue;
|
|
}
|
|
const dimensions = identity
|
|
? imageDimensionsByIdentity.get(identity)
|
|
: undefined;
|
|
if (dimensions) {
|
|
result.set(resource.id, dimensions);
|
|
}
|
|
}
|
|
return result;
|
|
}, [identityByResourceId, imageDimensionsByIdentity, input.resources]);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
cancelLocalProjectResourcePreviewScope(scopeIdRef.current);
|
|
scopeEpochRef.current += 1;
|
|
queueRef.current = [];
|
|
queuedIdentitiesRef.current.clear();
|
|
pendingIdentitiesRef.current.clear();
|
|
activeReadsRef.current = {
|
|
scopeEpoch: scopeEpochRef.current,
|
|
count: 0,
|
|
};
|
|
disposeAllCachedPreviews();
|
|
},
|
|
[disposeAllCachedPreviews],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const IntersectionObserverClass = window.IntersectionObserver;
|
|
if (!IntersectionObserverClass) {
|
|
return undefined;
|
|
}
|
|
const observer = new IntersectionObserverClass(
|
|
(entries) => {
|
|
for (const entry of entries) {
|
|
if (!entry.isIntersecting) {
|
|
continue;
|
|
}
|
|
const binding = observedCardsRef.current.get(
|
|
entry.target as HTMLElement,
|
|
);
|
|
if (binding) {
|
|
requestPreview(binding.resource, binding.identity, 'visible');
|
|
}
|
|
}
|
|
},
|
|
{
|
|
root: input.intersectionRootRef?.current ?? input.canvasRef.current,
|
|
rootMargin: '160px',
|
|
},
|
|
);
|
|
observerRef.current = observer;
|
|
for (const element of observedCardsRef.current.keys()) {
|
|
observer.observe(element);
|
|
}
|
|
return () => {
|
|
observer.disconnect();
|
|
if (observerRef.current === observer) {
|
|
observerRef.current = null;
|
|
}
|
|
};
|
|
}, [input.canvasRef, input.intersectionRootRef, requestPreview, scopeKey]);
|
|
|
|
return {
|
|
identityByResourceId,
|
|
previews,
|
|
imageDimensionsByResourceId,
|
|
idlePreview: IDLE_PROJECT_RESOURCE_CARD_PREVIEW,
|
|
observePreview,
|
|
requestPreview,
|
|
failPreview,
|
|
protectPreview,
|
|
};
|
|
}
|