Files
Endoleap/src/lib/canvas-bindings/resource-view.ts
T

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(); },
});
}