docs(canvas): explain gestures, coordinate spaces and creation planning
This commit is contained in:
@@ -1,18 +1,33 @@
|
||||
/**
|
||||
* 嵌套 canvas 的坐标换算:Transform 把一个 canvas 的局部空间映射到项目世界空间。
|
||||
* 统一采用平移、等比缩放和旋转,不处理 skew/非等比父变换。owner 改变时通过世界空间中转,
|
||||
* 保持节点外观和连线曲线位置;表现单位的 data 修正由结构/编辑规划器另行生成。
|
||||
*/
|
||||
import type { Document, Geometry, Id, Json, Point } from './types.ts';
|
||||
import { ancestors } from './document.ts';
|
||||
import { fault } from './values.ts';
|
||||
import { closeNumber, closeScale, unitScale } from './units.ts';
|
||||
import { connectionPathValid } from './features.ts';
|
||||
/** 局部原点的世界位置、累计等比 scale 和角度(度);不含 viewport 的屏幕 pan/zoom。 */
|
||||
export type Transform = { x: number; y: number; scale: number; rotation: number };
|
||||
/** 平移/比例用数值容差比较,角度按最短环绕差比较,避免 0/360 附近误判。 */
|
||||
export const sameTransform=(a:Transform,b:Transform)=>closeNumber(a.x,b.x)&&closeNumber(a.y,b.y)&&closeScale(a.scale,b.scale)&&Math.abs(((a.rotation-b.rotation+540)%360)-180)<1e-9;
|
||||
/** 根 canvas 的恒等变换:其坐标本身就是这里定义的世界坐标。 */
|
||||
const origin: Transform = { x: 0, y: 0, scale: 1, rotation: 0 };
|
||||
/** 围绕原点旋转向量,先把角度转换为弧度;位置平移不在这个步骤处理。 */
|
||||
const rotate = (point: Point, angle: number): Point => { const a = angle * Math.PI / 180; return { x: point.x * Math.cos(a) - point.y * Math.sin(a), y: point.x * Math.sin(a) + point.y * Math.cos(a) }; };
|
||||
/** 局部→世界:先旋转、再等比缩放、最后加原点平移。 */
|
||||
export function toWorld(transform: Transform, point: Point): Point { const value = rotate(point, transform.rotation); return { x: transform.x + value.x * transform.scale, y: transform.y + value.y * transform.scale }; }
|
||||
/** 世界→局部的逆变换:先减平移、除比例,再反向旋转;调用方必须提供有效非零 scale。 */
|
||||
export function toLocal(transform: Transform, point: Point): Point { return rotate({ x: (point.x - transform.x) / transform.scale, y: (point.y - transform.y) / transform.scale }, -transform.rotation); }
|
||||
/** 将可识别的 JSON 对象视为字段表,空值/原始值/数组按空表处理,不修改源数据。 */
|
||||
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
/** 对已识别的 bezier-chain 换空间;未知路径原样返回,严格写入边界由 convertPath 把关。
|
||||
* knot.position 是位置,in/out 与首尾 control 是偏移向量,向量不能叠加平移。 */
|
||||
export function transformPath(path: Json, transform: Transform, inverse = false): Json {
|
||||
const raw = object(path);
|
||||
if (raw.type !== 'bezier-chain' || !Array.isArray(raw.knots)) return path;
|
||||
/** 保留点的扩展字段;vector 模式把变换平移归零,仅施加旋转/比例。 */
|
||||
const point = (input: Json | undefined, vector = false): Json | undefined => {
|
||||
const p = object(input); if (typeof p.x !== 'number' || typeof p.y !== 'number') return input;
|
||||
const t = vector ? { ...transform, x: 0, y: 0 } : transform;
|
||||
@@ -24,12 +39,16 @@ export function transformPath(path: Json, transform: Transform, inverse = false)
|
||||
}
|
||||
|
||||
/** Format-1 child content origin: the same inset, scale and rotation used by renderer projection. */
|
||||
/** 合成父变换与分区入口几何,得到子 canvas 内容区原点/比例/旋转。
|
||||
* 24/36 是格式 1 的内容内边距,要乘 presentation.unitScale,和 renderer 的投影保持一致。 */
|
||||
export function childCanvasTransform(parent:Transform,geometry:Geometry,presentation:Json|undefined):Transform {
|
||||
const data=object(presentation),units=unitScale(presentation),scale=(typeof data.contentScale==='number'&&data.contentScale>0?data.contentScale:1)*units;
|
||||
// 入口绕中心旋转,因此先把内容内边距写成相对中心偏移,再旋转并加回中心。
|
||||
const offset=rotate({x:24*units-geometry.width/2,y:36*units-geometry.height/2},geometry.rotation);
|
||||
const start=toWorld(parent,{x:geometry.x+geometry.width/2+offset.x,y:geometry.y+geometry.height/2+offset.y});
|
||||
return{...start,scale:parent.scale*scale,rotation:((parent.rotation+geometry.rotation)%360+360)%360};
|
||||
}
|
||||
/** 从根向目标逐级合成入口变换;ancestors 同时检查缺失父项与循环。 */
|
||||
export function canvasTransform(document:Document,canvasId:Id):Transform {
|
||||
let result:Transform={...origin};
|
||||
for(const id of ancestors(document,canvasId).reverse()){
|
||||
@@ -39,11 +58,14 @@ export function canvasTransform(document:Document,canvasId:Id):Transform {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/** 把节点从旧 owner 空间搬到新 owner 空间:中心走世界中转,尺寸按比例换算,角度相减。
|
||||
* 相同变换复用原 geometry,避免纯浮点误差制造无意义的变化。 */
|
||||
export function convertGeometry(geometry:Geometry,from:Transform,to:Transform):Geometry {
|
||||
if(sameTransform(from,to))return geometry;
|
||||
const center=toLocal(to,toWorld(from,{x:geometry.x+geometry.width/2,y:geometry.y+geometry.height/2})),ratio=from.scale/to.scale,width=geometry.width*ratio,height=geometry.height*ratio;
|
||||
return{x:center.x-width/2,y:center.y-height/2,width,height,rotation:((geometry.rotation+from.rotation-to.rotation)%360+360)%360};
|
||||
}
|
||||
/** 严格曲线换空间:null/同变换无需改;不支持的路径拒绝,不擅自解释未知 payload。 */
|
||||
export function convertPath(path:Json,from:Transform,to:Transform):Json {
|
||||
if(path===null||sameTransform(from,to))return path;
|
||||
if(!connectionPathValid(path))throw fault('unsupported_coordinate_path');
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
/**
|
||||
* 组件声明式引用路径的遍历器,供复制/解散等纯规划复用。
|
||||
* 只访问自有字段,不用字符串相似度猜 ID;这里 yield 可写 slot,调用者必须先复制数据再改。
|
||||
*/
|
||||
import type { Json } from './types.ts';
|
||||
import { fault } from './values.ts';
|
||||
|
||||
/** Own JSON fields only. A * segment enumerates an array, never object keys. */
|
||||
/** 用显式 stack 遍历 path,返回终端字段的 parent/key;缺失普通字段直接跳过。
|
||||
* * 只允许数组,非数组抛错;反向压栈使最终输出仍按原数组顺序。 */
|
||||
export function* copySlots(data: Json, path: readonly string[]): Generator<{parent: Record<string, Json>; key: string}> {
|
||||
const work: {value: Json; depth: number}[] = [{value: data, depth: 0}];
|
||||
while (work.length) {
|
||||
const {value, depth} = work.pop()!, key = path[depth];
|
||||
if (key === '*') {
|
||||
if (!Array.isArray(value)) throw fault('invalid_copy_reference');
|
||||
// 终端 * 直接枚举数组项位置;非终端 * 继续匹配下一路径段。
|
||||
if (depth === path.length - 1) { for (let i = 0; i < value.length; i++) yield {parent: value as unknown as Record<string, Json>, key: String(i)}; }
|
||||
else for (let i = value.length - 1; i >= 0; i--) work.push({value: value[i], depth: depth + 1});
|
||||
} else if (value && typeof value === 'object' && Object.hasOwn(value, key)) {
|
||||
@@ -16,6 +23,7 @@ export function* copySlots(data: Json, path: readonly string[]): Generator<{pare
|
||||
}
|
||||
}
|
||||
}
|
||||
/** 在 slot 上读取引用 ID;null 代表无引用,many 要求数组,每个 ID 必须非空字符串。 */
|
||||
export function* copyReferenceIds(data: Json, ref: {path: readonly string[]; many?: boolean}): Generator<string> {
|
||||
for (const {parent, key} of copySlots(data, ref.path)) {
|
||||
const value = parent[key]; if (value === null) continue;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 同步事务内的大批创建优化。追加表用固定成员的 base+added 视图避免每新增一项都复制整表,
|
||||
* 累计 64 项后折叠为新 base。只用于尚未发布的 draft,提交前统一物化为标准文档表。
|
||||
*/
|
||||
import {detachElementData} from './element-data.ts';
|
||||
import type {CanvasRecord,Change,Document,ElementRecord,Id} from './types.ts';
|
||||
import {applyChanges} from './document.ts';
|
||||
@@ -6,13 +10,17 @@ import {clone,fault,freeze,readMap,validJson} from './values.ts';
|
||||
/** Transaction-local append views. Each retained view has fixed membership;
|
||||
* at most 64 additions are copied before folding into a new read-only base. */
|
||||
const overlays=new WeakMap<object,{base:ReadonlyMap<Id,unknown>;added:ReadonlyMap<Id,unknown>}>(),drafts=new WeakSet<Document>();
|
||||
/** 在不可变输入表上创建新的追加视图;复制 added 而不改旧视图,保留之前 draft 的快照语义。 */
|
||||
function append<T>(input:ReadonlyMap<Id,T>,id:Id,value:T):ReadonlyMap<Id,T>{
|
||||
if(input.has(id))throw fault('duplicate_identity',[id]);
|
||||
let previous=overlays.get(input) as {base:ReadonlyMap<Id,T>;added:ReadonlyMap<Id,T>}|undefined;
|
||||
// 控制覆盖层大小;到阈值后重建 base,而不是让链条随着事务长度无限加深。
|
||||
if(previous&&previous.added.size>=64)previous=undefined;
|
||||
const base=previous?.base??(overlays.has(input)?readMap(input):input),added=new Map(previous?.added);
|
||||
added.set(id,value);
|
||||
/** 迭代先 base 后 added,保持插入顺序;视图的 get/has 优先查 appended 成员。 */
|
||||
const entries=function*():Generator<[Id,T],undefined,unknown>{yield* base;yield* added;return undefined;};
|
||||
// keys/values/iterator 复用 entries;forEach 第三个参数仍是安全视图,不泄漏 added Map。
|
||||
const view:ReadonlyMap<Id,T>=Object.freeze({size:base.size+added.size,get:(key:Id)=>added.has(key)?added.get(key):base.get(key),has:(key:Id)=>added.has(key)||base.has(key),entries,
|
||||
*keys():Generator<Id,undefined,unknown>{for(const [key]of entries())yield key;return undefined;},*values():Generator<T,undefined,unknown>{for(const [,item]of entries())yield item;return undefined;},[Symbol.iterator]:entries,
|
||||
forEach(callback:(value:T,key:Id,map:ReadonlyMap<Id,T>)=>void,thisArg?:unknown){for(const[key,item]of entries())callback.call(thisArg,item,key,view);},
|
||||
@@ -20,6 +28,8 @@ function append<T>(input:ReadonlyMap<Id,T>,id:Id,value:T):ReadonlyMap<Id,T>{
|
||||
overlays.set(view,{base,added});return view;
|
||||
}
|
||||
/** Internal editor path: input tables already have fixed membership. */
|
||||
/** 仅接收 before=null 且 after 存在的创建变化,校验 JSON/ID 后捕获记录并追加相应表。
|
||||
* 整图依赖可在后续命令补齐,因此此处不提前做完整树/端点校验。 */
|
||||
export function stageCreation(document:Document,change:Extract<Change,{kind:'canvas'|'element'}>):Document{
|
||||
if(change.before!==null||!change.after)throw fault('invalid_creation_draft');
|
||||
if(!validJson(change.after))throw fault(change.kind==='canvas'?'invalid_canvas':'invalid_element',[change.id]);
|
||||
@@ -28,4 +38,5 @@ export function stageCreation(document:Document,change:Extract<Change,{kind:'can
|
||||
const next=Object.freeze({...document,...(change.kind==='canvas'?{canvases:append(document.canvases,change.id,value as CanvasRecord)}:{elements:append(document.elements,change.id,value as ElementRecord)})});drafts.add(next);return next;
|
||||
}
|
||||
/** Only published documents use the core's owned table envelopes. */
|
||||
/** 只有已标记的 draft 需要通过空 changes 重新捕获表;普通文档原样复用。 */
|
||||
export const materializeCreations=(document:Document)=>drafts.has(document)?applyChanges(document,[]):document;
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
/**
|
||||
* 图片元信息异步到达后,把默认尺寸修正合回原创建历史的纯规划器。
|
||||
* 同时修正后续历史的 before,使 undo/redo 保持可回放;不会改写已经冻结的请求 body。
|
||||
* 持久授权、Root 版本检查和发布由 coordinator/editor 完成。
|
||||
*/
|
||||
import type { Change, CreationSizeEffect, Document, ElementRecord, Geometry, HistoryState, Id, Registry, Target } from './types.ts';
|
||||
import { applyChanges, invert, validateDocument } from './document.ts';
|
||||
import { closeNumber, unitScale } from './units.ts';
|
||||
import { equal, fault, freeze, readMap, validJson } from './values.ts';
|
||||
|
||||
/** 校验恢复图像中的尺寸 effect;未知协议与格式非法分别拒绝,不能猜旧效果含义。 */
|
||||
export function validateCreationSizeEffect(value: unknown): asserts value is CreationSizeEffect {
|
||||
const effect = value as CreationSizeEffect | undefined;
|
||||
if (!effect || effect.version !== 1 || effect.kind !== 'creation-size') throw fault('history_effect_interpreter_unavailable');
|
||||
@@ -15,33 +21,43 @@ export function validateCreationSizeEffect(value: unknown): asserts value is Cre
|
||||
* durable sizing grant and record a recoverable history effect before adopting it.
|
||||
* Already-issued operation bodies are deliberately outside this function.
|
||||
*/
|
||||
/** 仅原创建仍在 undo、目标仍活且未被用户缩放时,准备默认尺寸与历史修正。
|
||||
* 返回候选 document/history/changes;原输入始终不变,失败不留下半份历史。 */
|
||||
export function planCreationSize(document: Document, history: HistoryState, registry: Registry,
|
||||
entryId: Id, target: Target, size: { readonly width: number; readonly height: number }) {
|
||||
/** 统一将失败绑定到目标 ID,便于调用方解释为何不能再自动调整。 */
|
||||
const fail = (code: string): never => { throw fault(code, [target.id]); };
|
||||
if (![size.width, size.height].every(value => Number.isFinite(value) && value > 0)) fail('invalid_creation_size');
|
||||
const index = history.undo.indexOf(entryId), entry = history.entries.get(entryId);
|
||||
const current = document.elements.get(target.id);
|
||||
if (index < 0 || !entry || current?.incarnation !== target.incarnation || current.structure !== 'node') fail('creation_size_target_changed');
|
||||
// 必须在指定 entry 中找到唯一一次同 incarnation 的节点创建,不能猜其它创建记录。
|
||||
const creations = entry!.changes.filter(change => change.kind === 'element' && change.id === target.id && !change.before && change.after?.incarnation === target.incarnation);
|
||||
if (creations.length !== 1 || creations[0].kind !== 'element' || creations[0].after?.structure !== 'node') fail('creation_size_entry_mismatch');
|
||||
const created = creations[0].after as ElementRecord & { structure: 'node' };
|
||||
if (registry.resolve(current!) !== 'known' || !registry.supports(current!, 'geometry.resize')) fail('unsupported_capability');
|
||||
// 以创建时的视觉尺寸为基准;目标换 owner 后 unitScale 可变,但视觉基线仍可比较。
|
||||
const units = unitScale(created.data.presentation);
|
||||
const baseline = { width: created.geometry.width / units, height: created.geometry.height / units };
|
||||
const ratio = { width: size.width / baseline.width, height: size.height / baseline.height };
|
||||
/** 同一生命周期的节点才属于本次自动尺寸修正对象。 */
|
||||
const matches = (element: ElementRecord | undefined) => element?.incarnation === target.incarnation && element.structure === 'node';
|
||||
/** 比较宽高除 unitScale 后的视觉值,移动/换 owner 不等同于用户主动缩放。 */
|
||||
const unchanged = (element: ElementRecord | undefined) => matches(element) && element!.structure === 'node'
|
||||
&& closeNumber(element!.geometry.width / unitScale(element!.data.presentation), baseline.width)
|
||||
&& closeNumber(element!.geometry.height / unitScale(element!.data.presentation), baseline.height);
|
||||
if (!unchanged(current)) fail('creation_size_user_resized');
|
||||
/** 保留位置和旋转,只将需要继承默认修正的宽高按当前 ratio 缩放。 */
|
||||
const scaled = (geometry: Geometry): Geometry => freeze({ ...geometry, width: geometry.width * ratio.width, height: geometry.height * ratio.height });
|
||||
// Rewind on private roots. applyChanges intentionally preserves historical
|
||||
// incarnations here; this is inspection, not a user-visible delete/restore.
|
||||
let original = document;
|
||||
// 从创建项起回退已应用历史,得到创建前私有基线;不产生用户可见的删除/恢复代次。
|
||||
const applied = history.undo.slice(index);
|
||||
for (const id of [...applied].reverse()) original = applyChanges(original, invert(history.entries.get(id)!.changes));
|
||||
let amended = original, inheriting = false;
|
||||
const entries = new Map(history.entries);
|
||||
// 先按 undo 的正向顺序重建已应用项,再倒序遍历 redo 栈还原未来重做时间线。
|
||||
const timeline = [...applied, ...[...history.redo].reverse()];
|
||||
for (let cursor = 0; cursor < timeline.length; cursor++) {
|
||||
const id = timeline[cursor], source = entries.get(id)!;
|
||||
@@ -51,6 +67,7 @@ export function planCreationSize(document: Document, history: HistoryState, regi
|
||||
// Only its before value inherits the asynchronous default correction.
|
||||
const futureResize = !isApplied && inheriting && matches(end.elements.get(target.id)) && !unchanged(end.elements.get(target.id));
|
||||
let resizeConsumed = false;
|
||||
/** redo 中用户显式改过的维度停止继承默认比例;仅修正其 before,不改用户指定的 after。 */
|
||||
const preserveFutureResize = () => {
|
||||
if (!futureResize || resizeConsumed) return;
|
||||
const value = end.elements.get(target.id) as ElementRecord & { structure: 'node' };
|
||||
@@ -59,11 +76,13 @@ export function planCreationSize(document: Document, history: HistoryState, regi
|
||||
resizeConsumed = true;
|
||||
};
|
||||
const changes: Change[] = [];
|
||||
// 每条 next 都基于 amended 的实际 before 重建,保证修正后的连续历史仍条件匹配。
|
||||
for (const change of source.changes) {
|
||||
let next = change;
|
||||
if (change.kind === 'element' && change.id === target.id) {
|
||||
const before = amended.elements.get(target.id) ?? null;
|
||||
const birth = id === entryId && change === creations[0];
|
||||
// 从创建点开始继承;遇到删除或其它 incarnation 时停止,不能跨生命周期改尺寸。
|
||||
if (birth) inheriting = true;
|
||||
else preserveFutureResize();
|
||||
const after = change.after && inheriting && change.after.incarnation === target.incarnation && change.after.structure === 'node'
|
||||
@@ -84,6 +103,7 @@ export function planCreationSize(document: Document, history: HistoryState, regi
|
||||
}
|
||||
// Publishable proposal changes only the currently live geometry. Replaying
|
||||
// redo above was validation; it must never become the working document.
|
||||
// 上方 replay redo 只为校验/修正历史;当前工作文档仅修改当前活元素,不提前重做未来项。
|
||||
const after = freeze({ ...(current as ElementRecord & { structure: 'node' }).geometry,
|
||||
width: (current as ElementRecord & { structure: 'node' }).geometry.width * size.width / baseline.width,
|
||||
height: (current as ElementRecord & { structure: 'node' }).geometry.height * size.height / baseline.height });
|
||||
@@ -92,6 +112,7 @@ export function planCreationSize(document: Document, history: HistoryState, regi
|
||||
]);
|
||||
const nextDocument = applyChanges(document, changes);
|
||||
validateDocument(nextDocument, registry);
|
||||
// 最后用组件自己的 resize handler 对照约束,自动尺寸不能绕过组件 min/max/aspect 政策。
|
||||
const handler = registry.handlers.get(current!.componentId)?.get('geometry.resize');
|
||||
const proposed = handler!({ element: current!, input: freeze({ patch: { width: after.width, height: after.height } }) });
|
||||
for (const change of proposed) if (change.target.id !== target.id || change.target.incarnation !== target.incarnation || change.field !== 'geometry') fail('invalid_handler_change');
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
/**
|
||||
* 有限预算的三次曲线命中精判。先用控制点包围盒排除,再用弦线距离与平坦度误差判断;
|
||||
* 不能确定时用 de Casteljau 二分。预算/数值精度不足明确返回 possible,不能伪称已证明 miss。
|
||||
*/
|
||||
import type {Point} from './types.ts';
|
||||
import type {Cubic} from './geometry.ts';
|
||||
/** 点到线段的最短距离:先按最大坐标量级归一化,投影限制在线段上,再恢复原尺度。
|
||||
* 零长度段退化为点,归一化用于减少大坐标求差/长度平方造成的溢出。 */
|
||||
const distance=(p:Point,a:Point,b:Point)=>{const magnitude=Math.max(1,Math.abs(p.x),Math.abs(p.y),Math.abs(a.x),Math.abs(a.y),Math.abs(b.x),Math.abs(b.y)),x=b.x/magnitude-a.x/magnitude,y=b.y/magnitude-a.y/magnitude,dx=p.x/magnitude-a.x/magnitude,dy=p.y/magnitude-a.y/magnitude,length=Math.hypot(x,y),ux=length?x/length:0,uy=length?y/length:0,t=Math.max(0,Math.min(length,dx*ux+dy*uy));return Math.hypot(dx-t*ux,dy-t*uy)*magnitude;};
|
||||
/** 两点中点写成 a/2+b/2,减少先做 a+b 时溢出的风险。 */
|
||||
const mid=(a:Point,b:Point)=>({x:a.x/2+b.x/2,y:a.y/2+b.y/2});
|
||||
/** de Casteljau 在 t=0.5 分割,生成两条连接于同一个 center 的等价子曲线。 */
|
||||
function split([a,b,c,d]:Cubic):[Cubic,Cubic]{const ab=mid(a,b),bc=mid(b,c),cd=mid(c,d),abc=mid(ab,bc),bcd=mid(bc,cd),center=mid(abc,bcd);return[[a,ab,abc,center],[center,bcd,cd,d]];}
|
||||
/** Hull rejection + bounded de Casteljau refinement; ambiguous boundaries are explicit, never a proven miss. */
|
||||
/** 返回 hit、certainty、距离估计、误差和预算状态;hit=true/possible 需调用方保守处理。
|
||||
* maxSteps 限总工作,maxDepth 限细分深度,error 控制可接受平坦度;stack 用显式 DFS。
|
||||
* 每轮:包围盒排除→near±flat 判定→足够平/深度已满则记 possible→否则入栈左右子曲线。
|
||||
* near 是到端点弦线段距离,flat 包括两控制点到该弦的偏差及 roundoff。
|
||||
* 剩余 stack 非空表示总预算耗尽,仍可能有未检查的命中区域。 */
|
||||
export function hitCubic(curve:Cubic,point:Point,radius:number,options:{error:number;maxDepth:number;maxSteps:number}){
|
||||
const stack:{curve:Cubic;depth:number}[]=[{curve,depth:0}];let steps=0,possible=false,estimate=Infinity,error=0;
|
||||
while(stack.length&&steps<options.maxSteps){const item=stack.pop()!;steps++;const xs=item.curve.map(p=>p.x),ys=item.curve.map(p=>p.y),dx=Math.max(0,Math.min(...xs)-point.x,point.x-Math.max(...xs)),dy=Math.max(0,Math.min(...ys)-point.y,point.y-Math.max(...ys));if(Math.hypot(dx,dy)>radius)continue;
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* 解散分区的纯规划:移除一个拥有 canvas,把直属成员提升到父级,保留原入口元素及原线。
|
||||
* 不同于删除子树,后代 canvas 内容保持原直接 owner。返回 changes 和权限预检命令,
|
||||
* commands 不是另一个任务队列,真正提交由 editor 统一完成。
|
||||
*/
|
||||
import type {Change,Command,Document,Id,Json,Registry,Target} from './types.ts';
|
||||
import {applyChanges,targetElement} from './document.ts';
|
||||
import {canvasTransform,convertGeometry} from './coordinates.ts';
|
||||
@@ -7,24 +12,30 @@ import {repairConnectionOwners} from './structure.ts';
|
||||
import {clone,equal,fault,freeze} from './values.ts';
|
||||
|
||||
/** Remove an owning canvas, retaining its entry and promoting only direct members. */
|
||||
/** 验证 canvas/入口生命周期,规划成员和引用转换;根不能解散,任何不支持路径整体拒绝。 */
|
||||
export function planDissolveCanvas(document:Document,registry:Registry,canvasId:Id,entryTarget:Target):{readonly changes:readonly Change[];readonly commands:readonly Command[]}{
|
||||
const canvas=document.canvases.get(canvasId);if(!canvas)throw fault('canvas_not_loaded',[canvasId]);
|
||||
if(!canvas.parent)throw fault('cannot_dissolve_root',[canvasId]);
|
||||
if(canvas.parent.entryElementId!==entryTarget.id)throw fault('canvas_entry_changed',[canvasId]);
|
||||
targetElement(document,entryTarget);
|
||||
const parent=canvas.parent.canvasId,from=canvasTransform(document,canvasId),to=canvasTransform(document,parent),changes:Change[]=[],commands:Command[]=[];
|
||||
// 只提升直属节点;保持世界几何并修正表现单位,边在成员移动后统一修复公共 owner。
|
||||
for(const element of document.elements.values())if(element.ownerCanvasId===canvasId&&element.structure==='node'){
|
||||
if(!registry.supports(element,'geometry.move'))throw fault('unsupported_capability',[element.id,'geometry.move']);
|
||||
const target={id:element.id,incarnation:element.incarnation},geometry=convertGeometry(element.geometry,from,to),presentation=withUnitScale(element.data.presentation,unitScale(element.data.presentation)*from.scale/to.scale);
|
||||
// 即使最终用 changes 原子提交,也要向 editor 暴露逐成员 policy 检查,防止入口权限越权。
|
||||
commands.push({type:'element.move',target,canvasId:parent});
|
||||
changes.push({kind:'field',target,field:'ownerCanvasId',before:canvasId,after:parent});
|
||||
if(!equal(geometry,element.geometry))changes.push({kind:'field',target,field:'geometry',before:element.geometry,after:geometry});
|
||||
if(!equal(presentation,element.data.presentation))changes.push({kind:'field',target,field:'data.presentation',before:element.data.presentation??null,beforePresent:Object.hasOwn(element.data,'presentation'),after:presentation??null,afterPresent:presentation!==undefined});
|
||||
}
|
||||
// 直属子 canvas 的入口随节点提升,因此只改其 parent,不递归改孙辈成员 owner。
|
||||
for(const child of document.canvases.values())if(child.parent?.canvasId===canvasId)changes.push({kind:'canvas',id:child.id,before:child,after:{...child,parent:{...child.parent,canvasId:parent}}});
|
||||
changes.push({kind:'canvas',id:canvasId,before:canvas,after:null});
|
||||
// 在私有候选文档上计算边的新公共上级,并把曲线从旧 owner 空间一并转换。
|
||||
const promoted=applyChanges(document,changes);changes.push(...repairConnectionOwners(document,promoted));
|
||||
// Only declared canvas references have meaning here. Opaque payloads are retained.
|
||||
// 只解释已知组件明确声明的 canvas 引用;opaque payload 和普通字面 ID 文本保留。
|
||||
for(const element of promoted.elements.values()){
|
||||
if(registry.resolve(element)!=='known')continue;
|
||||
const references=registry.components.get(element.componentId)?.copy?.references.filter(reference=>reference.target==='canvas')??[];
|
||||
@@ -33,9 +44,11 @@ export function planDissolveCanvas(document:Document,registry:Registry,canvasId:
|
||||
for(const reference of references)for(const slot of copySlots(data,reference.path)){
|
||||
const value=slot.parent[slot.key];if(value===null)continue;
|
||||
if(reference.many&&!Array.isArray(value))throw fault('invalid_copy_reference',[element.id]);
|
||||
// 被解散 canvas 的引用改指父级;many 与单值共用严格 ID 检查,不扩展成自由端点政策。
|
||||
const map=(value:Json):Json=>{if(typeof value!=='string'||!value)throw fault('invalid_copy_reference',[element.id]);return value===canvasId?parent:value;};
|
||||
slot.parent[slot.key]=reference.many?(value as Json[]).map(map):map(value);
|
||||
}
|
||||
// 仅产生实际改变的顶层 data 字段,并要求 content.edit 能力/宿主 policy 通过。
|
||||
for(const key of Object.keys(data))if(!equal(data[key],element.data[key])){
|
||||
if(!registry.supports(element,'content.edit'))throw fault('unsupported_capability',[element.id,'content.edit']);
|
||||
changes.push({kind:'field',target:{id:element.id,incarnation:element.incarnation},field:`data.${key}`,before:element.data[key],after:data[key]});
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
/**
|
||||
* 与渲染框架无关的基本几何协议。所有计算输入必须在同一坐标空间;
|
||||
* bounds 负责粗筛,hit 负责矩形精判,曲线 segments 由模型命中和 SVG 绘制共享。
|
||||
*/
|
||||
import type {Geometry,Point} from './types.ts';
|
||||
/** 轴对齐包围盒,用于空间索引粗筛;旋转节点的盒会大于其实际轮廓。 */
|
||||
export type Rect={readonly x:number;readonly y:number;readonly width:number;readonly height:number};
|
||||
/** 三次 Bezier 的四个绝对点,顺序为起点、第一控制点、第二控制点、终点。 */
|
||||
export type Cubic=readonly[Point,Point,Point,Point];
|
||||
/** 已解算到共同展示空间的连接点及朝外法向量;默认控制柄沿该法向量延伸。 */
|
||||
export type Connector={readonly point:Point;readonly normal:Point};
|
||||
/** knot.position 是绝对位置,in/out 和首尾 control 是相对锚点偏移。 */
|
||||
export type BezierChain={readonly knots:readonly{readonly position:Point;readonly in:Point;readonly out:Point}[];readonly startControl?:Point;readonly endControl?:Point};
|
||||
/** 绕节点中心旋转后的 AABB:宽=|cos|·w+|sin|·h,高=|sin|·w+|cos|·h。 */
|
||||
export function rectangleBounds(g:Geometry):Rect{const angle=g.rotation*Math.PI/180,c=Math.abs(Math.cos(angle)),s=Math.abs(Math.sin(angle)),width=g.width*c+g.height*s,height=g.width*s+g.height*c;return{x:g.x+g.width/2-width/2,y:g.y+g.height/2-height/2,width,height};}
|
||||
/** 将点反向旋转到矩形自身坐标,计算到矩形的距离,再与容差比较。
|
||||
* 角外区域使用欧氏距离而非扩展 AABB;浮点误差余量随坐标尺度调整。 */
|
||||
export function hitRectangle(g:Geometry,point:Point,tolerance=0):boolean{const angle=-g.rotation*Math.PI/180,c=Math.cos(angle),s=Math.sin(angle),dx=point.x-g.x-g.width/2,dy=point.y-g.y-g.height/2,x=dx*c-dy*s,y=dx*s+dy*c,ox=Math.max(0,Math.abs(x)-g.width/2),oy=Math.max(0,Math.abs(y)-g.height/2);return Math.hypot(ox,oy)<=tolerance+Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),g.width,g.height);}
|
||||
/** 默认矩形节点协议;空 dataDependencies 表示形状仅依赖 geometry,不因 title 等数据变化重建。 */
|
||||
export const rectangleGeometry=Object.freeze({kind:'node' as const,version:1 as const,dataDependencies:Object.freeze([] as string[]),bounds:({geometry}:{geometry:Geometry})=>rectangleBounds(geometry),hit:({geometry,point,tolerance}:{geometry:Geometry;point:Point;tolerance:number})=>hitRectangle(geometry,point,tolerance)});
|
||||
/** Shared by the built-in model protocol and the SVG renderer; all arguments are in one display space. */
|
||||
/** 由锚点链生成每段三次曲线:上一 knot.out 和下一 knot.in 分别作为控制偏移。
|
||||
* 无显式首尾控制柄时,按端点距离的 0.4 倍选择长度并限制在 40–240。
|
||||
* add 把相对偏移变成绝对控制点;map 对每对相邻 anchors 输出一段。 */
|
||||
export function bezierChainSegments(from:Connector,to:Connector,path?:BezierChain|null):Cubic[]{const reach=Math.min(240,Math.max(40,Math.hypot(to.point.x-from.point.x,to.point.y-from.point.y)*.4)),knots=path?.knots??[],anchors=[from.point,...knots.map(knot=>knot.position),to.point],add=(a:Point,b:Point)=>({x:a.x+b.x,y:a.y+b.y});return anchors.slice(0,-1).map((point,index)=>[point,add(point,index?knots[index-1].out:path?.startControl??{x:from.normal.x*reach,y:from.normal.y*reach}),add(anchors[index+1],index<knots.length?knots[index].in:path?.endControl??{x:to.normal.x*reach,y:to.normal.y*reach}),anchors[index+1]]);}
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
/**
|
||||
* 手势使用的可见坐标上下文签名。它描述目标 owner 到当前 view canvas 之间的入口链,
|
||||
* 不从项目根出发;view 之外的父级变化不必被误当作当前视图内的目标坐标变化。
|
||||
*/
|
||||
import type {Document,Geometry,Id,Registry} from './types.ts';
|
||||
import {fault} from './values.ts';
|
||||
import {unitScale} from './units.ts';
|
||||
|
||||
/** 一层入口的身份/类型/版本和变换相关值;上传状态、普通文字等无关字段不进入签名。 */
|
||||
type PlacementStep={canvasId:Id;parentCanvasId:Id;entryId:Id;incarnation:number;componentId:string;dataVersion:number;geometry:Geometry;contentScale:number;unitScale: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 只在一次准入或发布比较中复用,多选共享祖先扫描,不能跨文档更新继续使用。 */
|
||||
export function createGesturePlacementReader(document:Document,viewCanvasId:Id,registry:Registry){
|
||||
const values=new Map<Id,readonly PlacementStep[]>();
|
||||
/** 向 view 回溯目标拥有链,缺失/循环/隐藏入口明确拒绝;成功后才缓存完整链。 */
|
||||
return(ownerCanvasId:Id):readonly PlacementStep[]=>{
|
||||
const cached=values.get(ownerCanvasId);if(cached)return cached;
|
||||
if(!document.canvases.has(viewCanvasId))throw fault('canvas_missing',[viewCanvasId]);
|
||||
const chain:PlacementStep[]=[],seen=new Set<Id>();let id=ownerCanvasId;
|
||||
// 当前 view 本身无需入口步骤;回溯到根仍没遇到 view,说明目标不在当前视图子树。
|
||||
while(id!==viewCanvasId){
|
||||
if(seen.has(id))throw fault('canvas_cycle',[id]);seen.add(id);
|
||||
const parent=document.canvases.get(id)?.parent;
|
||||
@@ -19,6 +28,7 @@ export function createGesturePlacementReader(document:Document,viewCanvasId:Id,r
|
||||
const entry=document.elements.get(parent.entryElementId);
|
||||
// A future/unknown entry is a collapsed placeholder; never interpret its payload.
|
||||
if(!entry||entry.structure!=='node'||registry.resolve(entry)!=='known')throw fault('target_hidden',[ownerCanvasId]);
|
||||
// 未知/未来入口已在上方拒绝;已知入口仍可能折叠,折叠子内容不允许开始手势。
|
||||
const raw=entry.data.presentation,presentation=raw&&typeof raw==='object'&&!Array.isArray(raw)?raw:{};
|
||||
if(presentation.collapsed===true)throw fault('target_hidden',[ownerCanvasId]);
|
||||
const contentScale=typeof presentation.contentScale==='number'&&presentation.contentScale>0?presentation.contentScale:1;
|
||||
|
||||
@@ -1,36 +1,53 @@
|
||||
/**
|
||||
* 每个 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);
|
||||
@@ -39,17 +56,22 @@ export function createSessions(editor: Editor) {
|
||||
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; }
|
||||
@@ -61,7 +83,9 @@ export function createSessions(editor: Editor) {
|
||||
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);
|
||||
@@ -69,12 +93,14 @@ export function createSessions(editor: Editor) {
|
||||
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 }) });
|
||||
@@ -85,35 +111,45 @@ export function createSessions(editor: Editor) {
|
||||
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');
|
||||
@@ -121,11 +157,13 @@ export function createSessions(editor: Editor) {
|
||||
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');
|
||||
@@ -140,6 +178,7 @@ export function createSessions(editor: Editor) {
|
||||
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);
|
||||
@@ -149,6 +188,7 @@ 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,结束后无论成功/拒绝都清预览。 */
|
||||
commitConnection(viewId: Id): Result {
|
||||
try {
|
||||
view(viewId);
|
||||
@@ -160,6 +200,7 @@ export function createSessions(editor: Editor) {
|
||||
} catch (error) { cancel('commit_failed'); return reject(error); }
|
||||
},
|
||||
update,
|
||||
/** 节点手势提交一个同步事务,把各目标相对 baseline 的净字段变化合成一个 Transform 历史项。 */
|
||||
commit(viewId: Id): Result {
|
||||
try {
|
||||
view(viewId);
|
||||
@@ -170,6 +211,7 @@ export function createSessions(editor: Editor) {
|
||||
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 });
|
||||
}
|
||||
@@ -179,11 +221,14 @@ export function createSessions(editor: Editor) {
|
||||
} 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(); },
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user