Replan structural edits with shared coordinates and lifecycle-safe history
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Id, Json, OperationIntent } from '@/lib/canvas-core/types';
|
||||
import type { Change, Document, Id, Json, OperationIntent } from '@/lib/canvas-core/types';
|
||||
import type { ProjectEngine } from '@/lib/canvas-runtime/workspace';
|
||||
import type { RecoverySession } from '@/lib/canvas-runtime/engine';
|
||||
import type { ConflictPlan, RecoveryChoice } from '@/lib/canvas-runtime/conflicts';
|
||||
@@ -17,7 +17,11 @@ function message(error:unknown){
|
||||
if(code==='stale_incarnation')return '服务端已删除或替换了这个元素。可以放弃这项本地修改。';
|
||||
if(code==='operation_context_changed')return '元素所属分区已改变,请重新预览这项修改。';
|
||||
if(code==='ownership_conflict')return '元素已移入其它分区,当前坐标需要重新确认,暂时无法直接保留。';
|
||||
if(code?.startsWith('recovery_history')||code==='recovery_structure_not_supported')return '这批修改含结构或撤销操作,暂时无法自动处理。原内容和历史均已保留。';
|
||||
if(code==='recovery_identity_collision')return '这个身份已在服务端使用,不能覆盖另一份内容。可以放弃本地创建,或重新创建一个元素。';
|
||||
if(code==='stale_endpoint')return '连线所指元素已删除或进入新的生命周期,请重新选择端点。';
|
||||
if(code==='container_scale_conversion_required')return '这个分区包含子内容,跨不同缩放空间的整体转换尚未支持,原内容仍已保留。';
|
||||
if(code==='unsupported_coordinate_path')return '这条曲线使用未知坐标格式,当前无法安全转换归属,原路径仍已保留。';
|
||||
if(code?.startsWith('recovery_history')||code==='recovery_record_replacement_not_supported')return '这批修改含结构或撤销操作,暂时无法自动处理。原内容和历史均已保留。';
|
||||
if(code==='recovery_action_requires_new_command')return '生成任务需要重新手动发起。可以在这里放弃尚未执行的请求。';
|
||||
if(code==='forbidden'||code==='unauthorized'||code==='identity_changed')return '当前账号无法读取或修改此画布,请确认权限后重试。';
|
||||
return '处理未完成,当前修改仍已保留。请重试,或恢复编辑后稍后处理。';
|
||||
@@ -29,6 +33,21 @@ function PreviewValue({value}:{value:Json|undefined}){
|
||||
}
|
||||
const fieldLabel=(field:string)=>({'data.title':'标题','data.text':'正文',geometry:'位置与尺寸',path:'连线路径'}[field]??(field.startsWith('data.')?'内容字段':'元素关系'));
|
||||
|
||||
function StructureReview({change,document}:{change:Exclude<Change,{kind:'field'}>;document:Document}){
|
||||
const current=change.kind==='element'?document.elements.get(change.id):document.canvases.get(change.id),kind=change.kind==='element'?'元素':'分区';
|
||||
const title=change.kind==='element'?(change.after?.data.title??change.before?.data.title??change.id):change.id;
|
||||
return <div style={{fontSize:13,overflowWrap:'anywhere',marginTop:8}}><strong>{change.after===null?'删除':change.before===null?'创建':'调整'}{kind} · {String(title)}</strong>
|
||||
<div>服务端:{current?<PreviewValue value={current as unknown as Json}/>:<span>不存在</span>}</div><div>本地请求:{change.after?<PreviewValue value={change.after as unknown as Json}/>:<span>删除此{kind}</span>}</div></div>;
|
||||
}
|
||||
function PlanStructureReview({plan}:{plan:ConflictPlan}){
|
||||
const changes=plan.operationIds.flatMap(id=>plan.operations.get(id)!.changes),structural=changes.filter(change=>change.kind!=='field'||['ownerCanvasId','source','target','path'].includes(change.field));
|
||||
if(!structural.length)return null;
|
||||
const elements=structural.filter(change=>change.kind==='element'&&change.after===null).length,canvases=structural.filter(change=>change.kind==='canvas'&&change.after===null).length;
|
||||
return <details><summary>关联结构变更 {structural.length} 项 · 删除 {elements} 个元素、{canvases} 个分区(展开查看)</summary>
|
||||
{structural.map((change,index)=><div key={index} style={{borderBottom:'1px solid #ddd5ca',fontSize:12,overflowWrap:'anywhere',padding:8}}><strong>{change.kind==='field'?fieldLabel(change.field):change.kind==='element'?'元素':'分区'} · {change.kind==='field'?change.target.id:change.id}</strong><div>修改前:<PreviewValue value={change.before as unknown as Json}/></div><div>修改后:<PreviewValue value={change.after as unknown as Json}/></div></div>)}
|
||||
</details>;
|
||||
}
|
||||
|
||||
export function HeadlessRecoveryDialog({engine,close}:{engine:ProjectEngine;close():void}){
|
||||
const owner=useRef<RecoverySession|undefined>(undefined),[session,setSession]=useState<RecoverySession>(),[records,setRecords]=useState<readonly OperationIntent[]>([]);
|
||||
const [choices,setChoices]=useState(new Map<Id,RecoveryChoice>()),[plan,setPlan]=useState<ConflictPlan>(),[error,setError]=useState(''),[busy,setBusy]=useState(true),[frozen,setFrozen]=useState(false);
|
||||
@@ -48,12 +67,13 @@ export function HeadlessRecoveryDialog({engine,close}:{engine:ProjectEngine;clos
|
||||
{session&&records.length===0&&<p>没有待处理的本地修改,可以采用服务端最新内容。</p>}
|
||||
{session&&records.map((intent,index)=><article key={intent.id} style={{borderBottom:'1px solid #ddd5ca',paddingBottom:12}}>
|
||||
<strong>修改 {index+1}{intent.operationType==='run.start'?' · 尚未受理的生成':intent.operationType==='run.cancel'?' · 尚未确认的取消':''}</strong>
|
||||
{intent.changes.map((change,changeIndex)=>{if(change.kind!=='field')return <p key={changeIndex}>包含元素或分区结构修改</p>;const element=canonical?.elements.get(change.target.id),key=change.field.startsWith('data.')?change.field.slice(5):undefined,value=key===undefined?(element as unknown as Record<string,Json>|undefined)?.[change.field]:element?.data[key],proposed=plan?.document.elements.get(change.target.id),proposedValue=key===undefined?(proposed as unknown as Record<string,Json>|undefined)?.[change.field]:proposed?.data[key];return <div key={changeIndex} style={{fontSize:13,whiteSpace:'pre-wrap',overflowWrap:'anywhere',marginTop:8}}><span>{fieldLabel(change.field)}</span><div>服务端:{element?<PreviewValue value={value}/>:'元素已不存在'}</div><div>本地:{change.afterPresent===false?'移除此字段':<PreviewValue value={change.after}/>}</div>{plan&&<div>处理后:{proposed?<PreviewValue value={proposedValue}/>:'元素已不存在'}</div>}</div>;})}
|
||||
{intent.changes.map((change,changeIndex)=>{if(change.kind!=='field')return <StructureReview key={changeIndex} change={change} document={canonical!}/>;const element=canonical?.elements.get(change.target.id),key=change.field.startsWith('data.')?change.field.slice(5):undefined,value=key===undefined?(element as unknown as Record<string,Json>|undefined)?.[change.field]:element?.data[key],proposed=plan?.document.elements.get(change.target.id),proposedValue=key===undefined?(proposed as unknown as Record<string,Json>|undefined)?.[change.field]:proposed?.data[key];return <div key={changeIndex} style={{fontSize:13,whiteSpace:'pre-wrap',overflowWrap:'anywhere',marginTop:8}}><span>{fieldLabel(change.field)}</span><div>服务端:{element?<PreviewValue value={value}/>:'元素已不存在'}</div><div>本地:{change.afterPresent===false?'移除此字段':<PreviewValue value={change.after}/>}</div>{plan&&<div>处理后:{proposed?<PreviewValue value={proposedValue}/>:'元素已不存在'}</div>}</div>;})}
|
||||
<select aria-label={`处理第${index+1}项修改`} value={choices.get(intent.id)??''} disabled={busy||frozen} onChange={event=>{const next=new Map(choices);if(event.target.value)next.set(intent.id,event.target.value as RecoveryChoice);else next.delete(intent.id);setChoices(next);setPlan(undefined);setError('');}}>
|
||||
<option value="">选择处理方式</option>{['document.change','run.apply'].includes(intent.operationType)&&<option value="keep-local">保留这项本地修改</option>}<option value="discard-local">放弃这项本地修改</option>
|
||||
</select>
|
||||
</article>)}
|
||||
{plan&&<p role="status">方案已生成:保留 {plan.audit.decisions.filter(item=>item.choice==='keep-local').length} 项,放弃 {plan.audit.decisions.filter(item=>item.choice==='discard-local').length} 项。撤销记录由 {engine.getSnapshot().history.undo.length} 项调整为 {plan.history.undo.length} 项。</p>}
|
||||
{plan&&<PlanStructureReview plan={plan}/>}
|
||||
{error&&<p role="alert">{error}</p>}
|
||||
<DialogFooter><button disabled={busy&&!!session} onClick={()=>void cancel()}>{session?'取消并恢复编辑':'恢复编辑'}</button>
|
||||
{session&&records.length>0&&<button aria-label="预览处理方案" disabled={busy||frozen||choices.size!==records.length} onClick={()=>{try{setPlan(session.plan(choices));setError('');}catch(error){setPlan(undefined);setError(message(error));}}}>预览处理方案</button>}
|
||||
|
||||
@@ -4,26 +4,12 @@ import { fault } from '../canvas-core/values.ts';
|
||||
import type { CanvasNode } from '../canvas-elements.ts';
|
||||
import type { CanvasEdgeModel, EdgeData } from '../canvas-v2.ts';
|
||||
|
||||
export type Transform = { x: number; y: number; scale: number; rotation: number };
|
||||
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 }; }
|
||||
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); }
|
||||
import { childCanvasTransform, toWorld, transformPath, type Transform } from '../canvas-core/coordinates.ts';
|
||||
export { toWorld, toLocal, transformPath, type Transform } from '../canvas-core/coordinates.ts';
|
||||
const origin:Transform={x:0,y:0,scale:1,rotation:0};
|
||||
export type ProjectionNode = Omit<CanvasNode, 'type'> & { type: NonNullable<CanvasNode['type']> | 'unsupported' };
|
||||
const nodeTypes: Record<string, NonNullable<CanvasNode['type']>> = { 'plain-text': 'plainText', 'plain-image': 'plainImage', frame: 'frame', icon: 'icon' };
|
||||
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
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;
|
||||
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;
|
||||
return { ...p, ...(inverse ? toLocal(t, { x: p.x, y: p.y }) : toWorld(t, { x: p.x, y: p.y })) };
|
||||
};
|
||||
return { ...raw, ...(raw.startControl ? { startControl: point(raw.startControl, true)! } : {}), ...(raw.endControl ? { endControl: point(raw.endControl, true)! } : {}), knots: raw.knots.map(knot => {
|
||||
const k = object(knot); return { ...k, position: point(k.position)!, in: point(k.in, true)!, out: point(k.out, true)! };
|
||||
}) };
|
||||
}
|
||||
|
||||
/** Renderer projection retains identity/owner in the model, including nested and collapsed canvases. */
|
||||
export function projectCanvas(document: Document, canvasId: Id, registry: Registry, selected: ReadonlySet<Id> | { has(id: Id): boolean }, geometry: (id: Id) => Geometry | undefined, edgePreview?: { target: { id: Id }; path: Json } | null) {
|
||||
@@ -46,13 +32,11 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
|
||||
};
|
||||
nodes.push(projected); visible.add(element.id);
|
||||
if (child) {
|
||||
const presentation = object(data.presentation), scale = typeof presentation.contentScale === 'number' && presentation.contentScale > 0 ? presentation.contentScale : 1;
|
||||
const presentation = object(data.presentation);
|
||||
if (presentation.collapsed === true || !known) {
|
||||
const stack = [child]; while (stack.length) { const hidden = stack.pop()!; hiddenBy.set(hidden, element.id); for (const canvas of document.canvases.values()) if (canvas.parent?.canvasId === hidden) stack.push(canvas.id); }
|
||||
} else {
|
||||
const offset = rotate({ x: 24 - g.width / 2, y: 36 - g.height / 2 }, g.rotation);
|
||||
const start = toWorld(transform, { x: g.x + g.width / 2 + offset.x, y: g.y + g.height / 2 + offset.y });
|
||||
transforms.set(child, { ...start, scale: transform.scale * scale, rotation: transform.rotation + g.rotation }); visit(child, depth + 1);
|
||||
transforms.set(child, childCanvasTransform(transform,g,data.presentation)); visit(child, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Document, Geometry, Id, Json, Point } from './types.ts';
|
||||
import { ancestors } from './document.ts';
|
||||
import { equal, fault } from './values.ts';
|
||||
import { connectionPathValid } from './features.ts';
|
||||
export type Transform = { x: number; y: number; scale: number; rotation: number };
|
||||
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 }; }
|
||||
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); }
|
||||
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
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;
|
||||
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;
|
||||
return { ...p, ...(inverse ? toLocal(t, { x: p.x, y: p.y }) : toWorld(t, { x: p.x, y: p.y })) };
|
||||
};
|
||||
return { ...raw, ...(raw.startControl ? { startControl: point(raw.startControl, true)! } : {}), ...(raw.endControl ? { endControl: point(raw.endControl, true)! } : {}), knots: raw.knots.map(knot => {
|
||||
const k = object(knot); return { ...k, position: point(k.position)!, in: point(k.in, true)!, out: point(k.out, true)! };
|
||||
}) };
|
||||
}
|
||||
|
||||
/** Format-1 child content origin: the same inset, scale and rotation used by renderer projection. */
|
||||
export function childCanvasTransform(parent:Transform,geometry:Geometry,presentation:Json|undefined):Transform {
|
||||
const data=object(presentation),scale=typeof data.contentScale==='number'&&data.contentScale>0?data.contentScale:1;
|
||||
const offset=rotate({x:24-geometry.width/2,y:36-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};
|
||||
}
|
||||
export function canvasTransform(document:Document,canvasId:Id):Transform {
|
||||
let result:Transform={...origin};
|
||||
for(const id of ancestors(document,canvasId).reverse()){
|
||||
const parent=document.canvases.get(id)!.parent;if(!parent)continue;
|
||||
const entry=document.elements.get(parent.entryElementId);if(!entry||entry.structure!=='node')throw fault('invalid_canvas_entry',[id]);
|
||||
result=childCanvasTransform(result,entry.geometry,entry.data.presentation);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export function convertGeometry(geometry:Geometry,from:Transform,to:Transform):Geometry {
|
||||
if(equal(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};
|
||||
}
|
||||
export function convertPath(path:Json,from:Transform,to:Transform):Json {
|
||||
if(path===null||equal(from,to))return path;
|
||||
if(!connectionPathValid(path))throw fault('unsupported_coordinate_path');
|
||||
return transformPath(transformPath(path,from),to,true);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Applied, Availability, Capability, Change, Command, Commit, CoreSnapshot, Document, ElementRecord, Geometry, HistoryEntry, HistoryState, Id, OperationIntent, Origin, Registry, Result, Snapshot } from './types.ts';
|
||||
import { applyChanges, commonCanvas, createDocument, invert, targetElement, validateDocument } from './document.ts';
|
||||
import { applyChanges, createDocument, invert, targetElement, validateDocument } from './document.ts';
|
||||
import { clone, equal, fault, freeze, readMap, reject, validJson } from './values.ts';
|
||||
|
||||
import { canvasTransform, convertGeometry } from './coordinates.ts';
|
||||
import { removalChanges, repairConnectionOwners } from './structure.ts';
|
||||
|
||||
export type EditorOptions<Runtime = undefined> = {
|
||||
registry: Registry; document: Document; createId?: () => Id; historyLimit?: number;
|
||||
policy?: (command: Command, document: Document) => Availability;
|
||||
@@ -43,6 +46,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
const canvas = document.canvases.get(command.canvasId);
|
||||
if (!canvas) throw fault('canvas_not_loaded', [command.canvasId]);
|
||||
changes = equal(canvas.parent, command.parent) ? [] : [{ kind: 'canvas', id: canvas.id, before: canvas, after: { ...canvas, parent: command.parent } }];
|
||||
if(changes.length)changes=[...changes,...repairConnectionOwners(document,applyChanges(document,changes))];
|
||||
} else if (command.type === 'element.create') {
|
||||
const element = command.element;
|
||||
if (document.elements.has(element.id) || document.tombstones.has(element.id)) throw fault('duplicate_identity', [element.id]);
|
||||
@@ -59,21 +63,23 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
if (!validJson(command.path)) throw fault('invalid_connection_path', [element.id]);
|
||||
changes = options.registry.handlers.get(element.componentId)!.get('connection.edit')!({ element, input: freeze({ patch: { path: clone(command.path) } }) });
|
||||
for (const change of changes) if (change.kind !== 'field' || change.field !== 'path' || change.target.id !== element.id || change.target.incarnation !== element.incarnation) throw fault('invalid_handler_change', [element.id]);
|
||||
} else if(command.type==='connection.endpoints'){
|
||||
if(element.structure!=='connection'||!options.registry.supports(element,'connection.edit'))throw fault('unsupported_capability',[element.id]);
|
||||
changes=[];
|
||||
for(const [field,endpoint]of [['source',command.source],['target',command.destination]] as const)if(endpoint&&!equal(element[field],endpoint))changes=[...changes,{kind:'field',target:command.target,field,before:element[field],after:clone(endpoint)}];
|
||||
if(changes.length)changes=[...changes,...repairConnectionOwners(document,applyChanges(document,changes))];
|
||||
} else if (command.type === 'element.remove') {
|
||||
changes = [{ kind: 'element', id: element.id, before: element, after: null }];
|
||||
for (const edge of document.elements.values()) if (edge.structure === 'connection' && (edge.source.elementId === element.id || edge.target.elementId === element.id)) changes = [...changes, { kind: 'element', id: edge.id, before: edge, after: null }];
|
||||
changes = removalChanges(document,command.target);
|
||||
} else if (command.type === 'element.move') {
|
||||
if (!options.registry.supports(element, 'geometry.move')) throw fault('unsupported_capability', [element.id]);
|
||||
changes = [{ kind: 'field', target: command.target, field: 'ownerCanvasId', before: element.ownerCanvasId, after: command.canvasId }];
|
||||
if (command.geometry && element.structure === 'node') changes = [...changes, { kind: 'field', target: command.target, field: 'geometry', before: element.geometry, after: command.geometry }];
|
||||
for (const canvas of document.canvases.values()) if (canvas.parent?.entryElementId === element.id) changes = [...changes, { kind: 'canvas', id: canvas.id, before: canvas, after: { ...canvas, parent: { ...canvas.parent, canvasId: command.canvasId } } }];
|
||||
const moved = applyChanges(document, changes);
|
||||
for (const edge of moved.elements.values()) if (edge.structure === 'connection') {
|
||||
const source = moved.elements.get(edge.source.elementId), target = moved.elements.get(edge.target.elementId);
|
||||
if (!source || !target) throw fault('invalid_endpoint', [edge.id]);
|
||||
const owner = commonCanvas(moved, source.ownerCanvasId, target.ownerCanvasId);
|
||||
if (owner !== edge.ownerCanvasId) changes = [...changes, { kind: 'field', target: { id: edge.id, incarnation: edge.incarnation }, field: 'ownerCanvasId', before: edge.ownerCanvasId, after: owner }];
|
||||
if(element.structure==='node'){
|
||||
if([...document.canvases.values()].some(canvas=>canvas.parent?.entryElementId===element.id)&&canvasTransform(document,element.ownerCanvasId).scale!==canvasTransform(document,command.canvasId).scale)throw fault('container_scale_conversion_required',[element.id]);
|
||||
const geometry=command.geometry??(element.ownerCanvasId===command.canvasId?element.geometry:convertGeometry(element.geometry,canvasTransform(document,element.ownerCanvasId),canvasTransform(document,command.canvasId)));
|
||||
if(!equal(geometry,element.geometry))changes=[...changes,{kind:'field',target:command.target,field:'geometry',before:element.geometry,after:geometry}];
|
||||
}
|
||||
for (const canvas of document.canvases.values()) if (canvas.parent?.entryElementId === element.id) changes = [...changes, { kind: 'canvas', id: canvas.id, before: canvas, after: { ...canvas, parent: { ...canvas.parent, canvasId: command.canvasId } } }];
|
||||
changes=[...changes,...repairConnectionOwners(document,applyChanges(document,changes))];
|
||||
} else {
|
||||
const output: Change[] = [];
|
||||
const run = (capability: Capability, patch: unknown) => {
|
||||
@@ -232,19 +238,26 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
const entry = state.history.entries.get(id)!;
|
||||
const allowed = options.traversalPolicy?.(entry, direction);
|
||||
if (allowed && !allowed.enabled) throw fault(allowed.code, allowed.targets);
|
||||
let changes = direction === 'undo' ? invert(entry.changes) : entry.changes;
|
||||
const generations = new Map<Id, number>();
|
||||
changes = changes.map(change => {
|
||||
if (change.kind === 'element' && !change.before && change.after) {
|
||||
const dead = state.document.tombstones.get(change.id);
|
||||
if (dead !== undefined && change.after.incarnation <= dead) { generations.set(change.id, dead + 1); return freeze({ ...change, after: { ...change.after, incarnation: dead + 1 } }); }
|
||||
}
|
||||
const planned=direction==='undo'?invert(entry.changes):entry.changes;
|
||||
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;};
|
||||
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;
|
||||
}).map(change => change.kind === 'field' && generations.has(change.target.id) ? freeze({ ...change, target: { ...change.target, incarnation: generations.get(change.target.id)! } }) : change);
|
||||
const document = applyChanges(state.document, changes);
|
||||
validateDocument(document, options.registry);
|
||||
const entries = new Map(state.history.entries);
|
||||
entries.set(id, freeze({ ...entry, changes: direction === 'undo' ? invert(changes) : changes }));
|
||||
};
|
||||
let document=state.document;const changes:Change[]=[];
|
||||
for(const original of planned){
|
||||
let change=remap(original);
|
||||
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);
|
||||
}
|
||||
validateDocument(document,options.registry);
|
||||
const entries=new Map(state.history.entries);
|
||||
// Later redo entries belong to the lifecycle restored by this explicit history action.
|
||||
// Frozen operations retain their original identities and incarnations.
|
||||
if(generations.size)for(const [otherId,other]of entries)if(otherId!==id)entries.set(otherId,freeze({...other,changes:other.changes.map(remap)}));
|
||||
entries.set(id,freeze({...entry,changes:direction==='undo'?invert(changes):changes}));
|
||||
const history: HistoryState = Object.freeze({ undo: Object.freeze(direction === 'undo' ? state.history.undo.slice(0, -1) : [...state.history.undo, id]), redo: Object.freeze(direction === 'redo' ? state.history.redo.slice(0, -1) : [...state.history.redo, id]), entries: readMap(entries) });
|
||||
return publish(document, changes, direction, history, id);
|
||||
} catch (error) { return reject(error); }
|
||||
|
||||
@@ -5,3 +5,5 @@ export * from './registry.ts';
|
||||
export * from './features.ts';
|
||||
export * from './editor.ts';
|
||||
export * from './gestures.ts';
|
||||
export * from './coordinates.ts';
|
||||
export * from './structure.ts';
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Change, Document, Id, Target } from './types.ts';
|
||||
import { commonCanvas, targetElement } from './document.ts';
|
||||
import { canvasTransform, convertPath } from './coordinates.ts';
|
||||
import { equal, freeze } from './values.ts';
|
||||
|
||||
/** Own the subtree explicitly; geometry/visibility never selects which descendants are deleted. */
|
||||
export function removalChanges(document:Document,target:Target):readonly Change[] {
|
||||
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([
|
||||
...[...document.elements.values()].filter(record=>elements.has(record.id)).map(record=>({kind:'element' as const,id:record.id,before:record,after:null})),
|
||||
...[...document.canvases.values()].filter(canvas=>canvases.has(canvas.id)).map(canvas=>({kind:'canvas' as const,id:canvas.id,before:canvas,after:null})),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Ownership changes and their curve coordinates are one operation, with the same stable edge identity. */
|
||||
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;
|
||||
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});
|
||||
}
|
||||
return freeze(changes);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export type Change = FieldChange
|
||||
| { readonly kind: 'canvas'; readonly id: Id; readonly before: CanvasRecord | null; readonly after: CanvasRecord | null };
|
||||
export type Command =
|
||||
| { readonly type: 'element.create'; readonly element: ElementRecord }
|
||||
| { readonly type:'connection.endpoints'; readonly target:Target; readonly source?:Endpoint; readonly destination?:Endpoint }
|
||||
| { readonly type: 'connection.path'; readonly target: Target; readonly path: Json }
|
||||
| { readonly type: 'element.remove'; readonly target: Target }
|
||||
| { readonly type: 'element.data'; readonly target: Target; readonly patch: Data }
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { Change, Document, ElementRecord, FieldChange, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts';
|
||||
import { applyChanges, commonCanvas, validateDocument } from '../canvas-core/document.ts';
|
||||
import { canvasTransform, convertGeometry, convertPath, type Transform } from '../canvas-core/coordinates.ts';
|
||||
import { removalChanges, repairConnectionOwners } from '../canvas-core/structure.ts';
|
||||
import { equal, fault, freeze, readMap } from '../canvas-core/values.ts';
|
||||
|
||||
export type IntentContext = { readonly before:ReadonlyMap<Id,ElementRecord>; readonly after:ReadonlyMap<Id,ElementRecord>; readonly transforms:ReadonlyMap<Id,Transform> };
|
||||
/** Reconstruct only immutable intention metadata. This mutable cursor is never published as a document. */
|
||||
export function intentContexts(document:Document,intents:readonly OperationIntent[]):ReadonlyMap<Id,IntentContext> {
|
||||
const elements=new Map(document.elements),canvases=new Map(document.canvases),contexts=new Map<Id,IntentContext>();
|
||||
const cursor={...document,elements,canvases};
|
||||
for(const intent of [...intents].reverse()){
|
||||
const ids=new Set<Id>(),owners=new Set<Id>();
|
||||
for(const change of intent.changes){
|
||||
if(change.kind==='field'){ids.add(change.target.id);if(change.field==='source'||change.field==='target')for(const endpoint of [change.before,change.after])if(endpoint&&typeof endpoint==='object'&&!Array.isArray(endpoint)&&typeof endpoint.elementId==='string')ids.add(endpoint.elementId);}
|
||||
else if(change.kind==='element'){ids.add(change.id);for(const record of [change.before,change.after])if(record?.structure==='connection'){ids.add(record.source.elementId);ids.add(record.target.elementId);}}
|
||||
else for(const record of [change.before,change.after])if(record?.parent)ids.add(record.parent.entryElementId);
|
||||
}
|
||||
const after=new Map<Id,ElementRecord>();for(const id of ids){const record=elements.get(id);if(record){after.set(id,record);owners.add(record.ownerCanvasId);}}
|
||||
const transforms=new Map<Id,Transform>();for(const owner of owners)if(canvases.has(owner))transforms.set(owner,canvasTransform(cursor,owner));
|
||||
for(const change of [...intent.changes].reverse()){
|
||||
if(change.kind==='element'){if(change.before)elements.set(change.id,change.before);else elements.delete(change.id);}
|
||||
else if(change.kind==='canvas'){if(change.before)canvases.set(change.id,change.before);else canvases.delete(change.id);}
|
||||
else {const current=elements.get(change.target.id);if(!current||current.incarnation!==change.target.incarnation)continue;
|
||||
if(change.field.startsWith('data.')){const key=change.field.slice(5),data={...current.data};if(change.beforePresent===false)delete data[key];else data[key]=change.before;elements.set(current.id,freeze({...current,data}));}
|
||||
else elements.set(current.id,freeze({...current,[change.field]:change.before}) as ElementRecord);
|
||||
}
|
||||
}
|
||||
contexts.set(intent.id,Object.freeze({before:readMap(new Map([...ids].flatMap(id=>elements.has(id)?[[id,elements.get(id)!] as const]:[]))),after:readMap(after),transforms:readMap(transforms)}));
|
||||
}
|
||||
return readMap(contexts);
|
||||
}
|
||||
|
||||
/** Re-plan against current data. Whole records are used only for explicit creation/removal. */
|
||||
export function rebaseIntent(document:Document,intent:OperationIntent,context:IntentContext,registry:Registry):{document:Document;changes:readonly Change[]} {
|
||||
const before=document,changes:Change[]=[],moved=new Set<Id>(),deferredPaths:FieldChange[]=[];
|
||||
for(const change of intent.changes)if(change.kind==='field'&&change.field==='ownerCanvasId'&&context.after.get(change.target.id)?.structure==='node')moved.add(change.target.id);
|
||||
function apply(change:Change){document=applyChanges(document,[change]);changes.push(freeze(change));}
|
||||
function target(change:FieldChange){const current=document.elements.get(change.target.id),prior=context.before.get(change.target.id)??context.after.get(change.target.id);if(!current||current.incarnation!==change.target.incarnation)throw fault('stale_incarnation',[change.target.id]);if(prior&&(current.componentId!==prior.componentId||current.dataVersion!==prior.dataVersion||current.structure!==prior.structure))throw fault('recovery_target_type_changed',[current.id]);if(registry.resolve(current)!=='known')throw fault('unsupported_component',[current.id]);return current;}
|
||||
function endpoint(id:Id){const current=document.elements.get(id),expected=context.after.get(id);if(!current||current.structure!=='node'||!expected||current.incarnation!==expected.incarnation)throw fault('stale_endpoint',[id]);}
|
||||
for(const original of intent.changes){
|
||||
if(original.kind==='element'){
|
||||
const current=document.elements.get(original.id);
|
||||
if(original.before===null&&original.after){
|
||||
if(current||document.tombstones.has(original.id))throw fault('recovery_identity_collision',[original.id]);
|
||||
if(registry.resolve(original.after)!=='known')throw fault('unsupported_component',[original.id]);
|
||||
let after=original.after;
|
||||
if(after.structure==='node'){const basis=context.transforms.get(after.ownerCanvasId);if(!basis)throw fault('recovery_coordinate_context_missing',[after.id]);after={...after,geometry:convertGeometry(after.geometry,basis,canvasTransform(document,after.ownerCanvasId))};}
|
||||
if(after.structure==='connection'){
|
||||
endpoint(after.source.elementId);endpoint(after.target.elementId);const owner=commonCanvas(document,document.elements.get(after.source.elementId)!.ownerCanvasId,document.elements.get(after.target.elementId)!.ownerCanvasId);
|
||||
const basis=context.transforms.get(after.ownerCanvasId);if(!basis)throw fault('recovery_coordinate_context_missing',[after.id]);
|
||||
after={...after,ownerCanvasId:owner,path:convertPath(after.path,basis,canvasTransform(document,owner))};
|
||||
}
|
||||
apply({...original,after});
|
||||
}else if(original.before&&original.after===null){
|
||||
if(!current)continue;
|
||||
if(current.incarnation!==original.before.incarnation)throw fault('stale_incarnation',[original.id]);
|
||||
if(registry.resolve(current)!=='known')throw fault('unsupported_component',[original.id]);
|
||||
for(const change of removalChanges(document,{id:current.id,incarnation:current.incarnation}))apply(change);
|
||||
}else throw fault('recovery_record_replacement_not_supported',[original.id]);
|
||||
continue;
|
||||
}
|
||||
if(original.kind==='canvas'){
|
||||
if(original.before?.parent&&original.after?.parent&&original.before.parent.entryElementId===original.after.parent.entryElementId&&moved.has(original.after.parent.entryElementId))continue;
|
||||
const current=document.canvases.get(original.id);
|
||||
if(!original.before&&original.after){if(current)throw fault('recovery_identity_collision',[original.id]);if(original.after.parent)endpoint(original.after.parent.entryElementId);apply(original);}
|
||||
else if(original.before&&!original.after){if(current){if(current.parent===null)throw fault('missing_root');for(const record of document.elements.values())if(record.ownerCanvasId===current.id)throw fault('recovery_canvas_not_empty',[current.id]);apply({...original,before:current});}}
|
||||
else if(original.after&¤t){if(original.after.parent)endpoint(original.after.parent.entryElementId);const after={...current,parent:original.after.parent?{...current.parent,canvasId:original.after.parent.canvasId,entryElementId:original.after.parent.entryElementId}:null};if(!equal(current,after))apply({...original,before:current,after});}
|
||||
else throw fault('canvas_not_loaded',[original.id]);
|
||||
continue;
|
||||
}
|
||||
let current=target(original);const prior=context.before.get(current.id),afterContext=context.after.get(current.id);
|
||||
if(original.field==='ownerCanvasId'){
|
||||
if(current.structure==='connection')continue; // Recomputed from the final endpoints and tree below.
|
||||
if(!registry.supports(current,'geometry.move')||typeof original.after!=='string'||!document.canvases.has(original.after))throw fault('unsupported_capability',[current.id]);
|
||||
const basis=afterContext?context.transforms.get(afterContext.ownerCanvasId):undefined;if(!afterContext||afterContext.structure!=='node'||!basis)throw fault('recovery_coordinate_context_missing',[current.id]);
|
||||
if([...document.canvases.values()].some(canvas=>canvas.parent?.entryElementId===current.id)&&canvasTransform(document,current.ownerCanvasId).scale!==canvasTransform(document,original.after).scale)throw fault('container_scale_conversion_required',[current.id]);
|
||||
const geometry=convertGeometry(afterContext.geometry,basis,canvasTransform(document,original.after));
|
||||
if(current.ownerCanvasId!==original.after)apply({...original,before:current.ownerCanvasId});
|
||||
for(const canvas of document.canvases.values())if(canvas.parent?.entryElementId===current.id&&canvas.parent.canvasId!==original.after)apply({kind:'canvas',id:canvas.id,before:canvas,after:{...canvas,parent:{...canvas.parent,canvasId:original.after}}});
|
||||
current=document.elements.get(current.id)!;if(current.structure==='node'&&!equal(current.geometry,geometry))apply({kind:'field',target:original.target,field:'geometry',before:current.geometry,after:geometry});
|
||||
continue;
|
||||
}
|
||||
if(original.field==='geometry'&&moved.has(current.id))continue;
|
||||
if(original.field==='source'||original.field==='target'){
|
||||
if(current.structure!=='connection'||!registry.supports(current,'connection.edit'))throw fault('unsupported_capability',[current.id]);
|
||||
const next=original.after as unknown as {elementId:Id};endpoint(next.elementId);
|
||||
if(!equal(current[original.field],original.after))apply({...original,before:current[original.field]});continue;
|
||||
}
|
||||
if(original.field==='path'){
|
||||
const related=intent.changes.some(change=>change.kind==='field'&&change.target.id===current.id&&['ownerCanvasId','source','target'].includes(change.field));
|
||||
if(related){if(!registry.supports(current,'connection.edit'))throw fault('unsupported_capability',[current.id]);deferredPaths.push(original);continue;}
|
||||
}
|
||||
if((original.field==='geometry'||original.field==='path')&&prior&&prior.ownerCanvasId!==current.ownerCanvasId)throw fault('ownership_conflict',[current.id]);
|
||||
if(original.field.startsWith('data.')&&!registry.supports(current,'content.edit')||original.field==='path'&&!registry.supports(current,'connection.edit'))throw fault('unsupported_capability',[current.id]);
|
||||
const key=original.field.startsWith('data.')?original.field.slice(5):undefined,actual=key===undefined?(current as unknown as Record<string,Json>)[original.field]:current.data[key]??null,present=key===undefined?undefined:Object.hasOwn(current.data,key);
|
||||
let desired=original.after;
|
||||
if(original.field==='geometry'&¤t.structure==='node'){
|
||||
const start=original.before as unknown as typeof current.geometry,end=original.after as unknown as typeof current.geometry;
|
||||
desired={...current.geometry,...Object.fromEntries((['x','y','width','height','rotation'] as const).filter(key=>start[key]!==end[key]).map(key=>[key,end[key]]))};
|
||||
const next=desired as unknown as typeof current.geometry;for(const [keys,capability]of [[['x','y'],'geometry.move'],[['width','height'],'geometry.resize'],[['rotation'],'geometry.rotate']] as const)if(keys.some(key=>current.geometry[key]!==next[key])&&!registry.supports(current,capability))throw fault('unsupported_capability',[current.id]);
|
||||
}
|
||||
if(!equal(actual,desired)||(present??true)!==(original.afterPresent??true))apply({...original,before:actual,beforePresent:present,after:desired});
|
||||
}
|
||||
for(const original of deferredPaths){
|
||||
const edge=target(original),intended=context.after.get(edge.id);if(edge.structure!=='connection'||intended?.structure!=='connection')throw fault('invalid_endpoint',[edge.id]);
|
||||
const owner=commonCanvas(document,document.elements.get(edge.source.elementId)!.ownerCanvasId,document.elements.get(edge.target.elementId)!.ownerCanvasId),basis=context.transforms.get(intended.ownerCanvasId);if(!basis)throw fault('recovery_coordinate_context_missing',[edge.id]);
|
||||
const path=convertPath(original.after,basis,canvasTransform(document,owner));
|
||||
if(edge.ownerCanvasId!==owner)apply({kind:'field',target:original.target,field:'ownerCanvasId',before:edge.ownerCanvasId,after:owner});
|
||||
if(!equal(edge.path,path))apply({...original,before:edge.path,after:path});
|
||||
}
|
||||
for(const change of repairConnectionOwners(before,document))apply(change);
|
||||
validateDocument(document,registry);return{document,changes:freeze(changes)};
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Change, Document, FieldChange, HistoryState, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts';
|
||||
import { applyChanges, validateDocument } from '../canvas-core/document.ts';
|
||||
import { equal, fault, freeze, readMap } from '../canvas-core/values.ts';
|
||||
import type { CanonicalProposal, EngineSnapshot } from './contracts.ts';
|
||||
import { createLedger } from './ledger.ts';
|
||||
import { createLedger, type Presence } from './ledger.ts';
|
||||
import { intentContexts, rebaseIntent } from './change-rebase.ts';
|
||||
import { operationCanvases, validateCanonicalRead } from './reconcile.ts';
|
||||
import { referencedResources } from './resources.ts';
|
||||
|
||||
@@ -30,38 +30,21 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
|
||||
const used=new Set([...snapshot.operations.keys(),...snapshot.runtime.ledger.operations.keys(),...snapshot.runtime.ledger.requests.keys(),...snapshot.history.entries.keys(),...(snapshot.runtime.recoveries?.keys()??[])]);
|
||||
function unique(){const id=createId();if(!id||used.has(id))throw fault('duplicate_operation_id');used.add(id);return id;}
|
||||
const id=unique(),ledger=createLedger(snapshot.runtime.ledger),operations=new Map(snapshot.operations),decisions:RecoveryDecision[]=[],replacements=new Map<Id,OperationIntent|undefined>();
|
||||
const starts=new Map<string,ReturnType<typeof value>>(),historyOwner=new Map<Id,Id>();
|
||||
const starts=new Map<string,ReturnType<typeof value>>(),historyOwner=new Map<Id,Id>(),contexts=intentContexts(snapshot.document,records.map(record=>record.intent));
|
||||
for(const entry of snapshot.history.entries.values())for(const operationId of entry.operationIds){if(historyOwner.has(operationId))throw fault('recovery_history_ambiguous');historyOwner.set(operationId,entry.id);}
|
||||
let document=canonical.document,sequence=0;for(const record of snapshot.runtime.ledger.operations.values())sequence=Math.max(sequence,record.intent.sequence);
|
||||
for(const record of records){
|
||||
const intent=record.intent,choice=choices.get(intent.id)!;
|
||||
if(intent.operationSchemaVersion!==1)throw fault('operation_interpreter_unavailable',[intent.id]);
|
||||
if(intent.changes.some(change=>change.kind!=='field'))throw fault('recovery_structure_not_supported',[intent.id]);
|
||||
if(intent.changes.length&&intent.origin!=='user')throw fault('recovery_history_traversal_not_supported',[intent.id]);
|
||||
const changes:FieldChange[]=[],before=document;
|
||||
for(const original of intent.changes as readonly FieldChange[]){
|
||||
const before=document;
|
||||
for(const original of intent.changes)if(original.kind==='field'){
|
||||
const owner=historyOwner.get(intent.id),key=owner?JSON.stringify([owner,fieldKey(original)]):undefined;
|
||||
if(key&&!starts.has(key))starts.set(key,value(document,original,choice==='discard-local'));
|
||||
if(choice==='discard-local')continue;
|
||||
const current=document.elements.get(original.target.id),prior=snapshot.runtime.acknowledged.elements.get(original.target.id);
|
||||
if(!current||current.incarnation!==original.target.incarnation)throw fault('stale_incarnation',[original.target.id]);
|
||||
if(prior&&(prior.componentId!==current.componentId||prior.dataVersion!==current.dataVersion||prior.structure!==current.structure))throw fault('recovery_target_type_changed',[current.id]);
|
||||
if(registry.resolve(current)!=='known')throw fault('unsupported_component',[current.id]);
|
||||
if(original.field.startsWith('data.')&&!registry.supports(current,'content.edit')||original.field==='path'&&!registry.supports(current,'connection.edit'))throw fault('unsupported_capability',[current.id]);
|
||||
if((original.field==='geometry'||original.field==='path')&&prior?.ownerCanvasId!==current.ownerCanvasId)throw fault('ownership_conflict',[current.id]);
|
||||
if(original.field==='ownerCanvasId'||original.field==='source'||original.field==='target')throw fault('recovery_structure_not_supported',[intent.id]);
|
||||
const actual=value(document,original);
|
||||
let desired=original.after;
|
||||
if(original.field==='geometry'&¤t.structure==='node'){
|
||||
const before=original.before as unknown as typeof current.geometry,after=original.after as unknown as typeof current.geometry;
|
||||
desired={...current.geometry,...Object.fromEntries((['x','y','width','height','rotation'] as const).filter(key=>before[key]!==after[key]).map(key=>[key,after[key]]))};
|
||||
}
|
||||
const change=freeze({...original,...actual,after:desired});
|
||||
if(change.field==='geometry'&¤t.structure==='node'){const next=change.after as unknown as typeof current.geometry;for(const [keys,capability] of [[['x','y'],'geometry.move'],[['width','height'],'geometry.resize'],[['rotation'],'geometry.rotate']] as const)if(keys.some(key=>current.geometry[key]!==next[key])&&!registry.supports(current,capability))throw fault('unsupported_capability',[current.id]);}
|
||||
if(equal(actual.before,change.after)&&(actual.beforePresent??true)===(change.afterPresent??true))continue;
|
||||
document=applyChanges(document,[change]);changes.push(change);
|
||||
if(key&&!starts.has(key))starts.set(key,value(document,original,true));
|
||||
}
|
||||
validateDocument(document,registry);
|
||||
if(choice==='keep-local')for(const change of intent.changes)if(change.kind==='canvas'&&!change.before&&canonical.versions.has(change.id))throw fault('recovery_identity_collision',[change.id]);
|
||||
const rebased=choice==='keep-local'?rebaseIntent(document,intent,contexts.get(intent.id)!,registry):{document,changes:[]};
|
||||
const changes=rebased.changes;document=rebased.document;
|
||||
let replacement:OperationIntent|undefined;
|
||||
if(choice==='keep-local'){
|
||||
if(intent.operationType!=='document.change'&&intent.operationType!=='run.apply')throw fault('recovery_action_requires_new_command',[intent.id]);
|
||||
@@ -71,7 +54,7 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
|
||||
const dependencySet=new Set(dependencyIds);
|
||||
for(const previous of replacements.values())if(previous)dependencySet.add(previous.id);
|
||||
const replacementId=unique();
|
||||
const basic={...intent,id:replacementId,sequence:++sequence,changes:freeze(changes),dependencies:[...dependencySet],canvasIds:operationCanvases({...intent,canvasIds:[],changes},before)};
|
||||
const basic={...intent,id:replacementId,sequence:++sequence,changes:freeze(changes),dependencies:[...dependencySet],canvasIds:operationCanvases({...intent,canvasIds:[],changes},before,document)};
|
||||
replacement=intent.operationType==='run.apply'?freeze({...basic,operationType:'run.apply',action:{...intent.action,targetCanvasId:document.elements.get(intent.action.target.id)!.ownerCanvasId}}) as OperationIntent:freeze(basic);
|
||||
operations.set(replacement.id,replacement);
|
||||
}
|
||||
@@ -82,12 +65,13 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
|
||||
const history=rewriteHistory(snapshot,records.map(record=>record.intent),replacements,starts);
|
||||
ledger.retire(id,decisions);
|
||||
for(const replacement of replacements.values())if(replacement){
|
||||
const presence:Record<Id,'exists'>={};for(const change of replacement.changes)if(change.kind==='field')presence[change.target.id]='exists';
|
||||
const presence:Record<Id,Presence>={};for(const change of replacement.changes)if(change.kind==='field')presence[change.target.id]??='exists';else if(change.kind==='element')presence[change.id]??=change.before?'exists':'absent';
|
||||
ledger.add(replacement,presence);
|
||||
}
|
||||
// Conservative pins include the archived document/history and unresolved opaque local resources.
|
||||
const resourceIds=new Set(snapshot.runtime.resources.keys());
|
||||
for(const record of records)for(const resource of referencedResources(record.intent.changes,registry))resourceIds.add(resource);
|
||||
for(const replacement of replacements.values())if(replacement)for(const resource of referencedResources(replacement.changes,registry))resourceIds.add(resource);
|
||||
const backup=JSON.parse(JSON.stringify({document:documentValue(snapshot.document),acknowledged:documentValue(snapshot.runtime.acknowledged),versions:[...snapshot.runtime.versions],resourceIds:[...resourceIds],history:{undo:snapshot.history.undo,redo:snapshot.history.redo,entries:[...snapshot.history.entries]}})) as Json;
|
||||
const audit=freeze({id,formatVersion:1 as const,baseRootRevision:snapshot.rootRevision,decisions,backup});
|
||||
return Object.freeze({id,baseRootRevision:snapshot.rootRevision,document,history,operations:readMap(operations),ledger:ledger.getSnapshot(),audit,resourceIds:Object.freeze([...resourceIds]),operationIds:Object.freeze([...replacements.values()].flatMap(intent=>intent?[intent.id]:[]))});
|
||||
@@ -98,6 +82,12 @@ function rewriteHistory(snapshot:EngineSnapshot,pending:readonly OperationIntent
|
||||
for(const entry of snapshot.history.entries.values()){
|
||||
const intents=pending.filter(intent=>entry.operationIds.includes(intent.id));if(!intents.length)continue;
|
||||
if(!snapshot.history.undo.includes(entry.id)||entry.operationIds.some(id=>snapshot.operations.get(id)?.origin!=='user'))throw fault('recovery_history_traversal_not_supported',[entry.id]);
|
||||
if(entry.operationIds.every(id=>affected.has(id))){
|
||||
const changes=intents.flatMap(intent=>replacements.get(intent.id)?.changes??[]),operationIds=intents.flatMap(intent=>replacements.get(intent.id)?[replacements.get(intent.id)!.id]:[]);
|
||||
if(changes.length)entries.set(entry.id,freeze({...entry,changes,operationIds}));else entries.delete(entry.id);
|
||||
continue;
|
||||
}
|
||||
if(intents.some(intent=>intent.changes.some(change=>change.kind!=='field'||['ownerCanvasId','source','target'].includes(change.field))))throw fault('recovery_history_structure_conflict',[entry.id]);
|
||||
const keys=new Map<string,FieldChange>();for(const intent of intents)for(const change of intent.changes as readonly FieldChange[])keys.set(fieldKey(change),change);
|
||||
const fields:FieldChange[]=[];
|
||||
for(const [key,example]of keys){
|
||||
|
||||
@@ -24,7 +24,7 @@ export function validateCanonicalRead(previous: Document, previousVersions: Read
|
||||
}
|
||||
|
||||
/** The footprint follows ownership; geometry does not decide which canvas owns an object. */
|
||||
export function operationCanvases(intent: OperationIntent, document: Document): readonly Id[] {
|
||||
export function operationCanvases(intent: OperationIntent, document: Document, after:Document=document): readonly Id[] {
|
||||
if(intent.operationType==='asset.upload'||intent.operationType==='run.cancel')return[];
|
||||
if (intent.canvasIds?.length) return intent.canvasIds;
|
||||
const ids = new Set<Id>();
|
||||
@@ -36,9 +36,12 @@ export function operationCanvases(intent: OperationIntent, document: Document):
|
||||
} else if (change.kind === 'element') {
|
||||
if (change.before) ids.add(change.before.ownerCanvasId);
|
||||
if (change.after) ids.add(change.after.ownerCanvasId);
|
||||
for(const record of [change.before,change.after])if(record?.structure==='connection')for(const endpoint of [record.source,record.target]){const owner=after.elements.get(endpoint.elementId)?.ownerCanvasId??document.elements.get(endpoint.elementId)?.ownerCanvasId;if(owner)ids.add(owner);}
|
||||
} else {
|
||||
const owner = document.elements.get(change.target.id)?.ownerCanvasId;
|
||||
if (owner) ids.add(owner);
|
||||
const nextOwner=after.elements.get(change.target.id)?.ownerCanvasId;if(nextOwner)ids.add(nextOwner);
|
||||
if(change.field==='source'||change.field==='target')for(const endpoint of [change.before,change.after])if(endpoint&&typeof endpoint==='object'&&!Array.isArray(endpoint)&&typeof endpoint.elementId==='string'){for(const model of [document,after]){const owner=model.elements.get(endpoint.elementId)?.ownerCanvasId;if(owner)ids.add(owner);}}
|
||||
if (change.field === 'ownerCanvasId') { ids.add(change.before as Id); ids.add(change.after as Id); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,17 @@ import { createFoundryRuns } from '../../src/lib/canvas-service/runs.ts';
|
||||
import { createFoundryAssets } from '../../src/lib/canvas-service/assets.ts';
|
||||
import { createFoundryPersistence } from '../../src/lib/canvas-service/foundry.ts';
|
||||
|
||||
async function structureRecoveryCase(name,assert){
|
||||
const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents());
|
||||
const node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:0,y:0,width:300,height:200,rotation:0},data:{title:id,text:''}}),original=createDocument(root,[{id:root,parent:null}],[node('a')]),storage=createBrowserStorage({name:`${name}-structure`}),calls=[];let document=original,versions=new Map([[root,1]]),reject=true,engine;
|
||||
const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:participants.filter(id=>!document.canvases.has(id)),canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))});
|
||||
const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);if(reject){reject=false;versions.set(root,2);return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};}for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'structural request version mismatch');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};};
|
||||
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
|
||||
try{engine=await openProjectEngine(options);const edit=engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:{...node('frame'),componentId:'frame'}});tx.dispatch({type:'canvas.create',canvas:{id:child,parent:{canvasId:root,entryElementId:'frame'}}});tx.dispatch({type:'element.create',element:node('member',child)});});await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()][0],session=await engine.prepareRecovery(),plan=session.plan(new Map([[edit.operationIds[0],'keep-local']]));await session.resolve(plan);await engine.settle();await engine.close();
|
||||
engine=await openProjectEngine(options);assert(engine.getSnapshot().document.elements.get('member').ownerCanvasId===child,'structural replan did not recover its owner tree');assert(engine.getSnapshot().runtime.ledger.requests.get(old.id).body===old.body,'old structural request was changed');assert(engine.editor.history.undo().status==='applied','structural undo failed');await engine.settle();assert(!document.canvases.has(child),'undo left an orphan canvas');assert(engine.editor.history.redo().status==='applied','structural redo failed');await engine.settle();assert(document.elements.get('member').incarnation===2,'redo reused a deleted lifecycle');await engine.close();engine=await openProjectEngine(options);assert(engine.getSnapshot().document.elements.get('member').incarnation===2,'restored tree/history lifecycle did not survive reopening');
|
||||
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-structure`);}
|
||||
}
|
||||
|
||||
async function appliedReceiptRecoveryCase(name,assert){
|
||||
const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),target={id:'a',incarnation:1};
|
||||
const element={...target,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:root,geometry:{x:0,y:0,width:300,height:200,rotation:0},data:{title:'a',text:'original'}};
|
||||
@@ -283,6 +294,8 @@ export async function run() {
|
||||
results.push('conflict replan persists its audit/history/new identity; a lost replacement ACK recovers by the exact key and undo restores the reviewed server value');
|
||||
await appliedReceiptRecoveryCase(name,assert);
|
||||
results.push('a persisted successful receipt with failed coordination adopts a fresh base and replan in one cut, releases its writer and never repeats the original write');
|
||||
await structureRecoveryCase(name,assert);
|
||||
results.push('structural replan restores an atomic partition/member tree through IndexedDB, undo/archive and redo/new incarnation without changing the rejected request');
|
||||
return results;
|
||||
} finally { await leader?.release(); other?.close(); storage.close(); indexedDB.deleteDatabase(name); }
|
||||
}
|
||||
|
||||
@@ -172,6 +172,10 @@ try {
|
||||
const recompiled=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).at(-1),recompiledBody=JSON.parse(recompiled.bytes.toString());assert.notEqual(recompiledBody.request_id,rejectedId);assert.notEqual(recompiledBody.operations[0].id,rejectedEdit.operations[0].id);assert.equal(recompiledBody.operations[0].changes[0].before,'Remote reviewed');
|
||||
await click('撤销');for(let i=0;i<100&&documents.get(root).elements.get('b').data.text!=='Remote reviewed';i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,'Remote reviewed');
|
||||
console.log('PASS actual recovery dialog reviews remote/local content, recompiles with new identities, saves before dispatch and preserves the reviewed undo baseline');
|
||||
rejectNextEdit=true;await click('添加文本卡片');await until(`!!document.querySelector('[aria-label="处理同步修改"]')`,'rejected creation did not expose recovery');const createdId=rejectedEdit.operations[0].changes.find(change=>change.kind==='element'&&change.before===null).id;
|
||||
await click('处理同步修改');await until(`!!document.querySelector('select[aria-label="处理第1项修改"]')&&document.querySelector('[role=dialog]').innerText.includes('创建元素')`,'creation review omitted the structural change');
|
||||
await evaluate(`(()=>{const select=document.querySelector('select[aria-label="处理第1项修改"]');select.value='keep-local';select.dispatchEvent(new Event('change',{bubbles:true}));})()`);await click('预览处理方案');await until(`document.querySelector('[role=dialog]').innerText.includes('关联结构变更')&&!document.querySelector('[aria-label="应用处理方案"]').disabled`,'structural plan did not expose related changes');await click('应用处理方案');await until(`!document.querySelector('[role=dialog]')`,'structural review did not finish');for(let i=0;i<100&&!documents.get(root).elements.has(createdId);i++)await pause(20);assert.ok(documents.get(root).elements.has(createdId));await click('撤销');for(let i=0;i<100&&documents.get(root).elements.has(createdId);i++)await pause(20);assert.equal(documents.get(root).elements.has(createdId),false);
|
||||
console.log('PASS actual structural recovery dialog reviews a rejected creation and related changes, applies it with a new identity and retains undo');
|
||||
const selectB=async()=>{await call('Input.dispatchMouseEvent',{type:'mousePressed',x:1050,y:650,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:1050,y:650,button:'left',buttons:0,clickCount:1});await pause(50);const point=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height-30}})()`);await call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',buttons:0,clickCount:1});await until(`!document.querySelector('[aria-label="AI 创作"]').disabled`,'AI tool did not accept the selected element');};
|
||||
await selectB();await click('AI 创作');await until(`!!document.querySelector('[aria-label="AI 模型"] option[value="fixture-text"]')`,'AI catalog did not load through the host');
|
||||
await evaluate(`(()=>{const input=document.querySelector('[aria-label="AI 指令"]');Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,'Summarize this content');input.dispatchEvent(new Event('input',{bubbles:true}));})()`);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createDocument,createRegistry,builtinComponents,applyChanges,validateDocument,createEditor,canvasTransform,toWorld,convertGeometry } from '../../src/lib/canvas-core/index.ts';
|
||||
import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts';
|
||||
import { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts';
|
||||
const registry=createRegistry(builtinComponents()),geometry={x:100,y:60,width:100,height:80,rotation:0};
|
||||
const node=(id,owner='root',extra={})=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,data:{title:id,text:''},geometry,...extra});
|
||||
const frame=node('f','root',{componentId:'frame',geometry:{x:100,y:50,width:500,height:400,rotation:30},data:{title:'Frame',text:'',presentation:{contentScale:1.5}}});
|
||||
const edge=(id,source,target,owner='root',path=null)=>({id,incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:owner,data:{label:'preserve'},source:{elementId:source},target:{elementId:target},path});
|
||||
const base=()=>createDocument('project',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'f'}}],[frame,node('a'),node('b'),node('c','child')]);
|
||||
const path={type:'bezier-chain',extension:{keep:true},knots:[{id:'k',mode:'corner',position:{x:200,y:100},in:{x:-10,y:0},out:{x:10,y:0},extra:'keep'}]};
|
||||
const target=id=>({id,incarnation:1});
|
||||
const pending=engine=>[...engine.getSnapshot().runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved'&&record.intent.operationType!=='asset.upload');
|
||||
const choices=engine=>new Map(pending(engine).map(record=>[record.intent.id,'keep-local']));
|
||||
function fixture(original=base()){
|
||||
let server=original,versions=new Map([...original.canvases.keys()].map(id=>[id,1])),image,owner;const calls=[];
|
||||
const f={reject:true,calls,storage:{async claim(scope){assert.equal(owner,undefined);return owner={scope,active:true,fence:1,async release(){owner=undefined;}};},async recover(){return image&&structuredClone(image);},async commit(leader,batch){assert.equal(leader,owner);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},async readFile(){},close(){}},port:{compile(intent,id,snapshot){return{payload:{intent,id},canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)??0]))};},async submit(request){calls.push(request);if(f.reject)return{outcome:'not-applied',error:'version_conflict'};const {intent}=JSON.parse(request.body);for(const[id,version]of Object.entries(request.expectedVersions))assert.equal(version,versions.get(id)??0);const after=applyChanges(server,intent.changes);validateDocument(after,registry);for(const change of intent.changes){const ids=change.kind==='canvas'?[change.id]:change.kind==='element'?[change.before?.ownerCanvasId,change.after?.ownerCanvasId]:[server.elements.get(change.target.id)?.ownerCanvasId,after.elements.get(change.target.id)?.ownerCanvasId];for(const id of ids)if(id)assert.ok(request.canvasIds.includes(id));}server=after;for(const id of request.canvasIds)versions.set(id,(versions.get(id)??0)+1);return{outcome:'applied',canonical:{id:request.id}};},async query(){throw Error('unexpected query');},canonical(){return{document:server,versions};},async read(){return{document:server,versions};}},remote(changes){server=applyChanges(server,changes);validateDocument(server,registry);for(const id of new Set([...versions.keys(),...server.canvases.keys()]))versions.set(id,(versions.get(id)??0)+1);},image:()=>structuredClone(image),server:()=>server,async open(){return openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document:original,versions:new Map([...original.canvases.keys()].map(id=>[id,1])),storage:f.storage,persistence:f.port});}};return f;
|
||||
}
|
||||
const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7,`${a} != ${b}`);
|
||||
|
||||
test('pending create and later edits recover with absent baselines, new keys and lifecycle-safe undo/redo',async()=>{
|
||||
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.create',element:node('new')});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('new'),patch:{title:'edited'}});await engine.settle();
|
||||
f.remote([{kind:'field',target:target('a'),field:'data.title',before:'a',after:'remote',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));
|
||||
assert.equal(plan.ledger.operations.get(plan.operationIds[0]).basePresence.new,'absent');f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('new').data.title,'edited');assert.equal(engine.getSnapshot().history.undo.length,2);
|
||||
for(let i=0;i<2;i++){assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();}assert.equal(f.server().elements.has('new'),false);
|
||||
for(let i=0;i<2;i++){assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();}assert.equal(f.server().elements.get('new').incarnation,2);assert.equal(f.server().elements.get('new').data.title,'edited');
|
||||
assert.equal(decodeRecovery(f.image(),registry).document.elements.get('new').incarnation,2);await engine.close();
|
||||
});
|
||||
|
||||
test('an atomic frame/canvas/member creation can be kept or discarded without orphan records',async()=>{
|
||||
for(const keep of [true,false]){
|
||||
const f=fixture(),engine=await f.open();engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('new-frame','root',{componentId:'frame'})});tx.dispatch({type:'canvas.create',canvas:{id:'new-canvas',parent:{canvasId:'root',entryElementId:'new-frame'}}});tx.dispatch({type:'element.create',element:node('inside','new-canvas')});});await engine.settle();
|
||||
const session=await engine.prepareRecovery(),selected=choices(engine);if(!keep)for(const id of selected.keys())selected.set(id,'discard-local');const plan=session.plan(selected);f.reject=false;await session.resolve(plan);await engine.settle();
|
||||
assert.equal(engine.getSnapshot().document.canvases.has('new-canvas'),keep);assert.equal(engine.getSnapshot().document.elements.has('inside'),keep);assert.equal(engine.getSnapshot().history.undo.length,keep?1:0);
|
||||
if(keep){assert.equal(f.calls.at(-1).expectedVersions['new-canvas'],0);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().canvases.has('new-canvas'),false);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('inside').incarnation,2);}
|
||||
await engine.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('deletion is re-planned from current content and includes a new remote incident edge in its undo unit',async()=>{
|
||||
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.remove',target:target('a')});await engine.settle();
|
||||
f.remote([{kind:'field',target:target('a'),field:'data.text',before:'',after:'remote content',beforePresent:true,afterPresent:true},{kind:'element',id:'new-edge',before:null,after:edge('new-edge','a','b')}]);
|
||||
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine)),replacement=plan.operations.get(plan.operationIds[0]);assert.ok(replacement.changes.some(change=>change.kind==='element'&&change.id==='new-edge'&&change.after===null));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.has('new-edge'),false);
|
||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.text,'remote content');assert.equal(f.server().elements.get('a').incarnation,2);assert.equal(f.server().elements.get('new-edge').source.elementId,'a');await engine.close();
|
||||
});
|
||||
|
||||
test('deleting a partition uses its current subtree and cross-scope edges; undo restores all lifecycle identities',async()=>{
|
||||
const original=base(),f=fixture(original),engine=await f.open();assert.equal(engine.editor.dispatch({type:'element.remove',target:target('f')}).status,'applied');await engine.settle();
|
||||
f.remote([{kind:'element',id:'remote-child',before:null,after:node('remote-child','child')},{kind:'element',id:'cross',before:null,after:edge('cross','a','remote-child')}]);
|
||||
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().canvases.has('child'),false);assert.equal(f.server().elements.has('cross'),false);assert.ok(f.calls.at(-1).canvasIds.includes('child'));
|
||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();validateDocument(f.server(),registry);assert.equal(f.server().elements.get('remote-child').incarnation,2);assert.equal(f.server().canvases.get('child').parent.entryElementId,'f');assert.equal(f.server().elements.get('cross').target.elementId,'remote-child');await engine.close();
|
||||
});
|
||||
|
||||
test('owner moves preserve the intended world position and relocate edge curves with the common canvas',async()=>{
|
||||
const original=createDocument('project',[...base().canvases.values()],[...base().elements.values(),edge('e','a','c','root',path)]),f=fixture(original),engine=await f.open();
|
||||
const world=toWorld(canvasTransform(original,'root'),{x:geometry.x+geometry.width/2,y:geometry.y+geometry.height/2});assert.equal(engine.editor.dispatch({type:'element.move',target:target('a'),canvasId:'child'}).status,'applied');await engine.settle();
|
||||
f.remote([{kind:'field',target:target('f'),field:'geometry',before:frame.geometry,after:{...frame.geometry,x:400,rotation:70}}]);
|
||||
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();const current=f.server(),a=current.elements.get('a'),actual=toWorld(canvasTransform(current,'child'),{x:a.geometry.x+a.geometry.width/2,y:a.geometry.y+a.geometry.height/2});near(actual.x,world.x);near(actual.y,world.y);
|
||||
const connection=current.elements.get('e');assert.equal(connection.ownerCanvasId,'child');const knot=toWorld(canvasTransform(current,'child'),connection.path.knots[0].position);near(knot.x,200);near(knot.y,100);assert.deepEqual(connection.path.extension,{keep:true});
|
||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').ownerCanvasId,'root');assert.equal(f.server().elements.get('f').geometry.x,400);assert.equal(f.server().elements.get('e').ownerCanvasId,'root');await engine.close();
|
||||
});
|
||||
|
||||
test('reconnection uses current endpoint scope, preserves unrelated data, and refuses a restored endpoint lifecycle',async()=>{
|
||||
const original=createDocument('project',[...base().canvases.values()],[...base().elements.values(),edge('e','a','b','root',path)]);
|
||||
for(const restored of [false,true]){
|
||||
const f=fixture(original),engine=await f.open();engine.editor.dispatch({type:'connection.endpoints',target:target('e'),source:{elementId:'c'}});await engine.settle();
|
||||
if(restored)f.remote([{kind:'element',id:'c',before:original.elements.get('c'),after:null},{kind:'element',id:'c',before:null,after:{...original.elements.get('c'),incarnation:2}}]);
|
||||
else f.remote([{kind:'field',target:target('b'),field:'ownerCanvasId',before:'root',after:'child'},{kind:'field',target:target('e'),field:'data.label',before:'preserve',after:'remote label',beforePresent:true,afterPresent:true}]);
|
||||
const session=await engine.prepareRecovery();if(restored){assert.throws(()=>session.plan(choices(engine)),/stale_endpoint/);await session.cancel();}else{const plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('e').ownerCanvasId,'child');assert.equal(f.server().elements.get('e').data.label,'remote label');assert.ok(f.calls.at(-1).canvasIds.includes('child'));assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('e').source.elementId,'a');}
|
||||
await engine.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('discarded creations cannot be used by kept dependents, and colliding/tombstoned identities never bind foreign content',async()=>{
|
||||
const f=fixture(),engine=await f.open();const create=engine.editor.dispatch({type:'element.create',element:node('new')});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('new'),patch:{title:'edited'}});await engine.settle();
|
||||
let session=await engine.prepareRecovery(),selected=choices(engine);selected.set(create.operationIds[0],'discard-local');assert.throws(()=>session.plan(selected),/stale_incarnation/);await session.cancel();
|
||||
f.remote([{kind:'element',id:'new',before:null,after:node('new')}]);session=await engine.prepareRecovery();assert.throws(()=>session.plan(choices(engine)),/recovery_identity_collision/);await session.cancel();await engine.close();
|
||||
});
|
||||
|
||||
test('core world/local geometry conversion and history redo preserve user-owned lifecycles',()=>{
|
||||
const document=base(),editor=createEditor({registry,document});editor.dispatch({type:'element.create',element:node('new')});editor.dispatch({type:'element.data',target:target('new'),patch:{title:'edited'}});
|
||||
editor.history.undo();editor.history.undo();assert.equal(editor.history.redo().status,'applied');assert.equal(editor.history.redo().status,'applied');assert.equal(editor.getSnapshot().document.elements.get('new').data.title,'edited');
|
||||
const from=canvasTransform(document,'root'),to=canvasTransform(document,'child'),converted=convertGeometry(geometry,from,to),round=convertGeometry(converted,to,from);for(const key of ['x','y','width','height','rotation'])near(round[key],geometry[key]);
|
||||
});
|
||||
|
||||
test('moving an entry follows its current child binding rather than stealing its former canvas',async()=>{
|
||||
const g=node('g','root',{componentId:'frame'}),h=node('h','root',{componentId:'frame'}),original=createDocument('project',[...base().canvases.values(),{id:'outer',parent:{canvasId:'root',entryElementId:'g'}}],[...base().elements.values(),g,h]),f=fixture(original),engine=await f.open();
|
||||
engine.editor.dispatch({type:'element.move',target:target('f'),canvasId:'outer'});await engine.settle();
|
||||
f.remote([{kind:'canvas',id:'child',before:original.canvases.get('child'),after:{id:'child',parent:{canvasId:'root',entryElementId:'h'}}},{kind:'canvas',id:'new-child',before:null,after:{id:'new-child',parent:{canvasId:'root',entryElementId:'f'}}}]);
|
||||
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().canvases.get('child').parent.entryElementId,'h');assert.equal(f.server().canvases.get('child').parent.canvasId,'root');assert.equal(f.server().canvases.get('new-child').parent.canvasId,'outer');await engine.close();
|
||||
});
|
||||
|
||||
test('unknown curve formats and container scale changes remain explicit, atomic errors',()=>{
|
||||
const g=node('g','root',{componentId:'frame',data:{title:'g',text:'',presentation:{contentScale:2}}}),document=createDocument('project',[...base().canvases.values(),{id:'outer',parent:{canvasId:'root',entryElementId:'g'}}],[...base().elements.values(),g,edge('e','a','c','root',{type:'future-path',keep:true})]),editor=createEditor({registry,document}),before=editor.getSnapshot();
|
||||
assert.equal(editor.dispatch({type:'element.move',target:target('a'),canvasId:'child'}).code,'unsupported_coordinate_path');assert.equal(editor.getSnapshot(),before);
|
||||
assert.equal(editor.dispatch({type:'element.move',target:target('f'),canvasId:'outer'}).code,'container_scale_conversion_required');assert.equal(editor.getSnapshot(),before);
|
||||
});
|
||||
|
||||
test('reparenting preserves fresh unknown parent metadata instead of restoring an older envelope',async()=>{
|
||||
const h={...frame,id:'h'},original=createDocument('project',[...base().canvases.values()],[...base().elements.values(),h]),f=fixture(original),engine=await f.open();
|
||||
engine.editor.dispatch({type:'canvas.reparent',canvasId:'child',parent:{canvasId:'root',entryElementId:'h'}});await engine.settle();f.remote([{kind:'canvas',id:'child',before:original.canvases.get('child'),after:{...original.canvases.get('child'),parent:{...original.canvases.get('child').parent,future:{keep:'remote'}}}}]);
|
||||
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().canvases.get('child').parent.entryElementId,'h');assert.deepEqual(f.server().canvases.get('child').parent.future,{keep:'remote'});await engine.close();
|
||||
});
|
||||
Reference in New Issue
Block a user