diff --git a/src/lib/canvas-core/document.ts b/src/lib/canvas-core/document.ts index 38e0973..c4d19b6 100644 --- a/src/lib/canvas-core/document.ts +++ b/src/lib/canvas-core/document.ts @@ -1,3 +1,8 @@ +/** + * 规范化文档与条件变化:canvases/elements/tombstones 是按 ID 查找的只读表。 + * 这里负责记录/树/连线校验、私有副本上的修改、条件回放与反向变化;不拥有 history、 + * 网络或公开订阅。applyChanges 只生成候选文档,最终合法性与统一发布由调用方完成。 + */ import {detachElementData,hasOwnedElementData,componentDataValid} from './element-data.ts'; import type { CanvasRecord, Change, Document, ElementRecord, Geometry, Id, Registry, Target } from './types.ts'; import { clone, equal, fault, freeze, readMap, validJson } from './values.ts'; @@ -5,24 +10,31 @@ import { clone, equal, fault, freeze, readMap, validJson } from './values.ts'; // This owns only the envelope and fixed table membership, not validation or // immutability of values inherited by applyChanges from an external document. const factoryDocuments=new WeakSet(); +/** 冻结最外层文档并标记工厂来源;该标记只证明 envelope 来源,不代替数据校验。 */ function documentEnvelope(document:Document):Document{const value=Object.freeze(document);factoryDocuments.add(value);return value;} +/** 查询工厂来源,供调用方选择可信结构路径;不能据此信任所有外来字段。 */ export const isFactoryDocument=(document:Document)=>factoryDocuments.has(document); +/** 捕获输入记录、拒绝同一表内重复 ID,并脱离调用者的可变数据。 + * 不在此处验证整棵树或所有跨记录引用;完整发布前仍需 validateDocument。 */ export function createDocument(projectId: Id, canvases: readonly CanvasRecord[], elements: readonly ElementRecord[] = [], tombstones: ReadonlyMap = new Map()): Document { for (const element of elements) { const id = element.id; if (!staticElementProofs.has(element)&&!validJson(element)) throw fault('invalid_element', [id]); } for (const canvas of canvases) if (!validJson(canvas)) throw fault('invalid_canvas'); if (!projectId || new Set(canvases.map(item => item.id)).size !== canvases.length || new Set(elements.map(item => item.id)).size !== elements.length) throw fault('duplicate_identity'); return documentEnvelope({ projectId, canvases: readMap(new Map(canvases.map(item => [item.id, freeze(clone(item))]))), elements: readMap(new Map(elements.map(item => [item.id, detachElementData(item)]))), tombstones: readMap(tombstones) }); } +/** 节点几何必须有限、宽高为正且旋转在 [0,360);不把无效值静默修正。 */ export function geometryValid(value: Geometry): boolean { return !!value && typeof value === 'object' && [value.x, value.y, value.width, value.height, value.rotation].every(Number.isFinite) && value.width > 0 && value.height > 0 && value.rotation >= 0 && value.rotation < 360; } +/** 通过 ID+incarnation 定位活元素,阻止旧手势/异步回调命中删除后重建的同 ID 对象。 */ export function targetElement(document: Document, target: Target): ElementRecord { const element = document.elements.get(target.id); if (!element) throw fault('element_missing', [target.id]); if (element.incarnation !== target.incarnation) throw fault('stale_incarnation', [target.id]); return element; } +/** 从当前 canvas 向根收集路径(包含自身);Set 检测循环,缺失父项明确报告未加载。 */ export function ancestors(document: Document, canvasId: Id): Id[] { const result: Id[] = [], visited = new Set(); let next: Id | undefined = canvasId; @@ -35,6 +47,7 @@ export function ancestors(document: Document, canvasId: Id): Id[] { } return result; } +/** 找最近共同上级:先收集左路径,再从右侧逐级向上找首次交点。用于确定连线 owner。 */ export function commonCanvas(document: Document, left: Id, right: Id): Id { const parents = new Set(ancestors(document, left)); const result = ancestors(document, right).find(id => parents.has(id)); @@ -45,6 +58,8 @@ export function commonCanvas(document: Document, left: Id, right: Id): Id { const staticElementProofs=new WeakSet(); /** Validate one record before its graph dependencies have arrived. This does not * prove owner membership, endpoint existence or connection common ownership. */ +/** 校验单条记录的基础形状、墓碑代次和已知组件数据。 + * staticElementProofs 仅缓存已捕获记录的固有形状;墓碑和 registry 相关检查每次仍执行。 */ export function validateElementRecord(element: ElementRecord, registry: Registry, tombstones: ReadonlyMap = new Map(), componentData = true): void { const proven=!!element&&staticElementProofs.has(element); if (!proven&&(!element || !validJson(element) || typeof element.id !== 'string' || !element.id || typeof element.ownerCanvasId !== 'string' || !element.ownerCanvasId || typeof element.componentId !== 'string' || !element.componentId || !['node','connection'].includes(element.structure) || !Number.isSafeInteger(element.incarnation) || element.incarnation < 1 || !Number.isSafeInteger(element.dataVersion) || element.dataVersion < 1 || !element.data || typeof element.data !== 'object' || Array.isArray(element.data) || !validJson(element.data))) throw fault('invalid_element', [element?.id]); @@ -57,9 +72,12 @@ export function validateElementRecord(element: ElementRecord, registry: Registry if(hasOwnedElementData(element))staticElementProofs.add(element); } +/** 墓碑保存已死亡的最大 incarnation,必须是非空 ID 对应的正安全整数。 */ function validateTombstone(id:Id,incarnation:number){ if(!id||!Number.isSafeInteger(incarnation)||incarnation<1)throw fault('invalid_tombstone',[id]); } +/** 校验父链与入口:入口是父 canvas 的节点,且不能被多个子 canvas 共用。 + * 返回 1 表示根,供整文档校验要求恰好一个根。 */ function validateCanvasInDocument(document:Document,canvas:CanvasRecord,entries:Set):number{ if(typeof canvas.id!=='string'||!canvas.id||!Object.hasOwn(canvas,'parent')||canvas.parent!==null&&(!canvas.parent||typeof canvas.parent.canvasId!=='string'||typeof canvas.parent.entryElementId!=='string'))throw fault('invalid_canvas'); ancestors(document,canvas.id); @@ -68,6 +86,7 @@ function validateCanvasInDocument(document:Document,canvas:CanvasRecord,entries: if(!entry||entry.structure!=='node'||entry.ownerCanvasId!==canvas.parent.canvasId||entries.has(entry.id))throw fault('invalid_canvas_entry',[canvas.id]); entries.add(entry.id);return 0; } +/** 在单记录校验之上检查 owner、真实端点及最近共同上级;只允许节点作为边端点。 */ function validateElementInDocument(document:Document,element:ElementRecord,registry:Registry,componentData:boolean){ validateElementRecord(element,registry,document.tombstones,componentData); if(!document.canvases.has(element.ownerCanvasId))throw fault('invalid_element',[element.id]); @@ -78,6 +97,7 @@ function validateElementInDocument(document:Document,element:ElementRecord,regis } } /** Cooperative and synchronous paths use the same per-record checks and order. */ +/** 每项检查前 yield,让异步调度调用方可以分片;迭代器本身不等待或调度。 */ function* validateDocumentChecks(document:Document,registry:Registry,componentData:boolean):Generator{ if(!document.canvases.size)throw fault('missing_root'); for(const[id,incarnation]of document.tombstones){yield;validateTombstone(id,incarnation);} @@ -87,8 +107,11 @@ function* validateDocumentChecks(document:Document,registry:Registry,componentDa for(const element of document.elements.values()){yield;validateElementInDocument(document,element,registry,componentData);} } /** Structural indexes contain no component payload; use only for source planning, never editor publication. */ +/** 仅结构检查的分片迭代器:跳过组件 payload schema,供源结构规划使用。 */ export function* validateDocumentTopologySteps(document:Document,registry:Registry):Generator{yield* validateDocumentChecks(document,registry,false);} +/** 含组件 payload 的完整分片校验;必须迭代到结束才证明整份文档通过。 */ export function* validateDocumentSteps(document:Document,registry:Registry):Generator{yield* validateDocumentChecks(document,registry,true);} +/** 同步完整校验,要求恰好一个根,所有拥有关系、端点和组件数据均合法。 */ export function validateDocument(document:Document,registry:Registry):void{ if(!document.canvases.size)throw fault('missing_root'); for(const[id,incarnation]of document.tombstones)validateTombstone(id,incarnation); @@ -97,15 +120,21 @@ export function validateDocument(document:Document,registry:Registry):void{ if(roots!==1)throw fault('invalid_roots'); for(const element of document.elements.values())validateElementInDocument(document,element,registry,true); } +/** 一批有序修改及其允许的 canvas 上下文;回放不能跨越已冻结的操作范围。 */ export type ReplayChangeBatch = {readonly changes:readonly Change[];readonly canvasIds?:readonly Id[]}; +/** 在私有表副本中应用一批变化;默认校验 before 条件,失败不会修改输入文档。 */ export function applyChanges(document: Document, changes: readonly Change[], conditional = true): Document { return applyChangeBatches(document,[{changes}],conditional,false); } /** Ordered conditional replay against one private draft; no intermediate document escapes. */ +/** 顺序回放多批变化,已经满足的 after 可跳过,但仍检查目标的 scope。 */ export function replayChanges(document:Document,batches:Iterable):Document { return applyChangeBatches(document,batches,true,true); } +/** 三类变化共用的执行器:field 改字段、element 增删元素、canvas 改树记录。 + * 仅在全部循环成功后返回冻结 envelope,不泄漏半成品;此处不做整图合法性校验。 */ function applyChangeBatches(document:Document,batches:Iterable,conditional:boolean,replay:boolean):Document { + // 复制表成员而复用未改记录;出错时丢弃这些私有 Map,输入快照保持不变。 const elements = new Map(document.elements), canvases = new Map(document.canvases), tombstones = new Map(document.tombstones); for (const batch of batches) for (const change of batch.changes) { if(replay){ @@ -113,11 +142,13 @@ function applyChangeBatches(document:Document,batches:Iterable)[change.field]; if (conditional && (dataKey !== null && Object.hasOwn(element.data, dataKey) !== (change.beforePresent ?? true) || !equal(prior, change.before))) throw fault('field_conflict', [element.id]); @@ -127,6 +158,7 @@ function applyChangeBatches(document:Document,batches:Iterable,canvases:ReadonlyMap,change:Change):boolean { if(change.kind==='canvas')return equal(canvases.get(change.id)??null,change.after); if(change.kind==='element')return equal(elements.get(change.id)??null,change.after); @@ -157,6 +191,8 @@ function changeSatisfied(elements:ReadonlyMap,canvases:Readonl if(change.field.startsWith('data.')){const key=change.field.slice(5);return Object.hasOwn(element.data,key)===(change.afterPresent??true)&&equal(element.data[key]??null,change.after);} return equal((element as unknown as Record)[change.field],change.after); } +/** 倒序并交换 before/after,生成撤销变化;字段存在性标记也一起交换。 + * 如先创建再改名,撤销必须先撤改名再撤创建,因此不能只交换值而保持原顺序。 */ export function invert(changes: readonly Change[]): readonly Change[] { return changes.slice().reverse().map(change => { if(change.kind==='field'){const{beforePresent,afterPresent,...record}=change;return freeze({...record,before:change.after,after:change.before,...(afterPresent===undefined?{}:{beforePresent:afterPresent}),...(beforePresent===undefined?{}:{afterPresent:beforePresent})});} diff --git a/src/lib/canvas-core/history-transition.ts b/src/lib/canvas-core/history-transition.ts index 5304c61..af6343d 100644 --- a/src/lib/canvas-core/history-transition.ts +++ b/src/lib/canvas-core/history-transition.ts @@ -1,14 +1,25 @@ +/** + * history 的可逆增量记录:保存两栈 ID 顺序,以及历史项发生变化的数组区间。 + * 与 history.ts 的区别:这里恢复“历史元数据”,不执行文档修改或发送请求。 + * journal/恢复与资源 pin 扫描使用这些记录,既能回放,也保留被清 redo/淘汰的旧内容。 + */ import type { Change, HistoryEntry, HistoryState, Id } from './types.ts'; import { equal, fault, freeze, readMap, validJson } from './values.ts'; +/** 数组区间补丁:共同前缀后的 offset、两边总长度与替换前后区间;共同后缀不重复保存。 */ type Splice={readonly offset:number;readonly beforeLength:number;readonly afterLength:number;readonly before:readonly T[];readonly after:readonly T[]}; +/** 历史项的展示/分组元数据;变化数组和 operation IDs 分开描述。 */ type Meta={readonly label:string;readonly groupId?:Id}; +/** 同一 entry 的元数据与两个数组补丁;null 表示该侧不存在这个历史项。 */ type EntryPatch={readonly id:Id;readonly before:Meta|null;readonly after:Meta|null;readonly changes:Splice;readonly operationIds:Splice}; +/** version 控制解释器兼容性,rootRevision 关联发布版本;不等同于服务端版本号。 */ export type HistoryTransition={readonly version:1;readonly rootRevision:number;readonly entryId?:Id;readonly before:{readonly undo:readonly Id[];readonly redo:readonly Id[]};readonly after:{readonly undo:readonly Id[];readonly redo:readonly Id[]};readonly entries:readonly EntryPatch[]}; // Resource scans revisit the same transition across many durability cuts. Cache // only its flattened changes, never registry-dependent references or callbacks. const transitionChanges=new WeakMap(); const immutableData=new WeakSet(); +/** 检查可安全跨持久切点复用的深不可变普通数据,最多 64 层。 + * 冻结的 getter 仍可能返回不同值,稀疏数组也不是完整区间,因此都不允许缓存。 */ function immutablePlainData(value:unknown,depth=0):boolean{ if(depth>64)return false; if(value===null||typeof value!=='object')return typeof value!=='function'; @@ -23,48 +34,65 @@ function immutablePlainData(value:unknown,depth=0):boolean{ } immutableData.add(value);return true; } +/** 提取除 id/changes/operationIds 外的元数据,缺项用 null 表示,以便正反向校验。 */ const meta=(entry:HistoryEntry|undefined):Meta|null=>{if(!entry)return null;const{id:_id,changes:_changes,operationIds:_operations,...remaining}=entry;return remaining;}; +/** 去掉相等的共同前缀、后缀,只保存真正变化的中间区间;后缀不能与前缀重叠。 */ function splice(before:readonly T[],after:readonly T[]):Splice{ let offset=0,suffix=0;while(offsetunknown):asserts value is HistoryTransition{ + // 四个局部校验器统一抛领域错误,分别约束普通记录、非负整数、单 ID 和 ID 数组。 const object=(v:unknown):Record=>{if(!v||typeof v!=='object'||Array.isArray(v))throw fault('invalid_history_transition');return v as Record;}; const integer=(v:unknown)=>{if(typeof v!=='number'||!Number.isSafeInteger(v)||v<0)throw fault('invalid_history_transition');}; const id=(v:unknown)=>{if(typeof v!=='string'||!v)throw fault('invalid_history_transition');}; const ids=(v:unknown):string[]=>{if(!Array.isArray(v))throw fault('invalid_history_transition');v.forEach(id);return v;}; const raw=object(value);if(raw.version!==1)throw fault('history_transition_interpreter_unavailable');if(!validJson(value))throw fault('invalid_history_transition');integer(raw.rootRevision);if(raw.entryId!==undefined)id(raw.entryId); + // 同一侧 undo/redo 中不允许重复 ID,避免同一项同时处于两个栈或重复出现。 for(const side of [raw.before,raw.after]){const s=object(side),list=[...ids(s.undo),...ids(s.redo)];if(new Set(list).size!==list.length)throw fault('invalid_history_transition');} if(!Array.isArray(raw.entries))throw fault('invalid_history_transition');const seen=new Set(); for(const item of raw.entries){const p=object(item);id(p.id);if(seen.has(p.id as string))throw fault('invalid_history_transition');seen.add(p.id as string); for(const side of [p.before,p.after])if(side!==null){const m=object(side);if(typeof m.label!=='string'||['id','changes','operationIds'].some(key=>Object.hasOwn(m,key)))throw fault('invalid_history_transition');if(m.groupId!==undefined)id(m.groupId);} + // 两边保留的前后缀长度必须一致;不存在的历史项不允许带非零原数组长度。 for(const key of ['changes','operationIds']){const patch=object(p[key]);for(const field of ['offset','beforeLength','afterLength'])integer(patch[field]);if(!Array.isArray(patch.before)||!Array.isArray(patch.after))throw fault('invalid_history_transition');const{offset,beforeLength,afterLength}=patch as unknown as Splice;if(offset+patch.before.length>beforeLength||offset+patch.after.length>afterLength||beforeLength-patch.before.length!==afterLength-patch.after.length)throw fault('invalid_history_transition');if(key==='operationIds'){ids(patch.before);ids(patch.after);}else{checkChanges?.(patch.before);checkChanges?.(patch.after);} if(p.before===null&&beforeLength!==0||p.after===null&&afterLength!==0)throw fault('invalid_history_transition'); } } } /** Conditional metadata-only replay. A mismatch leaves the supplied history untouched. */ +/** 先检查栈和每个区间仍匹配预期,再在私有副本上正向/反向回放元数据。 + * 任一冲突抛出而不修改输入 history;不能拿旧 journal 覆盖新的历史顺序。 */ export function applyHistoryTransition(history:HistoryState,transition:HistoryTransition,direction:'forward'|'reverse'):HistoryState{ validateHistoryTransition(transition);const reverse=direction==='reverse',source=reverse?transition.after:transition.before,destination=reverse?transition.before:transition.after; if(!equal({undo:history.undo,redo:history.redo},source))throw fault('history_transition_conflict'); + /** 按方向交换 expected/insert;同时比较总长度和旧区间,防止错位拼接。 */ function apply(current:readonly T[],patch:Splice):readonly T[]{const expected=reverse?patch.after:patch.before,insert=reverse?patch.before:patch.after,length=reverse?patch.afterLength:patch.beforeLength;if(current.length!==length||!equal(current.slice(patch.offset,patch.offset+expected.length),expected))throw fault('history_transition_conflict');return[...current.slice(0,patch.offset),...insert,...current.slice(patch.offset+expected.length)];} + // 各 entry 使用独立数组补丁,新增/删除也校验旧元数据,不直接拿整份快照覆盖。 const entries=new Map(history.entries); for(const patch of transition.entries){const entry=entries.get(patch.id),expected=reverse?patch.after:patch.before,next=reverse?patch.before:patch.after;if(!equal(meta(entry),expected))throw fault('history_transition_conflict');const changes=apply(entry?.changes??[],patch.changes),operationIds=apply(entry?.operationIds??[],patch.operationIds);if(next)entries.set(patch.id,freeze({id:patch.id,...next,changes,operationIds}));else entries.delete(patch.id);} + // 最终两栈必须精确覆盖 entries 表,不能留下孤立项或指向缺失 entry 的栈 ID。 const ids=[...destination.undo,...destination.redo];if(ids.length!==entries.size||ids.some(id=>!entries.has(id)))throw fault('history_transition_conflict'); return Object.freeze({undo:freeze([...destination.undo]),redo:freeze([...destination.redo]),entries:readMap(entries)}); } +/** 汇总增量两侧出现的 Change,供资源保留扫描使用;不执行这些修改。 + * 只有自有区间且整条 transition 深不可变时才缓存,否则每次重新计算。 */ export function historyTransitionChanges(transition:HistoryTransition):readonly Change[]{ const cached=transitionChanges.get(transition);if(cached)return cached; validateHistoryTransition(transition); const changes=transition.entries.flatMap(patch=>[...patch.changes.before,...patch.changes.after]); + // 属性来自原型或 getter 时不可把一次结果当永久事实,避免错误释放资源 pin。 const ownRanges=Object.hasOwn(transition,'entries')&&transition.entries.every(patch=>Object.hasOwn(patch,'changes')&&Object.hasOwn(patch.changes,'before')&&Object.hasOwn(patch.changes,'after')); if(!ownRanges||!immutablePlainData(transition))return changes; const result=Object.freeze(changes);transitionChanges.set(transition,result);return result; diff --git a/src/lib/canvas-core/history.ts b/src/lib/canvas-core/history.ts index 4bfb544..2ce48d6 100644 --- a/src/lib/canvas-core/history.ts +++ b/src/lib/canvas-core/history.ts @@ -1,22 +1,35 @@ +/** + * 历史执行规划:把 undo/redo 给出的 changes 应用到私有文档副本。 + * 本模块不移动 undo/redo 栈、不发请求;editor/coordinator 在规划成功后统一发布。 + * 删除后恢复的元素沿用 ID,但必须获得大于墓碑的新 incarnation,使旧异步回调失效。 + */ import type { Change, Document, ElementRecord, Id, Registry } from './types.ts'; import { applyChanges, validateDocument } from './document.ts'; import { freeze } from './values.ts'; /** Pure history execution, also used to validate recovery proposals before publication. */ +/** 顺序回放一组历史修改,完成整份文档校验后返回候选文档和实际 changes。 + * remap 供调用者把其它历史目标同步映射到恢复后的 incarnation;失败时原文档不变。 */ export function planHistoryChanges(initial:Document,planned:readonly Change[],registry:Registry){ + // ID →(旧 incarnation → 新 incarnation);同一批恢复可能形成多级映射链。 const generations=new Map>(); + // 追踪到最终代次,避免后面的字段修改仍指向本批较早的已替换代次。 const incarnation=(id:Id,value:number)=>{const mapping=generations.get(id);while(mapping?.has(value))value=mapping.get(value)!;return value;}; + // 只改生命周期身份:字段目标与整元素 before/after 都要一致,canvas 变更原样通过。 const remap=(change:Change):Change=>{ if(change.kind==='field'){const next=incarnation(change.target.id,change.target.incarnation);return next===change.target.incarnation?change:freeze({...change,target:{...change.target,incarnation:next}});} if(change.kind==='element'){const record=(value:ElementRecord|null)=>value?{...value,incarnation:incarnation(value.id,value.incarnation)}:null;return freeze({...change,before:record(change.before),after:record(change.after)});} return change; }; let document=initial;const changes:Change[]=[]; + // 私有 draft 逐项推进,因此同一批后面的恢复可以看到前面已写入的墓碑。 for(const original of planned){ let change=remap(original); + // 从不存在恢复为存在时,若原代次已死,分配 dead+1 并重写本条及后续相关变化。 if(change.kind==='element'&&!change.before&&change.after){const dead=document.tombstones.get(change.id);if(dead!==undefined&&change.after.incarnation<=dead){const mapping=generations.get(change.id)??new Map();mapping.set(change.after.incarnation,dead+1);generations.set(change.id,mapping);change=remap(change);}} document=applyChanges(document,[change]);changes.push(change); } + // 暂态允许先恢复端点再恢复边;只有整批完成后才检查最终树、引用和 owner 一致性。 validateDocument(document,registry); return{document,changes:freeze(changes),remap,restored:generations.size>0}; } diff --git a/src/lib/canvas-core/structure.ts b/src/lib/canvas-core/structure.ts index 5d98ed7..0deee89 100644 --- a/src/lib/canvas-core/structure.ts +++ b/src/lib/canvas-core/structure.ts @@ -1,3 +1,7 @@ +/** + * 文档结构修改的纯规划函数:删除拥有子树,以及重挂后的连线 owner 修复。 + * 只返回条件 Change,不直接发布 editor 状态;调用方把它们合入一次事务再校验。 + */ import type {DocumentQueries} from './queries.ts'; import type { Change, Document, Id, Target } from './types.ts'; import { commonCanvas, targetElement } from './document.ts'; @@ -5,17 +9,23 @@ import { canvasTransform, convertPath } from './coordinates.ts'; import { equal, freeze, fault } from './values.ts'; /** Own the subtree explicitly; geometry/visibility never selects which descendants are deleted. */ +/** 删除目标、由它作为入口拥有的全部子 canvas/成员,以及这些成员关联的边。 + * queries 是可选的同版本关系索引;没有索引时扫描规范化文档,删除范围不依赖 DOM。 */ export function removalChanges(document:Document,target:Target,queries?:DocumentQueries):readonly Change[] { + // 索引快路径:先核对 snapshot 身份,再用显式 DFS stack 遍历拥有树。 if(queries){ if(queries.document!==document)throw fault('stale_query_snapshot');const element=targetElement(document,target),elements=new Set([element.id]),canvases=new Set(),child=queries.relations.childCanvasByEntry.get(element.id),stack=child?[child]:[]; while(stack.length){const id=stack.pop()!;if(canvases.has(id))continue;canvases.add(id);stack.push(...queries.childCanvases(id));for(const member of queries.members(id))elements.add(member);} + // 关联边可能属于更上级 canvas,即使不在子树成员表中也必须随端点删除。 for(const id of [...elements])for(const edge of queries.incident(id))elements.add(edge); return freeze([...queries.orderedElements(elements).map(id=>({kind:'element' as const,id,before:document.elements.get(id)!,after:null})),...queries.orderedCanvases(canvases).map(id=>({kind:'canvas' as const,id,before:document.canvases.get(id)!,after:null}))]); } + // 扫描路径构造 parent→children 临时表;Set 去重,stack 避免 JS 递归调用栈增长。 const element=targetElement(document,target),elements=new Set([element.id]),canvases=new Set(),children=new Map(); for(const canvas of document.canvases.values())if(canvas.parent){const list=children.get(canvas.parent.canvasId)??[];list.push(canvas.id);children.set(canvas.parent.canvasId,list);} const stack=[...document.canvases.values()].filter(canvas=>canvas.parent?.entryElementId===element.id).map(canvas=>canvas.id); while(stack.length){const id=stack.pop()!;if(canvases.has(id))continue;canvases.add(id);for(const child of children.get(id)??[])stack.push(child);} + // 先收集全部被删成员,再找相关边,防止边扫描先后顺序影响删除结果。 for(const record of document.elements.values())if(canvases.has(record.ownerCanvasId))elements.add(record.id); for(const record of document.elements.values())if(record.structure==='connection'&&(elements.has(record.source.elementId)||elements.has(record.target.elementId)))elements.add(record.id); return freeze([ @@ -25,11 +35,14 @@ export function removalChanges(document:Document,target:Target,queries?:Document } /** Ownership changes and their curve coordinates are one operation, with the same stable edge identity. */ +/** 端点重挂后,把边移到两端所属 canvas 的最近共同上级,保持边 ID/incarnation。 + * 曲线坐标属于旧 owner 空间,因此 owner 与 path 必须作为同一操作一起转换。 */ export function repairConnectionOwners(before:Document,document:Document):readonly Change[] { const changes:Change[]=[]; for(const edge of document.elements.values())if(edge.structure==='connection'){ const source=document.elements.get(edge.source.elementId),target=document.elements.get(edge.target.elementId);if(!source||!target)continue; const owner=commonCanvas(document,source.ownerCanvasId,target.ownerCanvasId);if(owner===edge.ownerCanvasId)continue; + // 旧 owner 可能已从新文档移除:优先用 before 的变换解释旧曲线,再转到新 owner。 const path=convertPath(edge.path,canvasTransform(before.canvases.has(edge.ownerCanvasId)?before:document,edge.ownerCanvasId),canvasTransform(document,owner)); changes.push({kind:'field',target:{id:edge.id,incarnation:edge.incarnation},field:'ownerCanvasId',before:edge.ownerCanvasId,after:owner}); if(!equal(edge.path,path))changes.push({kind:'field',target:{id:edge.id,incarnation:edge.incarnation},field:'path',before:edge.path,after:path}); diff --git a/src/lib/canvas-core/units.ts b/src/lib/canvas-core/units.ts index 75c0414..fd8ca79 100644 --- a/src/lib/canvas-core/units.ts +++ b/src/lib/canvas-core/units.ts @@ -1,27 +1,40 @@ +/** + * 表现单位换算:presentation.unitScale 表示视觉尺度,不改变元素的归属关系。 + * 没有 unitScale 时按 1 解释;其它 presentation 字段可能属于宿主,必须无损保留。 + */ import type { Json } from './types.ts'; import { fault } from './values.ts'; +/** 框架保留的表现元数据字段;可选表示使用默认比例。 */ export type PresentationUnits = { readonly unitScale?: number }; /** Reserved presentation metadata, independent of canvas ownership. Absence means one visual unit per owner unit. */ +/** 这里只校验可识别的 unitScale;非对象值交给更严格的写入路径决定是否支持。 */ export function presentationUnitsValid(value:Json|undefined):boolean { if(!value||typeof value!=='object'||Array.isArray(value))return true; return value.unitScale===undefined||typeof value.unitScale==='number'&&Number.isFinite(value.unitScale)&&value.unitScale>0; } +/** 读取有效比例,缺省为 1;对象中显式出现非法比例时拒绝继续计算。 */ export function unitScale(value:Json|undefined):number { if(!presentationUnitsValid(value))throw fault('invalid_presentation_units'); return value&&typeof value==='object'&&!Array.isArray(value)&&typeof value.unitScale==='number'?value.unitScale:1; } +/** 普通数值比较有绝对误差下限,适合判断计算结果是否应归一到 1。 */ export const closeNumber=(a:number,b:number)=>Math.abs(a-b)<=Number.EPSILON*64*Math.max(1,Math.abs(a),Math.abs(b)); +/** 比例比较使用相对误差,避免极小但不同的有效比例被绝对阈值吞掉。 */ export const closeScale=(a:number,b:number)=>Math.abs(a-b)<=Number.EPSILON*64*Math.max(Number.MIN_VALUE,Math.abs(a),Math.abs(b)); +/** 获取可合并的对象元数据;空值当空表,原始值/数组拒绝,以免合并时丢失原值。 */ export function presentationRecord(value:Json|undefined):Readonly> { if(value===undefined||value===null)return{}; if(typeof value!=='object'||Array.isArray(value))throw fault('unsupported_presentation_units'); unitScale(value);return value; } /** Normalize only the framework-owned unit field; all other presentation properties survive. */ +/** 返回替换比例后的元数据:等效值复用原对象,比例为 1 时省略保留字段。 + * 仅更新 unitScale,不清掉宿主扩展字段;字段全部清空时返回 undefined。 */ export function withUnitScale(value:Json|undefined,scale:number):Json|undefined { if(!Number.isFinite(scale)||scale<=0)throw fault('invalid_presentation_units'); + // 坐标换算可能产生接近 1 的浮点误差,统一表示能减少无意义的模型修改。 const normalized=closeNumber(scale,1)?1:scale; if(closeScale(unitScale(value),normalized))return value; const fields=new Map(Object.entries(presentationRecord(value))); diff --git a/src/lib/canvas-core/values.ts b/src/lib/canvas-core/values.ts index 480dc91..b3f5226 100644 --- a/src/lib/canvas-core/values.ts +++ b/src/lib/canvas-core/values.ts @@ -1,12 +1,21 @@ +/** + * 内核共享的值操作:错误归一化、JSON 值复制/比较,以及运行时只读集合。 + * 这些工具不读取网络或编辑器状态。注意类型上的 readonly 与运行时不可修改不是一回事; + * readMap/readSet 通过隐藏可变容器实现后者,freeze 不会使 Map 的 set 方法失效。 + */ import type { Id, Json, ReadonlyValueSet } from './types.ts'; +/** 构造携带稳定错误码和相关对象 ID 的错误,供命令层返回可解释的拒绝结果。 */ export function fault(code: string, targets: readonly Id[] = []): Error & { code: string; targets: readonly Id[] } { return Object.assign(new Error(code), { code, targets }); } +/** 将捕获的异常归一化为只读 rejected 结果;无领域错误码时使用 invalid_command。 */ export function reject(error: unknown) { const value = error as { code?: string; targets?: readonly string[] }; return Object.freeze({ status: 'rejected' as const, code: value?.code || 'invalid_command', targets: value?.targets || [] }); } +/** 递归冻结普通对象/数组的可枚举值;已冻结对象直接返回,不继续检查其子树。 + * 调用者负责提供适合此工具的数据,不能用它证明任意外部对象已经深度不可变。 */ export function freeze(value: T): T { if (value && typeof value === 'object' && !Object.isFrozen(value)) { for (const child of Object.values(value)) freeze(child); @@ -14,30 +23,40 @@ export function freeze(value: T): T { } return value; } +/** JSON 往返复制已验证的数据,切断共享引用;undefined 原样返回。 + * 此处不是校验器:JSON 会丢弃某些非法字段,因此外部输入必须在复制前验证。 */ export function clone(value: T): T { if (value === undefined) return value; return JSON.parse(JSON.stringify(value)) as T; } +/** 按可枚举自有键递归比较 JSON 风格的值;数组与对象不能互相视为相等。 + * 不处理任意循环对象,也不把 Map/Set 等实例当成业务值来比较。 */ export function equal(a: unknown, b: unknown): boolean { if (Object.is(a, b)) return true; if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || Array.isArray(a) !== Array.isArray(b)) return false; const left = Object.keys(a), right = Object.keys(b); return left.length === right.length && left.every(key => Object.prototype.hasOwnProperty.call(b, key) && equal((a as Record)[key], (b as Record)[key])); } +// 只在本模块中标记只读视图的来源,WeakSet 不会阻止无引用的视图被回收。 const ownedMapViews=new WeakSet(); /** Internal provenance only; table ownership does not imply immutable values. */ +/** 查询“是否由 readMap 创建”,不证明表内每个 value 已冻结或通过 schema。 */ export function isOwnedMapView(value:object){return ownedMapViews.has(value);} /** A capability-safe read view: unlike TS ReadonlyMap, no .set is exposed at runtime. */ +/** 复制表成员并仅暴露读取能力,外部既拿不到原 Map,也拿不到 set/delete。 + * value 保留原引用,深度不可变性由生产这些 value 的模块负责。 */ export function readMap(input: ReadonlyMap): ReadonlyMap { const map = new Map(input); const view: ReadonlyMap = { get size() { return map.size; }, get: key => map.get(key), has: key => map.has(key), entries: () => map.entries(), keys: () => map.keys(), values: () => map.values(), [Symbol.iterator]: () => map[Symbol.iterator](), + // 回调的第三个参数也必须是安全视图,不能把内部可变 Map 泄漏出去。 forEach: (callback, thisArg) => map.forEach((value, key) => callback.call(thisArg, value, key, view)), }; const result=Object.freeze(view);ownedMapViews.add(result);return result; } +/** 捕获一份成员集合并隐藏 add/delete;回调同样只接触只读视图。 */ export function readSet(input: Iterable): ReadonlyValueSet { const set = new Set(input); const view: ReadonlyValueSet = { @@ -46,6 +65,8 @@ export function readSet(input: Iterable): ReadonlyValueSet { }; return Object.freeze(view); } +/** 递归检查 JSON 值域:排除 undefined/函数/非有限数,最多允许 64 层。 + * 这里只检查值形状,不验证组件 schema,也不是所有原型/属性描述符的严格捕获器。 */ export function validJson(value: unknown, depth = 0): value is Json { if (depth > 64) return false; if (value === null || typeof value === 'string' || typeof value === 'boolean') return true;