235 lines
22 KiB
TypeScript
235 lines
22 KiB
TypeScript
/**
|
||
* 每个 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<typeof readSet<Id>>; readonly viewport: { readonly x: number; readonly y: number; readonly zoom: number }; readonly tool: 'select' | 'pan'; readonly modes: ReadonlyMap<Id, string> };
|
||
/** 节点手势:token 区分先后两次手势,targets 带 incarnation,preview 只存几何。 */
|
||
export type GestureState = { readonly token: number; readonly viewId: Id; readonly targets: readonly Target[]; readonly preview: ReadonlyMap<Id, Geometry>; 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<Id, ViewState>; 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<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.
|
||
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<Id,ViewState>,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<Geometry>): 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>][] = [
|
||
['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<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(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 为只读选择集合;这是会话选择,不是文档命令。 */
|
||
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<Id, Geometry>()), 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<Id, ViewState>()),gesture:null,connectionGesture:null,lastCancelReason:'disposed' }); notify();listeners.clear(); },
|
||
});
|
||
}
|