/** * 一个视图的元素→资源显示租约管理。按元素保留未变化的已加载 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 幂等释放所有在途/已完成句柄。 */ 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. */ } }; /** 只公布已拿到 handle 的元素,失败数另报;先复制 Map,不把内部记录交给界面修改。 */ 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 与重复项后再更新;删除或换源立即释放旧记录,未变化项继续复用。 */ 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;成功项不重新取 URL。 */ retry() { if (disposed) throw fault('resource_view_disposed'); for (const [id, record] of records) if (record.failed) { records.delete(id); release(record); } sync([...targets]); }, /** 中止并归还全部显示租约,发布空集合,后续 sync/retry 明确拒绝。 */ dispose() { if (disposed) return; disposed = true; for (const record of records.values()) release(record); records.clear(); targets.clear(); publish(); }, }); }