docs(canvas): explain document changes and history lifecycle in Chinese
This commit is contained in:
@@ -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<object>();
|
||||
/** 冻结最外层文档并标记工厂来源;该标记只证明 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<Id, number> = 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<Id>();
|
||||
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<object>();
|
||||
/** 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<Id,number> = 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<Id>):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<void,void>{
|
||||
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<void,void>{yield* validateDocumentChecks(document,registry,false);}
|
||||
/** 含组件 payload 的完整分片校验;必须迭代到结束才证明整份文档通过。 */
|
||||
export function* validateDocumentSteps(document:Document,registry:Registry):Generator<void,void>{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<ReplayChangeBatch>):Document {
|
||||
return applyChangeBatches(document,batches,true,true);
|
||||
}
|
||||
/** 三类变化共用的执行器:field 改字段、element 增删元素、canvas 改树记录。
|
||||
* 仅在全部循环成功后返回冻结 envelope,不泄漏半成品;此处不做整图合法性校验。 */
|
||||
function applyChangeBatches(document:Document,batches:Iterable<ReplayChangeBatch>,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<ReplayChangeBatch
|
||||
if(change.kind==='field'){const owner=elements.get(change.target.id)?.ownerCanvasId;if(owner&&batch.canvasIds?.length&&!batch.canvasIds.includes(owner))throw fault('operation_context_changed',[change.target.id]);}
|
||||
if(changeSatisfied(elements,canvases,change))continue;
|
||||
}
|
||||
// 字段级条件检查同时比较值与“字段是否存在”,区分缺失和显式 null。
|
||||
if (change.kind === 'field') {
|
||||
if (!validJson(change.after)) throw fault('invalid_change');
|
||||
if (!change.field.startsWith('data.') && !['geometry', 'ownerCanvasId', 'source', 'target', 'path'].includes(change.field)) throw fault('invalid_field');
|
||||
const element = elements.get(change.target.id);
|
||||
if (!element || element.incarnation !== change.target.incarnation) throw fault('stale_incarnation', [change.target.id]);
|
||||
// data. 后面的部分是一个顶层数据键,不在这里按点路径递归写嵌套对象。
|
||||
const dataKey = change.field.startsWith('data.') ? change.field.slice(5) : null;
|
||||
const prior = dataKey !== null ? element.data[dataKey] ?? null : (element as unknown as Record<string, unknown>)[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<ReplayChangeBatch
|
||||
if (change.afterPresent === false) delete data[dataKey]; else data[dataKey] = clone(change.after);
|
||||
elements.set(element.id, freeze({ ...element, data }));
|
||||
} else elements.set(element.id, freeze({ ...element, [change.field]: clone(change.after) }) as ElementRecord);
|
||||
// 整元素修改必须匹配 before;删除同时保留墓碑,使旧 incarnation 永远不能复活。
|
||||
} else if (change.kind === 'element') {
|
||||
if (conditional && !equal(elements.get(change.id) || null, change.before)) throw fault('element_conflict', [change.id]);
|
||||
if (change.after) {
|
||||
@@ -138,6 +170,7 @@ function applyChangeBatches(document:Document,batches:Iterable<ReplayChangeBatch
|
||||
if (old) tombstones.set(change.id, Math.max(tombstones.get(change.id) || 0, old.incarnation));
|
||||
elements.delete(change.id);
|
||||
}
|
||||
// canvas 删除只移除该记录;子树/连线清理由 structure 规划器显式生成,避免隐藏副作用。
|
||||
} else if (change.kind === 'canvas') {
|
||||
if (conditional && !equal(canvases.get(change.id) || null, change.before)) throw fault('canvas_conflict', [change.id]);
|
||||
if (change.after) {
|
||||
@@ -149,6 +182,7 @@ function applyChangeBatches(document:Document,batches:Iterable<ReplayChangeBatch
|
||||
}
|
||||
return documentEnvelope({ projectId: document.projectId, elements: readMap(elements), canvases: readMap(canvases), tombstones: readMap(tombstones) });
|
||||
}
|
||||
/** 检测回放目标效果是否已经存在;字段目标必须仍是同一个 incarnation 才可视为满足。 */
|
||||
function changeSatisfied(elements:ReadonlyMap<Id,ElementRecord>,canvases:ReadonlyMap<Id,CanvasRecord>,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<Id,ElementRecord>,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<string,unknown>)[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})});}
|
||||
|
||||
@@ -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<T>={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<Change>;readonly operationIds:Splice<Id>};
|
||||
/** 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<HistoryTransition,readonly Change[]>();
|
||||
const immutableData=new WeakSet<object>();
|
||||
/** 检查可安全跨持久切点复用的深不可变普通数据,最多 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<T>(before:readonly T[],after:readonly T[]):Splice<T>{
|
||||
let offset=0,suffix=0;while(offset<before.length&&offset<after.length&&equal(before[offset],after[offset]))offset++;
|
||||
while(suffix<before.length-offset&&suffix<after.length-offset&&equal(before[before.length-suffix-1],after[after.length-suffix-1]))suffix++;
|
||||
return{offset,beforeLength:before.length,afterLength:after.length,before:before.slice(offset,before.length-suffix),after:after.slice(offset,after.length-suffix)};
|
||||
}
|
||||
/** Store changed ranges only. Clearing redo/eviction still retains the removed entry records needed to reverse that transition. */
|
||||
/** 对比两份 history,记录新增、删除和修改的 entry 及两栈顺序。
|
||||
* 默认无变化不造记录;recordNoop 用于调用方显式需要记录发布边界的情形。 */
|
||||
export function describeHistoryTransition(before:HistoryState,after:HistoryState,rootRevision:number,entryId?:Id,recordNoop=false):HistoryTransition|undefined{
|
||||
const entries:EntryPatch[]=[];
|
||||
for(const id of new Set([...before.entries.keys(),...after.entries.keys()])){const a=before.entries.get(id),b=after.entries.get(id);if(equal(a,b))continue;entries.push({id,before:meta(a),after:meta(b),changes:splice(a?.changes??[],b?.changes??[]),operationIds:splice(a?.operationIds??[],b?.operationIds??[])});}
|
||||
if(!recordNoop&&!entries.length&&equal(before.undo,after.undo)&&equal(before.redo,after.redo))return undefined;
|
||||
return freeze({version:1,rootRevision,...(entryId?{entryId}:{}),before:{undo:before.undo,redo:before.redo},after:{undo:after.undo,redo:after.redo},entries});
|
||||
}
|
||||
/** 验证外部/恢复的增量 DTO,未知 version 明确要求其它解释器。
|
||||
* checkChanges 可由调用方注入更严格的 Change 校验,本函数本身不依赖 registry。 */
|
||||
export function validateHistoryTransition(value:unknown,checkChanges?:(value:unknown)=>unknown):asserts value is HistoryTransition{
|
||||
// 四个局部校验器统一抛领域错误,分别约束普通记录、非负整数、单 ID 和 ID 数组。
|
||||
const object=(v:unknown):Record<string,unknown>=>{if(!v||typeof v!=='object'||Array.isArray(v))throw fault('invalid_history_transition');return v as Record<string,unknown>;};
|
||||
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<string>();
|
||||
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<unknown>;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<T>(current:readonly T[],patch:Splice<T>):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;
|
||||
|
||||
@@ -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<Id,Map<number,number>>();
|
||||
// 追踪到最终代次,避免后面的字段修改仍指向本批较早的已替换代次。
|
||||
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<number,number>();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};
|
||||
}
|
||||
|
||||
@@ -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<Id>([element.id]),canvases=new Set<Id>(),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<Id>([element.id]),canvases=new Set<Id>(),children=new Map<Id,Id[]>();
|
||||
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});
|
||||
|
||||
@@ -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<Record<string,Json>> {
|
||||
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)));
|
||||
|
||||
@@ -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<T>(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<T>(value: T): T {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
/** JSON 往返复制已验证的数据,切断共享引用;undefined 原样返回。
|
||||
* 此处不是校验器:JSON 会丢弃某些非法字段,因此外部输入必须在复制前验证。 */
|
||||
export function clone<T>(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<string, unknown>)[key], (b as Record<string, unknown>)[key]));
|
||||
}
|
||||
// 只在本模块中标记只读视图的来源,WeakSet 不会阻止无引用的视图被回收。
|
||||
const ownedMapViews=new WeakSet<object>();
|
||||
/** 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<K, V>(input: ReadonlyMap<K, V>): ReadonlyMap<K, V> {
|
||||
const map = new Map(input);
|
||||
const view: ReadonlyMap<K, V> = {
|
||||
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<T>(input: Iterable<T>): ReadonlyValueSet<T> {
|
||||
const set = new Set(input);
|
||||
const view: ReadonlyValueSet<T> = {
|
||||
@@ -46,6 +65,8 @@ export function readSet<T>(input: Iterable<T>): ReadonlyValueSet<T> {
|
||||
};
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user