Files
Endoleap/src/lib/canvas-core/gestures.ts
T

235 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 每个 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(); },
});
}