69 lines
5.1 KiB
TypeScript
69 lines
5.1 KiB
TypeScript
/**
|
|
* 一个视图的元素→资源显示租约管理。按元素保留未变化的已加载 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<DisplayHandle>;
|
|
onChange(sources: Map<Id, ResourceViewSource>, failures: number): void;
|
|
onError?(error: unknown): void;
|
|
}) {
|
|
// records 是当前尝试表,targets 保留最近一次期望集合供 retry;同资源用于不同元素仍各有租约。
|
|
const records = new Map<Id, Record>(); let targets = new Map<Id, Id>(), 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<Id, Id>();
|
|
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(); },
|
|
});
|
|
}
|