76 lines
5.9 KiB
TypeScript
76 lines
5.9 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 请求中止在途并归还已有句柄。
|
||
* 工厂不立即 resolve;每个元素一次独立尝试,同资源的多个元素不在此合并(底层缓存可共享读取)。
|
||
* resolve 须返回合法 DisplayHandle;本模块不校验 URL/下载权限或图片内容,回调错误仅报告。 */
|
||
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. */ } };
|
||
/** 每次重建 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<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;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(); },
|
||
});
|
||
}
|