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'; /** * 容器 root 迟迟拿不到时的有界重试:5 次、1.6s 内退避到 512ms。 * * 重试只用于「把视口判定换回画本容器判定」这一次精度提升,不是门禁可用性的前提 —— * observer 在没有容器 root 时也会按视口建出来(见 observer effect 的注释), * 所以重试用尽也不会让任何卡片停在未观察状态。 */ const RESOURCE_PREVIEW_ROOT_RETRY_LIMIT = 5; const RESOURCE_PREVIEW_ROOT_RETRY_BASE_MS = 32; /** * 可见性兜底扫描的延迟点(毫秒)。 * * 门禁原先只有 `IntersectionObserver` 一条路:observer 没建出来、回调没送达、 * 或注册与创建交错,卡片就会**再也没有第二次机会**,表现为"可见卡停在 `idle`、 * 卡面只剩占位图标"。兜底扫描按**同一个可见性判据**独立复核一遍登记表, * 给这些时序缺陷一个恢复窗口。 * * 多个时间点而不是单次定时器:晚挂载的卡(分页切换、懒挂载)也要被覆盖到。 */ const RESOURCE_PREVIEW_VISIBLE_SWEEP_DELAYS_MS = [0, 250, 1000] as const; /** 视口判定允许的额外边界,与 observer 的 `rootMargin: '160px'` 对齐。 */ const RESOURCE_PREVIEW_VIEWPORT_MARGIN_PX = 160; type PreviewRequestReason = 'visible' | 'detail' | 'play'; type PreviewJob = { scopeKey: string; scopeId: string; scopeEpoch: number; identity: string; resource: ProjectResource; reason: PreviewRequestReason; /** 入队时「当前为谁预取」的标识;见 `prefetchScopeKey`。 */ prefetchScopeKey: string; }; 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 = { 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, }; } /** * 选下一个要跑的排队任务:**理由优先级固定 `play > detail > visible`,同一理由内当前预取作用域优先**。 * * 为什么需要"作用域优先"这一档:全局 3 槽跨视图共享,进入总览时队列里可能还压着 * 上一视图的可见性预取。若不做同级裁决,总览自己那几张"该出图"的卡会排在它们后面, * 用户感知就是"进总览要等图"。 * * ⚠️ 作用域**只做同一理由内的平手裁决**,绝不允许当前视图的 `visible` 越过 `play`: * 那会破坏 PRD §3.3.2 的固定调度优先级。早期实现用 `+3` 加权就踩过这个坑 * (当前视图的 visible 会压过上一视图的 play),由对应的契约用例钉住。 */ export function nextPreviewJob(queue: PreviewJob[], prefetchScopeKey: string) { const isPreferred = (job: PreviewJob) => job.prefetchScopeKey === prefetchScopeKey; const better = (candidate: PreviewJob, incumbent: PreviewJob) => { const candidateRank = previewRequestPriority[candidate.reason]; const incumbentRank = previewRequestPriority[incumbent.reason]; if (candidateRank !== incumbentRank) { return candidateRank > incumbentRank; } return isPreferred(candidate) && !isPreferred(incumbent); }; let nextIndex = 0; for (let index = 1; index < queue.length; index += 1) { if (better(queue[index]!, queue[nextIndex]!)) { nextIndex = index; } } return queue.splice(nextIndex, 1)[0]; } export function useProjectResourceCardPreviews(input: { projectPath: string; projectId: string; mode: ProjectResourceCanvasLayoutMode; /** * 身份与渲染口径,**必须传全量资源投影**。 * * 这里同时决定 `identityByResourceId`,而调用方在身份缺失时会直接不挂载卡片 * (`ProjectDevelopmentView` 的 `renderResourceBookCard` 对未知身份 `return null`)。 * 因此把本参数收窄成「当前分页栏目」会让总览里其它栏目只剩栏目标题栏和空的缩略卡片。 * 需要缩小预热范围请用 [`eagerResources`],不要改这里。 */ resources: ProjectResource[]; /** * 只用于热预取(`eagerPreviewLimit` 计数的那个集合),不影响身份 / 缓存 / 渲染。 * * 缺省回退到 `resources`。资源画布传「当前分页栏目」,避免用户看不到的栏目先占满 * 全局物理读取槽;传全量时前若干张可能落在别的栏目,看得见的卡片反而要排队。 */ eagerResources?: ProjectResource[]; canvasRef: React.RefObject; // 可见性判定容器。卡片渲染在资源画本场景里时,传真正包含卡片的容器; // 缺省回退到 canvasRef。 intersectionRootRef?: React.RefObject; eagerPreviewLimit?: number; previewVersionByResourceId?: ReadonlyMap; /** * 「当前为谁预取」的标识(资源画布传视图标识,例如 `main` / `child:<栏目>`)。 * * 它变化时**只取消队列里 `visible` 理由的预取**:用户已经离开那个视图, * 继续为它排队读图没有任何收益,而这些排队的预取会**排在后面视图的按需请求前面** * (全局 3 槽跨越视图共享),把"进入总览后等图"变成纯等待。 * * 语义边界(刻意收窄): * - **不取消任何按需加载**:`detail` / `play` 理由的排队与在途一律保留; * - **不失效任何缓存与身份**:`cachedPreviewsRef` / `identityByResourceId` 原样保留, * 切回原视图不会重读已经拿到的图; * - **不留"取消后永不重试"的死角**:被取消的卡如果在几何上仍然可见, * 兜底扫描会重新按 `visible` 入队(扫描是"什么算可见"的仲裁者)。 */ prefetchScopeKey?: 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 >(new Map()); const [imageDimensionsByIdentity, setImageDimensionsByIdentity] = useState< Map< string, NonNullable> > >(new Map()); const [initialScopeId] = useState(createProjectResourcePreviewScopeId); /** * 当前预取作用域:优先用调用方显式传入的 `prefetchScopeKey`;没有传时, * 退化为**被喂进本 hook 的资源集合签名** —— 资源集合变了就等价于"换了要预取的对象", * 因此不强制调用方多接一根线(`index.tsx` 不必改)。 */ const prefetchScopeKey = input.prefetchScopeKey ?? JSON.stringify(input.resources.map((resource) => resource.id)); const prefetchScopeKeyRef = useRef(prefetchScopeKey); prefetchScopeKeyRef.current = prefetchScopeKey; 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(null); const observedCardsRef = useRef(new Map()); const queueRef = useRef([]); const queuedIdentitiesRef = useRef(new Map()); /** * 被队列上限吞掉的**按需**请求(`detail` / `play`)。 * * 这类丢弃此前完全不可见:用户在可见卡上点播放,没有请求、没有报错、没有 loading。 * 记为单调计数 + 最后一条明细,供排障与用例观测;`visible` 的背压不计入(那是设计内的)。 */ const droppedRequestsRef = useRef<{ count: number; last: { identity: string; reason: PreviewRequestReason; queueLength: number; at: number; } | null; }>({ count: 0, last: null }); const pendingIdentitiesRef = useRef(new Map()); const cacheOrderRef = useRef(new Map()); const cachedPreviewsRef = useRef(new Map()); const cacheBytesRef = useRef(0); const activeReadsRef = useRef({ scopeEpoch: 0, count: 0 }); const protectedIdentityRef = useRef(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); let evictedCount = 0; 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); /** * 淘汰必须知道"谁还在屏幕上"。 * * 缓存条目的顺序是「最近一次被请求」,而**停在屏幕上不动的卡片不会产生新的请求** * —— 于是恰恰是用户正看着的那几张排在队首:纯 LRU 淘汰会首选它们,表现为 * "图片自己消失又回来"。这里把当前可见集合交给淘汰函数,让它先只淘汰视口外的条目。 */ const visibleIdentities = new Set(); for (const [element, binding] of observedCardsRef.current) { if (viewportBandRef.current(element) <= 1) { visibleIdentities.add(binding.identity); } } const evictedIdentities = projectResourceCardPreviewEvictionIdentities( Array.from(cacheOrderRef.current.keys()).map((cachedIdentity) => ({ identity: cachedIdentity, retainedBytes: cachedPreviewsRef.current.get(cachedIdentity)?.retainedBytes ?? 0, })), protectedIdentityRef.current, visibleIdentities, ); for (const evictedIdentity of evictedIdentities) { cacheOrderRef.current.delete(evictedIdentity); disposeCachedPreview(evictedIdentity); next.delete(evictedIdentity); evictedCount += 1; } } previewsRef.current = next; setPreviews(next); if (evictedCount > 0) { /** * 驱逐之后立刻补一次可见性兜底扫描。 * * 被驱逐的 identity 从 `previews` 里被删掉,状态正好回到"从未请求"(`undefined`), * 而 [`sweepVisiblePreviews`] **只对 `undefined` 的卡重新入队** —— 也就是说 * 驱逐后仍停在视口里的卡只差这一次调用:不补,它的图片就凭空消失、退回占位图标, * 而且不会自己回来(`IntersectionObserver` 对一直相交的元素没有二次回调, * 下一次扫描只等 scope 变化的 0/250/1000ms、resize 或 visibilitychange)。 * * 调用点必须在 `previewsRef.current = next` **之后**:扫描读的是 `previewsRef`, * 早一步调用会看到被驱逐的卡仍是 `loaded`,于是什么都不做。 * * 重入性:重新入队走 `requestPreview` → `drainQueue` → `publishPreview({status:'loading'})`, * 而 `loading` 发布**不进**上面这个 `loaded` / `failed` 分支,因此不会再触发驱逐、 * 也不会再走到这里 —— 递归深度恒为 1,不自激。 */ sweepVisiblePreviewsRef.current(); } }, [disposeCachedPreview], ); const readPreview = useCallback( async ( job: PreviewJob, ): Promise => { 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( 'read_local_project_image_preview', { projectPath: input.projectPath, relativePath: job.resource.path, scopeId: job.scopeId, requestId: createProjectResourcePreviewRequestId(), }, ); } if (kind === 'document' || kind === 'code') { return invoke( 'read_local_project_text_preview', { projectPath: input.projectPath, relativePath: job.resource.path, scopeId: job.scopeId, requestId: createProjectResourcePreviewRequestId(), }, ); } return invoke( '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, prefetchScopeKeyRef.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) { /* * 队列被占满、又找不到可以顶掉的可见性预取时,这次请求只能丢掉。 * * `visible` 撞上限是设计内的背压(下一轮兜底扫描会把仍可见的卡补回来), * 但 **`detail` / `play` 是按需请求**:用户在可见卡上点了播放却什么都不发生, * 此前连一条痕迹都没有 —— 这里必须留下可观测标记(快照 + 日志)。 * 队列上限、3 个并发槽与优先级顺序都不放宽,只是把"被吞掉的那次"记下来。 */ if (reason !== 'visible') { droppedRequestsRef.current = { count: droppedRequestsRef.current.count + 1, last: { identity, reason, queueLength: queueRef.current.length, at: Date.now(), }, }; console.warn( `[preview-queue] 按需请求被队列上限丢弃 reason=${reason} queueLength=${queueRef.current.length}`, ); } 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, prefetchScopeKey: prefetchScopeKeyRef.current, }); drainQueueRef.current(); }, [touchCachedPreview], ); /** * 把当前登记表里的全部卡片元素补挂到 observer 上。 * * 注册(`observePreview`)与 observer 的创建分属不同 effect,两者存在时序窗口: * 在 observer 还没建好时注册的卡只能靠 observer effect 里的一次性补挂兜住, * 一旦那个补挂发生在注册之前、或 observer 之后被重建,卡就会**停留在登记表里 * 却从未被观察** —— 它永不触发可见性回调,也就永远停在 `idle`,卡面只剩占位图标。 * 所以每次 observer 就绪或重建后都要按登记表重新补挂一遍。 */ const attachObservedCards = useCallback(() => { const observer = observerRef.current; if (!observer) { return; } for (const element of observedCardsRef.current.keys()) { observer.observe(element); } }, []); /** * 创建 observer 时用的 root,用来判断它是否变了。 * * 取值只用于"要不要重建"的比较:`null` 表示**当时没有拿到容器 root**, * 这时仍然要按视口建 observer(见下),不能把"没有 root"当成"不建 observer"。 */ const observerRootRef = useRef(null); const [rootEpoch, setRootEpoch] = useState(0); /** * 本 scope 是否已经把 observer 建起来过。 * * 用来把"注册时触发的重建"限制在 observer 尚未就绪的阶段:一旦建好, * 后续注册只走 `observe` 快路径,不再触发重渲染。 */ const observerReadyRef = useRef(false); /** root 尚未就绪时的有界重试序号,避免"信号永不再来"导致 observer 永不创建。 */ const rootRetryRef = useRef(0); /** * 兜底扫描的转发 ref:`observePreview` 与 observer effect 都可能在 * `sweepVisiblePreviews` 定义之前建立,用 ref 避免把回调顺序写成隐式契约。 */ const sweepVisiblePreviewsRef = useRef<() => number>(() => 0); /** * 可见性档位判据的转发 ref:`publishPreview` 在它定义之前就要用它算"谁还在屏幕上", * 同样用 ref 避免把定义顺序写成隐式契约。 */ const viewportBandRef = useRef<(element: HTMLElement) => 0 | 1 | 2>(() => 2); const observePreview = useCallback( (element: HTMLElement, resource: ProjectResource, identity: string) => { const binding = { resource, identity }; observedCardsRef.current.set(element, binding); if (!observerReadyRef.current) { // 卡片一定渲染在资源画本容器内部,所以「有卡片注册」本身就是「root 已就绪」 // 的信号。observer 还没建好时用它触发一次重建,避免卡死在未观察状态。 // 在测试等没有 IntersectionObserver 的环境里保持静默,不制造多余渲染。 const root = input.intersectionRootRef?.current ?? input.canvasRef.current; if (root && window.IntersectionObserver) { setRootEpoch((epoch) => epoch + 1); } } observerRef.current?.observe(element); // 注册即复核:observer 回调可能还没送达,此时先按几何判一次可见性。 sweepVisiblePreviewsRef.current(); return () => { observerRef.current?.unobserve(element); if (observedCardsRef.current.get(element) === binding) { observedCardsRef.current.delete(element); } }; }, [input.canvasRef, input.intersectionRootRef], ); /** * 把一个卡元素判到三个档位: * `0` 落在视口内、`1` 只落在 `rootMargin`(160px)那一圈里、`2` 在放行范围之外。 * * 判据与 observer 的 `rootMargin: 160px` 对齐,但它是**独立复核**:门禁漏放行要修的 * 就是"trust observer 报过什么",所以不能拿同一条可能失效的通路当判据。 * * 两个用途共用同一套几何口径,避免"扫描一套、热预取另一套": * - `2` 是**放行门禁**(外圈不得请求,也不得进热预取,因此不会被全量预读吃掉预算); * - `0` / `1` 是**放行顺序**(先视口内、再余量圈,见 `requestPreviewCardsByViewportBand`)。 * * 尺寸量不出来(未布局、`0×0`)时判 `2`:宁可等一次 observer 回调或下一轮扫描, * 也不能凭"量不到"就当可见。 */ const viewportBandOfElement = useCallback( (element: HTMLElement): 0 | 1 | 2 => { const rect = element.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) { return 2; } const viewportWidth = window.innerWidth || document.documentElement?.clientWidth || 0; const viewportHeight = window.innerHeight || document.documentElement?.clientHeight || 0; if (viewportWidth <= 0 || viewportHeight <= 0) { return 2; } const withinBand = (margin: number) => rect.right >= -margin && rect.left <= viewportWidth + margin && rect.bottom >= -margin && rect.top <= viewportHeight + margin; if (withinBand(0)) { return 0; } return withinBand(RESOURCE_PREVIEW_VIEWPORT_MARGIN_PX) ? 1 : 2; }, [], ); viewportBandRef.current = viewportBandOfElement; /** * 按**两档**放行一批已确认可见的卡:先视口内的(档 0),再 `rootMargin` 圈里的(档 1)。 * * 冷启动首屏一次可能同时有十几二十张卡够格(真机口径:可见 ≈15、加上 160px 余量后 * 相交 ≈21),而物理读取只有 3 个槽(`PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY`)。 * 一次全部入队时,排在队列后面的"其实就在屏幕里"的卡要等前面那些最多 160px 外的卡读完 * —— 用户感知就是"首屏等图"。两档放行**只改入队顺序、不改总量**:档 1 的卡在同一次调用里 * 一样会被放行,所以兜底扫描不会退化成"只补第一档",3 槽 / 72 项 / 64 MiB 合同也不动。 */ const requestPreviewCardsByViewportBand = useCallback( ( cards: readonly { element: HTMLElement; resource: ProjectResource; identity: string; }[], options: { onlyIdle: boolean; limit?: number }, ) => { const limit = options.limit ?? Number.POSITIVE_INFINITY; const requestedIdentities = new Set(); let requested = 0; for (const band of [0, 1] as const) { for (const card of cards) { if (requested >= limit) { return requested; } if (requestedIdentities.has(card.identity)) { continue; } if (viewportBandOfElement(card.element) !== band) { continue; } if ( options.onlyIdle && previewsRef.current.get(card.identity)?.status !== undefined ) { continue; } requestedIdentities.add(card.identity); requestPreview(card.resource, card.identity, 'visible'); requested += 1; } } return requested; }, [requestPreview, viewportBandOfElement], ); /** * 可见性兜底扫描:把「已登记、确实在视口内、但还没有任何请求」的卡补一次 `visible`。 * * 它**不是第二条加载通路**(`requestPreview` 仍然只做去重、排优先级、走同一条队列), * 而是给"门禁漏放行"一个可恢复窗口。只对 `idle`(从未请求)的卡生效: * `loading` / `queued` / `loaded` / `failed` 一律不碰,所以不会绕过热预取合同、 * 也不会突破全局 3 槽与 LRU 预算。 * * 放行顺序按视口内 / 余量圈两档(见 `requestPreviewCardsByViewportBand`)。 */ const sweepVisiblePreviews = useCallback(() => { return requestPreviewCardsByViewportBand( Array.from(observedCardsRef.current, ([element, binding]) => ({ element, resource: binding.resource, identity: binding.identity, })), { onlyIdle: true }, ); }, [requestPreviewCardsByViewportBand]); sweepVisiblePreviewsRef.current = sweepVisiblePreviews; /** * 为 `prefetchKey` 取消队列里 `visible` 理由的预取(修法 2 + 4)。 * * 只下掉**还在排队**的可见性预取: * - `detail` / `play` 理由保留(按需加载不取消); * - 已经在途(`pendingIdentitiesRef`)的请求**不打断** —— 它只占 3 个槽中的 1 个, * 打断它既拿不回已花的读盘成本,也让"切回来"要重读; * - 缓存与身份不动,因此切回原视图不会重读已拿到的图; * - 下掉的 job 若几何上仍可见,会被随后的兜底扫描重新入队 ⇒ 没有"永不重试"死角。 */ const cancelQueuedVisiblePrefetches = useCallback(() => { const kept: PreviewJob[] = []; let cancelled = 0; for (const job of queueRef.current) { if (job.reason === 'visible') { cancelled += 1; if (queuedIdentitiesRef.current.get(job.identity) === job.scopeEpoch) { queuedIdentitiesRef.current.delete(job.identity); } continue; } kept.push(job); } queueRef.current = kept; return cancelled; }, []); // 视图切换即下掉上一视图的排队预取。修法 4 的"不再为离开的视图补发"也由它收口: // 离开后卡片卸载、observer 注销,任何新注册都会立即被这里清掉,除非该卡在几何上仍可见。 // // 依赖**派生后的** `prefetchScopeKey`(而不是可能为 undefined 的 `input.prefetchScopeKey`): // 调用方不传时它退化为资源集合签名,取消逻辑必须同样生效,否则就是一条永远不跑的死代码。 useEffect(() => { cancelQueuedVisiblePrefetches(); // 下掉之后立刻按几何复核一次:仍然可见的卡重新入队,不可见的自然不再请求。 sweepVisiblePreviews(); }, [cancelQueuedVisiblePrefetches, prefetchScopeKey, sweepVisiblePreviews]); /** * 兜底扫描的生命周期:scope 变化后按若干延迟点各扫一次,覆盖"卡晚挂载 / observer 迟到"; * 页面重新可见(用户切走再回来)和窗口尺寸变化也各扫一次,因为是同一批卡可能重新进入视口。 */ useEffect(() => { const timers = RESOURCE_PREVIEW_VISIBLE_SWEEP_DELAYS_MS.map((delay) => setTimeout(() => sweepVisiblePreviewsRef.current(), delay), ); const handleVisibility = () => { if (!document.hidden) { sweepVisiblePreviewsRef.current(); } }; window.addEventListener('resize', handleVisibility); document.addEventListener('visibilitychange', handleVisibility); return () => { for (const timer of timers) { clearTimeout(timer); } window.removeEventListener('resize', handleVisibility); document.removeEventListener('visibilitychange', handleVisibility); }; }, [scopeKey]); 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), ), ); // 只预热 `eagerResources`(缺省回退 `resources`):身份与缓存仍然覆盖全量投影, // 这里收窄的仅仅是「谁先占读取槽」。 const eagerResources = input.eagerResources ?? input.resources; const elementsByIdentity = new Map(); for (const [element, binding] of observedCardsRef.current) { elementsByIdentity.set(binding.identity, element); } /** * **热预取也要过可见性判据**:此前它按投影顺序盲取前 `eagerPreviewLimit` 张, * 条件里只有身份与 kind 过滤 —— 于是排在前面的视口外卡片先占满 3 个读取槽, * 屏幕里那些卡反而要排队,冷启动首屏就是"等图片"。 * * 现在只预取**几何上可见**(档 0 / 档 1)且已登记的卡,并按"先视口内、再余量圈" * 两档入队;`eagerPreviewLimit` 仍是硬上限,3 槽 / 48 项 / 64 MiB 合同不变。 */ const visibleCards: { element: HTMLElement; resource: ProjectResource; identity: string; }[] = []; for (const resource of eagerResources) { const identity = identityByResourceId.get(resource.id); if (!identity) { continue; } const kind = projectResourceCardPreviewKind(resource); if (kind === 'version' || kind === 'placeholder' || kind === 'audio') { continue; } const element = elementsByIdentity.get(identity); if (!element) { continue; } visibleCards.push({ element, resource, identity }); } const requested = requestPreviewCardsByViewportBand(visibleCards, { onlyIdle: true, limit: eagerPreviewLimit, }); if (requested > 0) { return; } /** * 一张可见卡都判不出来时(卡片尚未注册、容器还没布局出尺寸)保留原来的顺序预取, * **绝不把首屏热预取削成 0**:这时按投影顺序预热前若干张,至少保证首屏有图可读。 * 真正的放行顺序与去重仍然只有一条通路(`requestPreview`),这里不是第二条加载通路。 */ let fallbackRequested = 0; for (const resource of eagerResources) { if (fallbackRequested >= eagerPreviewLimit) { break; } const identity = identityByResourceId.get(resource.id); const kind = projectResourceCardPreviewKind(resource); if ( identity && kind !== 'version' && kind !== 'placeholder' && kind !== 'audio' ) { requestPreview(resource, identity, 'visible'); fallbackRequested += 1; } } }, [ identityByResourceId, input.eagerPreviewLimit, input.eagerResources, input.resources, requestPreview, requestPreviewCardsByViewportBand, ]); const imageDimensionsByResourceId = useMemo(() => { const result = new Map< string, NonNullable> >(); 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; } // root 是资源画本容器;它由被观察卡片所在的那棵子树持有。 // // 这里**绝不能用"root 还没就绪"当作"不建 observer"的理由**:本 effect 每个 scope // 实际只跑一次(`requestPreview` 是稳定引用,其余依赖在 scope 内不变),一旦这一轮 // 因为 root 为 null 直接返回,就再也没有第二次机会 —— 所有卡片永远不被观察、 // 永远停在 `idle`,整个栏目只剩占位图标。这是此前真实发生过的回归。 // 因此:拿到容器 root 最好,拿不到就退到 canvas、再退到视口(root: null), // 并且只要 root 还没就绪就有界重试,直到拿到容器 root 再重建一次。 const root = input.intersectionRootRef?.current ?? input.canvasRef.current; if (observerRef.current && observerRootRef.current === root) { // root 没变且 observer 已在:只补挂新注册的卡,不做无谓重建。 observerReadyRef.current = true; attachObservedCards(); return undefined; } observerRef.current?.disconnect(); const observer = new IntersectionObserverClass( (entries) => { /* * 一次回调往往同时报来十几二十张相交的卡(真机首屏口径:可见 ≈15、 * 加 160px 余量后 ≈21),而读取只有 3 个槽:谁先进队列,谁就先出图。 * * 这里**只改入队顺序**:视口内的(档 0)先入队,剩下报相交的(档 1,或几何上 * 量不出尺寸的卡)随后入队 —— 一张都不丢,所以既有"相交即放行"的语义不变, * 只是先看到的那几张先出图。判据复用 `viewportBandOfElement`,不做第二次门禁: * observer 已经报过相交,这里没有理由因为量不到尺寸而拒收。 */ const intersectingCards: { element: HTMLElement; resource: ProjectResource; identity: string; }[] = []; const deferredCards: typeof intersectingCards = []; for (const entry of entries) { if (!entry.isIntersecting) { continue; } const element = entry.target as HTMLElement; const binding = observedCardsRef.current.get(element); if (!binding) { continue; } const card = { element, resource: binding.resource, identity: binding.identity, }; if (viewportBandOfElement(element) === 0) { intersectingCards.push(card); } else { deferredCards.push(card); } } for (const card of intersectingCards) { requestPreview(card.resource, card.identity, 'visible'); } for (const card of deferredCards) { requestPreview(card.resource, card.identity, 'visible'); } }, { // `root: null` 表示按视口判定。它比"没有 observer"严格更好:屏幕上真实可见的卡 // 一定在视口矩形内、一定会被报为相交;代价只是视口外但仍在画布内的卡会晚一步 // 由滚动触发。拿不到容器 root 时宁可先用它,也不要让门禁整体失效。 root: root ?? null, rootMargin: '160px', }, ); observerRef.current = observer; observerReadyRef.current = true; observerRootRef.current = root; attachObservedCards(); // observer 刚就绪就立刻复核一遍登记表:注册与创建交错时漏掉的卡在这里被补上。 sweepVisiblePreviewsRef.current(); // 容器 root 迟到时重建一次,恢复"按画本容器裁剪"的精确判定。 let retryTimer: ReturnType | undefined; if (!root && rootRetryRef.current < RESOURCE_PREVIEW_ROOT_RETRY_LIMIT) { const attempt = rootRetryRef.current; rootRetryRef.current = attempt + 1; retryTimer = setTimeout( () => setRootEpoch((epoch) => epoch + 1), RESOURCE_PREVIEW_ROOT_RETRY_BASE_MS * 2 ** attempt, ); } return () => { if (retryTimer !== undefined) { clearTimeout(retryTimer); } observer.disconnect(); if (observerRef.current === observer) { observerRef.current = null; observerReadyRef.current = false; } if (observerRootRef.current === root) { observerRootRef.current = null; } }; }, [ attachObservedCards, input.canvasRef, input.intersectionRootRef, requestPreview, rootEpoch, scopeKey, viewportBandOfElement, ]); return { identityByResourceId, previews, imageDimensionsByResourceId, idlePreview: IDLE_PROJECT_RESOURCE_CARD_PREVIEW, observePreview, requestPreview, failPreview, protectPreview, /** * 只读的队列快照,供排障与测试观测"谁在排队、属于哪个预取作用域"。 * * 队列病理(排队被上一视图占满、可见卡被丢弃)此前只能靠猜;有了这个快照, * 「当前视图的卡是否真的进了队列」变成可直接断言的事实。不参与渲染,无副作用。 */ previewQueueSnapshot: () => ({ queue: queueRef.current.map((job) => ({ identity: job.identity, reason: job.reason, prefetchScopeKey: job.prefetchScopeKey, })), activeReadCount: activeReadsRef.current.count, prefetchScopeKey: prefetchScopeKeyRef.current, /** 被队列上限吞掉的按需请求条数;`visible` 的背压不计入。 */ droppedRequestCount: droppedRequestsRef.current.count, /** 最近一次被吞掉的按需请求明细,从未发生时为 `null`。 */ lastDroppedRequest: droppedRequestsRef.current.last, }), }; }