docs(core): clarify view session and gesture lifecycle boundaries

This commit is contained in:
2026-11-12 12:35:23 +08:00
parent f1c5853fea
commit 5fd8312ef5
2 changed files with 40 additions and 18 deletions
+5 -1
View File
@@ -12,8 +12,10 @@ type PlacementStep={canvasId:Id;parentCanvasId:Id;entryId:Id;incarnation:number;
/** A short-lived reader of the visible owner chain relative to one view, not the project root.
* Cache by owner only during this admission/publication, so multi-selection shares ancestry work. */
/** 给定不可变文档/view/registry,返回短生命周期 owner→入口链读取器。
* Map 只在一次准入或发布比较中复用,多选共享祖先扫描,不能跨文档更新继续使用。 */
* Map 只在一次准入或发布比较中复用,多选共享祖先扫描,不能跨文档更新继续使用。
* 返回数组/步骤没有运行时 freeze,geometry 也沿用记录引用;readonly 是调用约定,不应修改缓存结果。 */
export function createGesturePlacementReader(document:Document,viewCanvasId:Id,registry:Registry){
// 链按 owner→view 方向排列,不包含 view 自身在更上级的入口;读同一 owner 会返回同一个数组。
const values=new Map<Id,readonly PlacementStep[]>();
/** 向 view 回溯目标拥有链,缺失/循环/隐藏入口明确拒绝;成功后才缓存完整链。 */
return(ownerCanvasId:Id):readonly PlacementStep[]=>{
@@ -24,6 +26,7 @@ export function createGesturePlacementReader(document:Document,viewCanvasId:Id,r
while(id!==viewCanvasId){
if(seen.has(id))throw fault('canvas_cycle',[id]);seen.add(id);
const parent=document.canvases.get(id)?.parent;
// owner 缺失或已回溯到无父根,统一视为不在此 view;仅 view 本身缺失在上方报 canvas_missing。
if(!parent)throw fault('target_outside_view',[ownerCanvasId]);
const entry=document.elements.get(parent.entryElementId);
// A future/unknown entry is a collapsed placeholder; never interpret its payload.
@@ -31,6 +34,7 @@ export function createGesturePlacementReader(document:Document,viewCanvasId:Id,r
// 未知/未来入口已在上方拒绝;已知入口仍可能折叠,折叠子内容不允许开始手势。
const raw=entry.data.presentation,presentation=raw&&typeof raw==='object'&&!Array.isArray(raw)?raw:{};
if(presentation.collapsed===true)throw fault('target_hidden',[ownerCanvasId]);
// 签名只读这些坐标字段,不校验整个 presentation;contentScale 这里仅检查 number 且 >0。
const contentScale=typeof presentation.contentScale==='number'&&presentation.contentScale>0?presentation.contentScale:1;
chain.push({canvasId:id,parentCanvasId:parent.canvasId,entryId:entry.id,incarnation:entry.incarnation,componentId:entry.componentId,dataVersion:entry.dataVersion,geometry:entry.geometry,contentScale,unitScale:unitScale(raw)});
id=parent.canvasId;
+35 -17
View File
@@ -1,7 +1,9 @@
/**
* 每个 editor 的临时交互会话:多个 view 各自保存选择/视口/工具/mode,共用一份已提交文档。
* 同时至多存在一个节点或连线手势;baseline/preview 均是短生命周期状态,不写 history/ledger。
* pointerup 时才通过 editor 提交净修改;取消仅清预览,不回写旧整节点,因此不会撤掉晚到资源 ACK。
* 一个 manager 的临时交互会话:多个 view 各自保存选择/视口/工具/mode,共用 editor 已提交文档。
* 单个 manager 内至多一个节点或连线手势;同一 editor 若另建 manager,并不共享这项互斥。
* baseline/preview 均是短生命周期状态,不写 history/ledger。
* 输入绑定通常在 pointerup 调用 commit;本模块不接 DOM 事件,只有显式 commit 才发 editor 命令。
* 取消仅清预览,不回写旧整节点,因此不会撤掉晚到资源 ACK;session listener 仍由本模块同步通知。
*/
import type { Editor } from './editor.ts';
import type { Availability, Capability, ElementRecord, Geometry, Id, Json, Result, Target } from './types.ts';
@@ -24,10 +26,11 @@ export function createSessions(editor: Editor) {
let snapshot: SessionSnapshot = Object.freeze({ views: readMap(new Map<Id, ViewState>()), gesture: null, connectionGesture: null, lastCancelReason: null });
// 开始手势时捕获原记录,用它计算净几何差;提交时仍以 editor 当前数据为准,不恢复整条记录。
let baseline = new Map<Id, ElementRecord>(), connectionBaseline: ElementRecord | undefined, disposed = false, nextGestureToken = 0;
// Compare adjacent published identities without duplicating the per-view mode table.
// 相邻 editor 发布的文档身份用于 mode/坐标签名比较;runtime-only 发布仍会重新检查 editor.can 的当前准入。
let publishedDocument=editor.getSnapshot().document;
const listeners = new Set<() => void>();
/** 对监听列表做副本迭代并隔离异常,renderer 出错不能影响已提交模型。 */
/** 对监听列表做副本迭代并隔离异常,renderer 出错不能影响已提交模型。
* 会话本身没有 entered 重入闸门;监听器不应同步递归制造新会话操作。 */
const notify = () => { for (const listener of [...listeners]) try { listener(); } catch { /* A renderer cannot invalidate model commits. */ } };
/** 一次发布 views;若被中断的是手势拥有者,同时清 baseline 和预览,只通知一次。 */
function publishViews(views:Map<Id,ViewState>,cancelOwnedView?:Id,reason='view_interrupted'){
@@ -37,7 +40,8 @@ export function createSessions(editor: Editor) {
}
/** 复制视图表并替换一项,可在同一发布中带上切工具/切 canvas 等取消原因。 */
function setView(view: ViewState,cancelOwnedReason?:string) { const views = new Map(snapshot.views); views.set(view.id, view); publishViews(views,cancelOwnedReason?view.id:undefined,cancelOwnedReason); }
/** 幂等取消当前手势,保留 committed 文档及资源状态;重复 cancel 不重复通知。 */
/** 幂等取消本 manager 当前手势,保留 committed 文档及资源状态;重复 cancel 不重复通知。
* cancel 不接 viewId,属于 manager 级清理入口,不执行单 view 所有权校验。 */
function cancel(reason = 'canceled') {
if (!snapshot.gesture && !snapshot.connectionGesture) return;
baseline.clear(); connectionBaseline = undefined; snapshot = Object.freeze({ ...snapshot, gesture: null, connectionGesture: null, lastCancelReason: reason }); notify();
@@ -49,6 +53,7 @@ export function createSessions(editor: Editor) {
for (const [id, view] of views) {
// 当前 canvas 被删除时回到根;失效 selection 清理,模式还需检查相邻发布的元素身份。
const canvasMissing = !document.canvases.has(view.canvasId);
// selection 只保留仍存在的 ID,不按 incarnation/owner/可见性重新筛;mode 才比较相邻发布的完整身份字段。
const selection = [...view.selection].filter(target => document.elements.has(target)), modes = new Map([...view.modes].filter(([target]) => {
const before=beforeDocument.elements.get(target),after=document.elements.get(target);
return !!before&&!!after&&before.incarnation===after.incarnation&&before.ownerCanvasId===after.ownerCanvasId&&before.structure===after.structure&&before.componentId===after.componentId&&before.dataVersion===after.dataVersion;
@@ -64,7 +69,8 @@ export function createSessions(editor: Editor) {
const oldPlacement=activeView?createGesturePlacementReader(beforeDocument,activeView.canvasId,editor.registry):undefined,newPlacement=activeView?createGesturePlacementReader(document,activeView.canvasId,editor.registry):undefined;
/** 同文档无需重算;旧/新链不再可读或值变化都按坐标上下文失效处理。 */
const placementChanged=(owner:Id)=>{if(beforeDocument===document)return false;try{return !oldPlacement||!newPlacement||!equal(oldPlacement(owner),newPlacement(owner));}catch{return true;}};
// 连线预览只依赖 path、身份、owner 和准入;普通内容或资源 ACK 不应自动取消它。
// 连线预览检查 path、身份、owner、坐标祖先链和当前准入;普通内容/资源 ACK 本身不直接取消,
// 但若该发布使 policy 不再允许 connection.path,后面的 editor.can 仍会取消。
if (!cancelReason&&snapshot.connectionGesture?.phase === 'active') {
const current = document.elements.get(snapshot.connectionGesture.target.id), before = connectionBaseline;
if (!current || current.structure !== 'connection' || before?.structure !== 'connection' || current.incarnation !== before.incarnation || current.ownerCanvasId !== before.ownerCanvasId || current.componentId!==before.componentId || current.dataVersion!==before.dataVersion || !equal(current.path, before.path)) cancelReason='target_changed';
@@ -100,7 +106,8 @@ export function createSessions(editor: Editor) {
['geometry.resize', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'width' || key === 'height'))],
['geometry.rotate', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'rotation'))],
];
// move/resize/rotate 依次读取上一步预览结果;handler 只能给同目标生成合法 geometry。
// update 不以空 patch 做整命令 policy 预检,而是逐项检查实际使用的 capability、handler 输出目标与几何。
// move/resize/rotate 依次读取上一步预览结果;真正 commit 仍会通过 editor 再查 policy。
for (const [capability, value] of parts) if (Object.keys(value).length) {
if (!editor.registry.supports(element, capability)) throw fault('unsupported_capability', [target.id]);
const changes = editor.registry.handlers.get(element.componentId)!.get(capability)!({ element, input: freeze({ patch: value }) });
@@ -112,6 +119,7 @@ export function createSessions(editor: Editor) {
}
}
// 更新 preview 不触发 editor 命令,所以 pointermove 不制造 history 或出站请求。
// 即使 patch 结果相同,本路径仍替换会话快照并 notify,不像 tool/mode 有相同值早返回。
const preview = new Map(snapshot.gesture.preview); preview.set(target.id, (element as ElementRecord & { structure: 'node' }).geometry);
snapshot = Object.freeze({ ...snapshot, gesture: freeze({ ...snapshot.gesture, preview: readMap(preview) }) }); notify();
return { enabled: true };
@@ -120,36 +128,42 @@ export function createSessions(editor: Editor) {
return Object.freeze({
/** 外部订阅读取当前只读会话,不能直接修改内部 Map/Set。 */
getSnapshot: () => snapshot,
/** 创建独立 view,返回带 attached 标记的幂等卸载函数;只释放这个 view 与其手势。 */
/** 创建独立 view,返回内部带 attached 布尔标记的幂等卸载闭包;只释放这个 view 与其手势。
* 已 dispose 或重复 view ID 都报 duplicate_view;卸载不关闭 editor,也不停止 runtime 持久任务。 */
attach(id: Id, canvasId = [...editor.getSnapshot().document.canvases.values()].find(canvas => !canvas.parent)!.id) {
if (disposed || snapshot.views.has(id)) throw fault('duplicate_view', [id]);
if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]);
setView(freeze({ id, canvasId, selection: readSet<Id>([]), viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select', modes: readMap(new Map<Id, string>()) }));
let attached=true;return () => { if(!attached)return;attached=false;if(!snapshot.views.has(id))return;const views=new Map(snapshot.views);views.delete(id);publishViews(views,id,'view_detached'); };
},
/** 切换查看的 canvas,清 selection/modes,并取消属于该 view 的旧坐标手势。 */
/** 设置查看的 canvas,清 selection/modes,并取消属于该 view 的旧坐标手势。
* 即使传相同 canvasId 也走清理/通知;它不是相同值 no-op 的入口。 */
canvas(id: Id, canvasId: Id) {
const current = view(id);
if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]);
setView(freeze({ ...current, canvasId, selection: readSet<Id>([]), modes: readMap(new Map<Id, string>()) }),'canvas_changed');
},
/** 捕获有效元素 ID 为只读选择集合;这是会话选择,不是文档命令。 */
/** 捕获存在的元素 ID 为去重选择集合;不要求属于此 view、可见或已安装组件。
* selection 不携带 incarnation,也不是授权证据;begin/提交另行核对目标与准入。 */
select(id: Id, ids: readonly Id[]) {
const current = view(id), document = editor.getSnapshot().document;
for (const target of ids) if (!document.elements.has(target)) throw fault('element_missing', [target]);
setView(freeze({ ...current, selection: readSet(ids) }));
},
/** 保存有限 pan 和正 zoom;改变观察视口不修改元素几何或 history。 */
/** 保存有限 pan 和正 zoom;不修改元素几何/history,也不自动取消当前手势。
* 屏幕输入如何随 pan/zoom 换算由输入绑定负责,此处不保存 DOM 指针坐标。 */
viewport(id: Id, viewport: ViewState['viewport']) {
if (![viewport.x, viewport.y, viewport.zoom].every(Number.isFinite) || viewport.zoom <= 0) throw fault('invalid_viewport');
setView(freeze({ ...view(id), viewport: { ...viewport } }));
},
/** select/pan 切换会取消本 view 的手势;重复设置相同工具不发通知。 */
tool(id: Id, tool: ViewState['tool']) { const current=view(id);if(tool!=='select'&&tool!=='pan')throw fault('invalid_tool');if(current.tool===tool)return;setView(freeze({ ...current, tool }),'tool_changed'); },
/** 保存某元素的临时编辑模式;空值移除,不创建持久的业务字段。 */
/** 保存某元素的临时模式字符串;null/空串移除,非空值要求元素存在。
* 不在此枚举模式名或检查 capability;mode 不是可编辑权限,也不是持久业务字段。 */
mode(id: Id, target: Id, mode: string | null) { const current = view(id);if(mode!==null&&typeof mode!=='string')throw fault('invalid_mode');if(mode&&!editor.getSnapshot().document.elements.has(target))throw fault('element_missing',[target]);if((current.modes.get(target)??null)===(mode||null))return;const modes = new Map(current.modes); if (mode) modes.set(target, mode); else modes.delete(target); setView(freeze({ ...current, modes: readMap(modes) })); },
/** 节点手势准入:select 工具、无其它手势、目标去重/版本支持、可见拥有链及 policy 全部通过。
* 先把整组选区检查完再替换 baseline,拒绝不会留下半组手势。 */
* 先把整组目标检查完再替换 baseline,拒绝不会留下半组手势;targets 不必等于 view.selection。
* 空 geometry patch 的 editor.can 不调用 move/resize/rotate handler,实际能力仍在 update/commit 检查。 */
begin(viewId: Id, targets: readonly Target[]): Availability {
try {
if(view(viewId).tool!=='select')throw fault('tool_not_select');
@@ -188,7 +202,8 @@ export function createSessions(editor: Editor) {
return { enabled: true };
} catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; }
},
/** 连线结束只 dispatch 一次;先标 committing,结束后无论成功/拒绝都清预览。 */
/** 连线结束只 dispatch 一次;先标 committing,使 editor 发布监听暂不按 active 取消自己的提交。
* 正常提交和 catch 都调用 manager 级 cancel;错误 view 的提交调用也可能取消当前另一 view 的手势。 */
commitConnection(viewId: Id): Result {
try {
view(viewId);
@@ -200,7 +215,9 @@ export function createSessions(editor: Editor) {
} catch (error) { cancel('commit_failed'); return reject(error); }
},
update,
/** 节点手势提交一个同步事务,把各目标相对 baseline 的净字段变化合成一个 Transform 历史项。 */
/** 节点手势提交一个同步事务,仅遍历 preview 中的目标,合并相对 baseline 的净字段变化。
* 有实际文档变化时通常形成一个 Transform 历史项;空预览/无净变化遵守 editor 的 no-op 语义。
* 与连线提交相同,异常路径会取消本 manager 当前手势,而不只清理传入的 view。 */
commit(viewId: Id): Result {
try {
view(viewId);
@@ -221,7 +238,8 @@ export function createSessions(editor: Editor) {
} catch (error) { cancel('commit_failed'); return reject(error); }
},
cancel,
/** 渲染时优先读取预览,否则读取最新 committed 几何;取消后自然显露最新模型。 */
/** 按元素 ID 优先读取本 manager 预览,否则读取最新 committed 几何;取消后自然显露最新模型。
* 无 viewId 参数,不过滤调用者视图;投影/renderer 负责决定这个元素是否应在该 view 显示。 */
effectiveGeometry(id: Id): Geometry | undefined {
const element = editor.getSnapshot().document.elements.get(id);
return snapshot.gesture?.preview.get(id) || (element?.structure === 'node' ? element.geometry : undefined);