diff --git a/src/lib/canvas-core/gesture-placement.ts b/src/lib/canvas-core/gesture-placement.ts index 5ad0dba..8eb71a8 100644 --- a/src/lib/canvas-core/gesture-placement.ts +++ b/src/lib/canvas-core/gesture-placement.ts @@ -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(); /** 向 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; diff --git a/src/lib/canvas-core/gestures.ts b/src/lib/canvas-core/gestures.ts index 0d4466d..47f48b1 100644 --- a/src/lib/canvas-core/gestures.ts +++ b/src/lib/canvas-core/gestures.ts @@ -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()), gesture: null, connectionGesture: null, lastCancelReason: null }); // 开始手势时捕获原记录,用它计算净几何差;提交时仍以 editor 当前数据为准,不恢复整条记录。 let baseline = new Map(), 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,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([]), viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select', modes: readMap(new Map()) })); 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([]), modes: readMap(new Map()) }),'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);