/** * 每个 editor 的临时交互会话:多个 view 各自保存选择/视口/工具/mode,共用一份已提交文档。 * 同时至多存在一个节点或连线手势;baseline/preview 均是短生命周期状态,不写 history/ledger。 * pointerup 时才通过 editor 提交净修改;取消仅清预览,不回写旧整节点,因此不会撤掉晚到资源 ACK。 */ import type { Editor } from './editor.ts'; import type { Availability, Capability, ElementRecord, Geometry, Id, Json, Result, Target } from './types.ts'; import { geometryValid, targetElement } from './document.ts'; import {createGesturePlacementReader} from './gesture-placement.ts'; import { clone, equal, fault, freeze, readMap, readSet, reject, validJson } from './values.ts'; /** 单视图状态:selection/modes 指向元素 ID,viewport 是画布 pan/zoom,均不属于持久模型。 */ export type ViewState = { readonly id: Id; readonly canvasId: Id; readonly selection: ReturnType>; readonly viewport: { readonly x: number; readonly y: number; readonly zoom: number }; readonly tool: 'select' | 'pan'; readonly modes: ReadonlyMap }; /** 节点手势:token 区分先后两次手势,targets 带 incarnation,preview 只存几何。 */ export type GestureState = { readonly token: number; readonly viewId: Id; readonly targets: readonly Target[]; readonly preview: ReadonlyMap; readonly phase: 'active' | 'committing' }; /** 连线路径预览与 committed path 分开;committing 阶段避免自己的提交被观察者当外部变化取消。 */ export type ConnectionGesture = { readonly token: number; readonly viewId: Id; readonly target: Target; readonly path: Json; readonly phase: 'active' | 'committing' }; /** 只读会话快照;lastCancelReason 解释取消原因,不代表持久任务失败或撤销。 */ export type SessionSnapshot = { readonly views: ReadonlyMap; readonly gesture: GestureState | null; readonly connectionGesture: ConnectionGesture | null; readonly lastCancelReason: string | null }; /** Transient interaction state belongs to views; no pointer event enters the ledger. */ /** 创建会话管理器并订阅 editor,随发布清理失效选中项/mode、取消失效手势;dispose 时解除订阅。 */ 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. let publishedDocument=editor.getSnapshot().document; const listeners = new Set<() => void>(); /** 对监听列表做副本迭代并隔离异常,renderer 出错不能影响已提交模型。 */ 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'){ const owned=cancelOwnedView!==undefined&&(snapshot.gesture?.viewId===cancelOwnedView||snapshot.connectionGesture?.viewId===cancelOwnedView); if(owned){baseline.clear();connectionBaseline=undefined;} snapshot=Object.freeze({...snapshot,views:readMap(views),...(owned?{gesture:null,connectionGesture:null,lastCancelReason:reason}:{})});notify(); } /** 复制视图表并替换一项,可在同一发布中带上切工具/切 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 不重复通知。 */ 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(); } // 文档/运行时发布后重新检查临时状态:模式与代次、owner、组件版本绑定,不能跟随同 ID 复活。 const detachEditor = editor.subscribe(() => { const document = editor.getSnapshot().document,beforeDocument=publishedDocument;publishedDocument=document; const views = new Map(snapshot.views); let changed = false; for (const [id, view] of views) { // 当前 canvas 被删除时回到根;失效 selection 清理,模式还需检查相邻发布的元素身份。 const canvasMissing = !document.canvases.has(view.canvasId); 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; })); if (canvasMissing || selection.length !== view.selection.size || modes.size !== view.modes.size) { views.set(id, freeze({ ...view, canvasId: canvasMissing ? [...document.canvases.values()].find(canvas => !canvas.parent)!.id : view.canvasId, selection: readSet(canvasMissing ? [] : selection), modes: readMap(canvasMissing ? new Map() : modes) })); changed = true; } } let cancelReason:string|undefined; // 只审查 active;committing 的自身发布由提交者负责清理,避免误判为外部干扰。 const active=snapshot.gesture?.phase==='active'?snapshot.gesture:snapshot.connectionGesture?.phase==='active'?snapshot.connectionGesture:undefined; const activeView=active?snapshot.views.get(active.viewId):undefined; if(activeView&&!document.canvases.has(activeView.canvasId))cancelReason='canvas_changed'; // 对比 view 内祖先入口链;只有目标 geometry 相同仍不够,父级旋转/缩放也会改变坐标上下文。 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 不应自动取消它。 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'; else if(placementChanged(current.ownerCanvasId))cancelReason='coordinate_context_changed'; else if (!editor.can({ type: 'connection.path', target: snapshot.connectionGesture.target, path: current.path }).enabled) cancelReason='permission_changed'; } // 节点几何预览同理:几何/身份变化优先取消;然后核对祖先上下文和当前命令权限。 if (snapshot.gesture?.phase === 'active'&&!cancelReason) for (const target of snapshot.gesture.targets) { const before = baseline.get(target.id), current = document.elements.get(target.id); if (!current || current.structure !== 'node' || before?.structure !== 'node' || current.incarnation !== before.incarnation || current.ownerCanvasId !== before.ownerCanvasId || current.componentId!==before.componentId || current.dataVersion!==before.dataVersion || !equal(current.geometry, before.geometry)) { cancelReason='target_changed'; break; } if(placementChanged(current.ownerCanvasId)){cancelReason='coordinate_context_changed';break;} if (!editor.can({ type: 'element.geometry', target, patch: {} }).enabled) { cancelReason='permission_changed'; break; } } if(changed||cancelReason){ if(cancelReason){baseline.clear();connectionBaseline=undefined;} snapshot=Object.freeze({...snapshot,...(changed?{views:readMap(views)}:{}),...(cancelReason?{gesture:null,connectionGesture:null,lastCancelReason:cancelReason}:{})});notify(); } }); /** 读取已挂载且管理器仍开放的 view,不让旧组件回调操作已销毁的会话。 */ function view(id: Id) { const found = snapshot.views.get(id); if (!found || disposed) throw fault('view_detached', [id]); return found; } /** 更新节点预览:校验手势所有权,将 patch 交给 capability handler,最后仅发布 session。 */ function update(viewId: Id, target: Target, patch: Partial): Availability { try { view(viewId); if (snapshot.gesture?.viewId !== viewId || !snapshot.gesture.targets.some(value => value.id === target.id && value.incarnation === target.incarnation)) throw fault('gesture_not_owned', [target.id]); const original = targetElement(editor.getSnapshot().document, target); if (original.structure !== 'node') throw fault('unsupported_capability', [target.id]); if (Object.keys(patch).some(key => !['x', 'y', 'width', 'height', 'rotation'].includes(key))) throw fault('invalid_geometry_patch', [target.id]); // 以最新 committed 记录合并已有预览几何,保留其余最新字段,不用开始时的旧内容覆盖。 let element: ElementRecord = freeze({ ...original, geometry: snapshot.gesture.preview.get(target.id) || original.geometry }); const parts: [Capability, Partial][] = [ ['geometry.move', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'x' || key === 'y'))], ['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。 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 }) }); for (const change of changes) { if (change.field !== 'geometry' || change.target.id !== target.id || change.target.incarnation !== target.incarnation) throw fault('invalid_handler_change', [target.id]); const geometry = change.after as Geometry; if (!geometryValid(geometry)) throw fault('invalid_geometry', [target.id]); element = freeze({ ...element, geometry }); } } // 更新 preview 不触发 editor 命令,所以 pointermove 不制造 history 或出站请求。 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 }; } catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; } } return Object.freeze({ /** 外部订阅读取当前只读会话,不能直接修改内部 Map/Set。 */ getSnapshot: () => snapshot, /** 创建独立 view,返回带 attached 标记的幂等卸载函数;只释放这个 view 与其手势。 */ 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(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 为只读选择集合;这是会话选择,不是文档命令。 */ 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。 */ 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'); }, /** 保存某元素的临时编辑模式;空值移除,不创建持久的业务字段。 */ 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,拒绝不会留下半组手势。 */ begin(viewId: Id, targets: readonly Target[]): Availability { try { if(view(viewId).tool!=='select')throw fault('tool_not_select'); if (snapshot.gesture || snapshot.connectionGesture || !targets.length || new Set(targets.map(target => target.id)).size !== targets.length) throw fault('gesture_busy'); const placement=createGesturePlacementReader(editor.getSnapshot().document,view(viewId).canvasId,editor.registry); const plannedBaseline = new Map(targets.map(target => { const element = targetElement(editor.getSnapshot().document, target); if (element.structure !== 'node' || editor.registry.resolve(element) !== 'known') throw fault('unsupported_component', [target.id]); return [target.id, element]; })); for (const target of targets) { placement(plannedBaseline.get(target.id)!.ownerCanvasId);const allowed = editor.can({ type: 'element.geometry', target, patch: {} }); if (!allowed.enabled) throw fault(allowed.code, allowed.targets); } // 成功时分配新 token 并清取消原因;初始 preview 为空,尚无任何文档变化。 baseline=plannedBaseline; snapshot = Object.freeze({ ...snapshot, lastCancelReason: null, gesture: freeze({ token: ++nextGestureToken, viewId, targets: targets.map(target => ({ ...target })), preview: readMap(new Map()), phase: 'active' as const }) }); notify(); return { enabled: true }; } catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; } }, /** 开始连线编辑,保存当前 path/身份与可见坐标链,通过 editor.can 后才建立会话。 */ beginConnection(viewId: Id, target: Target): Availability { try { if(view(viewId).tool!=='select')throw fault('tool_not_select'); if (snapshot.gesture || snapshot.connectionGesture) throw fault('gesture_busy'); const element = targetElement(editor.getSnapshot().document, target); if (element.structure !== 'connection') throw fault('unsupported_capability', [target.id]); createGesturePlacementReader(editor.getSnapshot().document,view(viewId).canvasId,editor.registry)(element.ownerCanvasId); const allowed = editor.can({ type: 'connection.path', target, path: element.path }); if (!allowed.enabled) throw fault(allowed.code, allowed.targets); connectionBaseline = element; snapshot = Object.freeze({ ...snapshot, lastCancelReason: null, connectionGesture: freeze({ token: ++nextGestureToken, viewId, target: { ...target }, path: element.path, phase: 'active' as const }) }); notify(); return { enabled: true }; } catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; } }, /** 捕获合法 JSON 路径预览;具体路径协议与当前权限仍在真正 editor 提交时重新校验。 */ updateConnection(viewId: Id, path: Json): Availability { try { view(viewId); if (snapshot.connectionGesture?.viewId !== viewId) throw fault('gesture_not_owned'); if (!validJson(path)) throw fault('invalid_connection_path'); snapshot = Object.freeze({ ...snapshot, connectionGesture: freeze({ ...snapshot.connectionGesture, path: clone(path) }) }); notify(); return { enabled: true }; } catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; } }, /** 连线结束只 dispatch 一次;先标 committing,结束后无论成功/拒绝都清预览。 */ commitConnection(viewId: Id): Result { try { view(viewId); const gesture = snapshot.connectionGesture; if (!gesture || gesture.viewId !== viewId) throw fault('gesture_not_owned'); snapshot = Object.freeze({ ...snapshot, connectionGesture: freeze({ ...gesture, phase: 'committing' as const }) }); const result = editor.dispatch({ type: 'connection.path', target: gesture.target, path: gesture.path }, { label: 'Edit connection' }); cancel(result.status === 'rejected' ? result.code : 'committed'); return result; } catch (error) { cancel('commit_failed'); return reject(error); } }, update, /** 节点手势提交一个同步事务,把各目标相对 baseline 的净字段变化合成一个 Transform 历史项。 */ commit(viewId: Id): Result { try { view(viewId); const gesture = snapshot.gesture; if (!gesture || gesture.viewId !== viewId) throw fault('gesture_not_owned'); snapshot = Object.freeze({ ...snapshot, gesture: freeze({ ...gesture, phase: 'committing' as const }) }); const result = editor.transaction(tx => { for (const [id, geometry] of gesture.preview) { const original = baseline.get(id); if (!original || original.structure !== 'node') throw fault('target_changed', [id]); // 只发送实际变化的几何键,避免覆盖未由此次手势修改的值;其余 data 从不回写。 const patch = Object.fromEntries(Object.entries(geometry).filter(([key, value]) => !equal(original.geometry[key as keyof Geometry], value))); tx.dispatch({ type: 'element.geometry', target: { id, incarnation: original.incarnation }, patch }); } }, { label: 'Transform' }); cancel(result.status === 'rejected' ? result.code : 'committed'); return result; } catch (error) { cancel('commit_failed'); return reject(error); } }, cancel, /** 渲染时优先读取预览,否则读取最新 committed 几何;取消后自然显露最新模型。 */ 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); }, /** 订阅会话变化;退订只清监听,不负责取消持久上传或关闭整个 editor。 */ subscribe(listener: () => void) { if (disposed) throw fault('sessions_disposed'); listeners.add(listener); return () => { listeners.delete(listener); }; }, /** 幂等销毁:解绑 editor、清临时会话并通知一次,然后释放全部 session listener。 */ dispose() { if(disposed)return;disposed = true;detachEditor();baseline.clear();connectionBaseline=undefined;snapshot = Object.freeze({ ...snapshot, views: readMap(new Map()),gesture:null,connectionGesture:null,lastCancelReason:'disposed' }); notify();listeners.clear(); }, }); }