/** * 一个视图的元素→资源显示租约管理。按元素保留未变化的已加载 URL,各图片独立完成就独立发布。 * 这里只借用资源句柄;不拥有资源原件、上传任务或业务文档,视图销毁应释放租约而非删除资源。 */ import type { Id } from '../canvas-core/types.ts'; import { fault } from '../canvas-core/values.ts'; import type { DisplayHandle } from '../canvas-runtime/resources.ts'; /** 一个元素当前一次解析尝试,控制器和记录对象身份共同隔离迟到结果。 */ type Record = { readonly resource: Id; readonly controller: AbortController; handle?: DisplayHandle; failed: boolean }; /** 对界面只暴露 URL 及对应资源 ID,避免只按元素 ID 把旧图片结果应用到新资源。 */ export type ResourceViewSource = { readonly resourceId: Id; readonly url: string }; /** A view owns subscriptions/leases, never a resource or document store. Retain * unchanged nodes, publish each ready source independently, and fence late work. */ /** resolve 由 engine/display service 注入,onChange 收到新的已就绪集合和当前失败数。 * sync 输入是完整目标集合,retry 只重建失败项,dispose 请求中止在途并归还已有句柄。 * 工厂不立即 resolve;每个元素一次独立尝试,同资源的多个元素不在此合并(底层缓存可共享读取)。 * resolve 须返回合法 DisplayHandle;本模块不校验 URL/下载权限或图片内容,回调错误仅报告。 */ export function createResourceView(options: { resolve(resource: Id, signal: AbortSignal): Promise; onChange(sources: Map, failures: number): void; onError?(error: unknown): void; }) { // records 是当前尝试表,targets 保留最近一次期望集合供 retry;同资源用于不同元素仍各有租约。 const records = new Map(); let targets = new Map(), disposed = false; /** 错误展示抛错不影响租约释放或其它图片的加载。 */ const report = (error: unknown) => { try { options.onError?.(error); } catch { /* Observer errors do not own leases. */ } }; /** 每次重建 ready 元素→{resourceId,url} 的新 Map,另计 failed;pending 不计失败也不带空 URL。 * Map/条目未运行时冻结,但不暴露内部 record/handle;onChange 抛错被 report 隔离,不撤销已采纳句柄。 */ const publish = () => { try { options.onChange(new Map([...records].flatMap(([id, record]) => record.handle ? [[id, {resourceId:record.resource,url:record.handle.url}] as [Id, ResourceViewSource]] : [])), [...records.values()].filter(record => record.failed).length); } catch (error) { report(error); } }; /** 释放器自身抛错只报告,不让一张图片阻止其它图片清理。 */ const releaseHandle = (handle: DisplayHandle) => { try { handle.release(); } catch (error) { report(error); } }; /** 先中止在途解析,再归还已完成句柄;未完成解析的迟到成功也由下方结果边界归还。 */ const release = (record: Record) => { record.controller.abort(); if(record.handle)releaseHandle(record.handle); }; /** 先验证并捕获完整目标映射(元素 ID 唯一,资源 ID 可重复),再删除/换源/补新尝试。 * 未变化项包括 failed 都保留,因此同集合 sync 不隐式重试;显式 retry 才重建失败项。 * 变化时先发布当前 ready 集合,各 resolve 独立完成再各自发布,不等全组 Promise.all。 */ function sync(next: readonly (readonly [Id, Id])[]) { if (disposed) throw fault('resource_view_disposed'); const desired = new Map(); for (const [id, resource] of next) { if (typeof id !== 'string' || !id || typeof resource !== 'string' || !resource || desired.has(id)) throw fault('invalid_resource_view'); desired.set(id, resource); } targets = desired; let changed = false; for (const [id, record] of records) if (desired.get(id) !== record.resource) { records.delete(id); release(record); changed = true; } for (const [id, resource] of desired) if (!records.has(id)) { const record: Record = { resource, controller: new AbortController(), failed: false }; records.set(id, record); changed = true; // 延到微任务发起解析,先确认该尝试仍是当前记录;不是简单按 ID 接受任意迟到 URL。 void Promise.resolve().then(() => { if (disposed || records.get(id) !== record) throw fault('display_aborted'); return options.resolve(resource, record.controller.signal); }).then(handle => { // 即使底层不响应 abort,旧尝试成功返回也必须立刻 release,不能挂到新元素状态上。 if (disposed || records.get(id) !== record) { releaseHandle(handle); return; } record.handle = handle; publish(); }, error => { // 取消/换源的旧失败不增加失败数;真正当前失败才发布并允许 retry。 if (disposed || records.get(id) !== record || record.controller.signal.aborted) return; record.failed = true; publish(); report(error); }); } if (changed) publish(); } return Object.freeze({ sync, /** 先撤掉失败尝试并释放,再用最近 targets 重新 sync;ready/pending 项继续复用。 * 没有失败项也不额外发布,不重新授权所有成功资源或重建业务元素。 */ retry() { if (disposed) throw fault('resource_view_disposed'); for (const [id, record] of records) if (record.failed) { records.delete(id); release(record); } sync([...targets]); }, /** 同步标 disposed、请求 abort 并 release 已有句柄,清目标/记录后发布空集合。 * 不等待 resolve Promise 退出;迟到成功由记录身份/ disposed 检查归还,后续 sync/retry 明确拒绝。 */ dispose() { if (disposed) return; disposed = true; for (const record of records.values()) release(record); records.clear(); targets.clear(); publish(); }, }); }