diff --git a/src/lib/canvas-core/coordinates.ts b/src/lib/canvas-core/coordinates.ts index e7e1909..d63f3da 100644 --- a/src/lib/canvas-core/coordinates.ts +++ b/src/lib/canvas-core/coordinates.ts @@ -2,6 +2,7 @@ * 嵌套 canvas 的坐标换算:Transform 把一个 canvas 的局部空间映射到项目世界空间。 * 统一采用平移、等比缩放和旋转,不处理 skew/非等比父变换。owner 改变时通过世界空间中转, * 保持节点外观和连线曲线位置;表现单位的 data 修正由结构/编辑规划器另行生成。 + * 数学辅助函数依赖已校验的有限几何/正比例;它们不逐项代替 schema、registry 或可见性校验。 */ import type { Document, Geometry, Id, Json, Point } from './types.ts'; import { ancestors } from './document.ts'; @@ -22,9 +23,10 @@ export function toWorld(transform: Transform, point: Point): Point { const value export function toLocal(transform: Transform, point: Point): Point { return rotate({ x: (point.x - transform.x) / transform.scale, y: (point.y - transform.y) / transform.scale }, -transform.rotation); } /** 将可识别的 JSON 对象视为字段表,空值/原始值/数组按空表处理,不修改源数据。 */ const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {}; -/** 对已识别的 bezier-chain 换空间;未知路径原样返回,严格写入边界由 convertPath 把关。 +/** type/knots 可识别时逐点换空间;其它路径原样返回,不在本函数完整验证曲线形状。 * knot.position 是位置,in/out 与首尾 control 是偏移向量,向量不能叠加平移。 */ export function transformPath(path: Json, transform: Transform, inverse = false): Json { + // 直接调用可对部分字段做转换;实际需要换空间时,convertPath 会先检查支持的路径格式。 const raw = object(path); if (raw.type !== 'bezier-chain' || !Array.isArray(raw.knots)) return path; /** 保留点的扩展字段;vector 模式把变换平移归零,仅施加旋转/比例。 */ @@ -42,13 +44,15 @@ export function transformPath(path: Json, transform: Transform, inverse = false) /** 合成父变换与分区入口几何,得到子 canvas 内容区原点/比例/旋转。 * 24/36 是格式 1 的内容内边距,要乘 presentation.unitScale,和 renderer 的投影保持一致。 */ export function childCanvasTransform(parent:Transform,geometry:Geometry,presentation:Json|undefined):Transform { + // unitScale 由专用读取器检查;contentScale 在这里仅按 number 且 >0 选择,有限性等约束依赖上游 schema。 const data=object(presentation),units=unitScale(presentation),scale=(typeof data.contentScale==='number'&&data.contentScale>0?data.contentScale:1)*units; // 入口绕中心旋转,因此先把内容内边距写成相对中心偏移,再旋转并加回中心。 const offset=rotate({x:24*units-geometry.width/2,y:36*units-geometry.height/2},geometry.rotation); const start=toWorld(parent,{x:geometry.x+geometry.width/2+offset.x,y:geometry.y+geometry.height/2+offset.y}); return{...start,scale:parent.scale*scale,rotation:((parent.rotation+geometry.rotation)%360+360)%360}; } -/** 从根向目标逐级合成入口变换;ancestors 同时检查缺失父项与循环。 */ +/** 从根向目标逐级合成入口变换;ancestors 检查缺失父项与循环。 + * 这里只要求每层入口存在且为 node,不检查组件是否已知/展开;可编辑性与可见性由上层决定。 */ export function canvasTransform(document:Document,canvasId:Id):Transform { let result:Transform={...origin}; for(const id of ancestors(document,canvasId).reverse()){ @@ -65,7 +69,8 @@ export function convertGeometry(geometry:Geometry,from:Transform,to:Transform):G const center=toLocal(to,toWorld(from,{x:geometry.x+geometry.width/2,y:geometry.y+geometry.height/2})),ratio=from.scale/to.scale,width=geometry.width*ratio,height=geometry.height*ratio; return{x:center.x-width/2,y:center.y-height/2,width,height,rotation:((geometry.rotation+from.rotation-to.rotation)%360+360)%360}; } -/** 严格曲线换空间:null/同变换无需改;不支持的路径拒绝,不擅自解释未知 payload。 */ +/** null/同变换先原样返回;只有实际需要换空间时才检查支持的曲线格式,不支持就拒绝。 + * 两次转换分别完成旧局部→世界、世界→新局部;无须改变 knot ID 或曲线段拓扑。 */ export function convertPath(path:Json,from:Transform,to:Transform):Json { if(path===null||sameTransform(from,to))return path; if(!connectionPathValid(path))throw fault('unsupported_coordinate_path'); diff --git a/src/lib/canvas-core/creation-draft.ts b/src/lib/canvas-core/creation-draft.ts index a25850a..87a6323 100644 --- a/src/lib/canvas-core/creation-draft.ts +++ b/src/lib/canvas-core/creation-draft.ts @@ -1,6 +1,7 @@ /** * 同步事务内的大批创建优化。追加表用固定成员的 base+added 视图避免每新增一项都复制整表, - * 累计 64 项后折叠为新 base。只用于尚未发布的 draft,提交前统一物化为标准文档表。 + * 已有追加层达到 64 项时,下一次追加先折叠为新 base。只用于未发布 draft,提交前统一物化文档表。 + * 输入表须已具固定成员;视图借用 base 和未变记录,不是任意可变 Map 的隔离器。 */ import {detachElementData} from './element-data.ts'; import type {CanvasRecord,Change,Document,ElementRecord,Id} from './types.ts'; @@ -14,12 +15,14 @@ const overlays=new WeakMap;added:ReadonlyMa function append(input:ReadonlyMap,id:Id,value:T):ReadonlyMap{ if(input.has(id))throw fault('duplicate_identity',[id]); let previous=overlays.get(input) as {base:ReadonlyMap;added:ReadonlyMap}|undefined; - // 控制覆盖层大小;到阈值后重建 base,而不是让链条随着事务长度无限加深。 + // 当前 input 若含 64 个 added,本次先通过 readMap(input) 折叠,再以新 added 放入第 65 个;不是整个事务最多 64 项。 if(previous&&previous.added.size>=64)previous=undefined; + // overlays 记录的是框架创建的视图身份;普通输入直接借用,只有已知旧 overlay 才需要折叠。 const base=previous?.base??(overlays.has(input)?readMap(input):input),added=new Map(previous?.added); added.set(id,value); /** 迭代先 base 后 added,保持插入顺序;视图的 get/has 优先查 appended 成员。 */ const entries=function*():Generator<[Id,T],undefined,unknown>{yield* base;yield* added;return undefined;}; + // get 用 has 区分“added 中显式存 undefined”与缺失;迭代顺序始终 base 在前、追加成员在后。 // keys/values/iterator 复用 entries;forEach 第三个参数仍是安全视图,不泄漏 added Map。 const view:ReadonlyMap=Object.freeze({size:base.size+added.size,get:(key:Id)=>added.has(key)?added.get(key):base.get(key),has:(key:Id)=>added.has(key)||base.has(key),entries, *keys():Generator{for(const [key]of entries())yield key;return undefined;},*values():Generator{for(const [,item]of entries())yield item;return undefined;},[Symbol.iterator]:entries, @@ -29,14 +32,17 @@ function append(input:ReadonlyMap,id:Id,value:T):ReadonlyMap{ } /** Internal editor path: input tables already have fixed membership. */ /** 仅接收 before=null 且 after 存在的创建变化,校验 JSON/ID 后捕获记录并追加相应表。 - * 整图依赖可在后续命令补齐,因此此处不提前做完整树/端点校验。 */ + * 整图依赖可在后续命令补齐,因此此处不提前做完整树/端点校验。 + * 本层只检查创建形状、JSON、ID 一致及活表重复;registry 支持与墓碑禁复用由 editor/最终校验负责。 */ export function stageCreation(document:Document,change:Extract):Document{ if(change.before!==null||!change.after)throw fault('invalid_creation_draft'); if(!validJson(change.after))throw fault(change.kind==='canvas'?'invalid_canvas':'invalid_element',[change.id]); if(change.after.id!==change.id)throw fault('identity_mismatch',[change.id]); const value=change.kind==='element'?detachElementData(change.after as ElementRecord):freeze(clone(change.after)); + // 只替换被追加的表,另一表/墓碑沿用;新 Document 标记为 draft,旧视图不随本次 added 改变。 const next=Object.freeze({...document,...(change.kind==='canvas'?{canvases:append(document.canvases,change.id,value as CanvasRecord)}:{elements:append(document.elements,change.id,value as ElementRecord)})});drafts.add(next);return next; } -/** Only published documents use the core's owned table envelopes. */ -/** 只有已标记的 draft 需要通过空 changes 重新捕获表;普通文档原样复用。 */ +/** Materialize transaction-local append tables before publication. */ +/** 只有已标记 draft 通过空 changes 物化成工厂 envelope/readMap;其它文档原样返回。 + * 不在这里校验 schema/树,也不把任何外来 Document 自动视为已校验或深度不可变。 */ export const materializeCreations=(document:Document)=>drafts.has(document)?applyChanges(document,[]):document; diff --git a/src/lib/canvas-core/creation-size-plan.ts b/src/lib/canvas-core/creation-size-plan.ts index a74fd9b..4996862 100644 --- a/src/lib/canvas-core/creation-size-plan.ts +++ b/src/lib/canvas-core/creation-size-plan.ts @@ -22,12 +22,14 @@ export function validateCreationSizeEffect(value: unknown): asserts value is Cre * Already-issued operation bodies are deliberately outside this function. */ /** 仅原创建仍在 undo、目标仍活且未被用户缩放时,准备默认尺寸与历史修正。 - * 返回候选 document/history/changes;原输入始终不变,失败不留下半份历史。 */ + * 返回候选 document/history/changes,不发布状态;输入须来自已捕获的不可变文档与历史。 + * 这里按 geometry.resize 能力判定,不限定 componentId 必须是 image;图片授权条件由外层 grant 决定。 */ export function planCreationSize(document: Document, history: HistoryState, registry: Registry, entryId: Id, target: Target, size: { readonly width: number; readonly height: number }) { /** 统一将失败绑定到目标 ID,便于调用方解释为何不能再自动调整。 */ const fail = (code: string): never => { throw fault(code, [target.id]); }; if (![size.width, size.height].every(value => Number.isFinite(value) && value > 0)) fail('invalid_creation_size'); + // 创建项必须仍在已应用 undo 时间线上;已撤销到 redo、被淘汰或目标换代的尺寸结果不能采用。 const index = history.undo.indexOf(entryId), entry = history.entries.get(entryId); const current = document.elements.get(target.id); if (index < 0 || !entry || current?.incarnation !== target.incarnation || current.structure !== 'node') fail('creation_size_target_changed'); @@ -40,7 +42,7 @@ export function planCreationSize(document: Document, history: HistoryState, regi const units = unitScale(created.data.presentation); const baseline = { width: created.geometry.width / units, height: created.geometry.height / units }; const ratio = { width: size.width / baseline.width, height: size.height / baseline.height }; - /** 同一生命周期的节点才属于本次自动尺寸修正对象。 */ + /** 调用处已按 target.id 取记录,这里再限制 incarnation 与 node 结构;不单独比较元素 ID。 */ const matches = (element: ElementRecord | undefined) => element?.incarnation === target.incarnation && element.structure === 'node'; /** 比较宽高除 unitScale 后的视觉值,移动/换 owner 不等同于用户主动缩放。 */ const unchanged = (element: ElementRecord | undefined) => matches(element) && element!.structure === 'node' @@ -52,9 +54,11 @@ export function planCreationSize(document: Document, history: HistoryState, regi // Rewind on private roots. applyChanges intentionally preserves historical // incarnations here; this is inspection, not a user-visible delete/restore. let original = document; - // 从创建项起回退已应用历史,得到创建前私有基线;不产生用户可见的删除/恢复代次。 + // 从创建项起回退全部已应用项,得到创建前私有基线;此处用 applyChanges 而非 planHistoryChanges, + // 因而允许检查途中存在历史代次/墓碑组合,不把它们作为最终文档发布。 const applied = history.undo.slice(index); for (const id of [...applied].reverse()) original = applyChanges(original, invert(history.entries.get(id)!.changes)); + // original 追踪旧时间线,amended 追踪修正后的时间线;inheriting 只在匹配创建之后开启。 let amended = original, inheriting = false; const entries = new Map(history.entries); // 先按 undo 的正向顺序重建已应用项,再倒序遍历 redo 栈还原未来重做时间线。 @@ -63,11 +67,12 @@ export function planCreationSize(document: Document, history: HistoryState, regi const id = timeline[cursor], source = entries.get(id)!; const end = applyChanges(original, source.changes), isApplied = cursor < applied.length; if (isApplied && !unchanged(end.elements.get(target.id))) fail('creation_size_user_resized'); - // A future explicit resize remains a resize to the user's chosen dimensions. - // Only its before value inherits the asynchronous default correction. + // A future resize preserves each dimension explicitly changed by the user. + // Its before value and unchanged dimensions may inherit the default correction. const futureResize = !isApplied && inheriting && matches(end.elements.get(target.id)) && !unchanged(end.elements.get(target.id)); let resizeConsumed = false; - /** redo 中用户显式改过的维度停止继承默认比例;仅修正其 before,不改用户指定的 after。 */ + /** redo 中按宽/高分别判断:被用户改过的维度将 ratio 置 1,保留其 after; + * before 重取修正时间线,未被用户改变的另一维仍可继承默认比例。每项最多消费一次。 */ const preserveFutureResize = () => { if (!futureResize || resizeConsumed) return; const value = end.elements.get(target.id) as ElementRecord & { structure: 'node' }; @@ -76,9 +81,10 @@ export function planCreationSize(document: Document, history: HistoryState, regi resizeConsumed = true; }; const changes: Change[] = []; - // 每条 next 都基于 amended 的实际 before 重建,保证修正后的连续历史仍条件匹配。 + // 目标整记录/geometry 变化从 amended 重取 before;其它变化保留原条件,继续用 applyChanges 校验。 for (const change of source.changes) { let next = change; + // 整记录变化也要重取真实 before;其它对象/字段沿用原变化,保证整条时间线仍按原次序检查。 if (change.kind === 'element' && change.id === target.id) { const before = amended.elements.get(target.id) ?? null; const birth = id === entryId && change === creations[0]; @@ -98,6 +104,7 @@ export function planCreationSize(document: Document, history: HistoryState, regi amended = applyChanges(amended, [next]); changes.push(next); } + // 整项完成后再推进旧时间线;只有变化内容确实不同才替换 entries 中这一项,operationIds/标签保持。 original = end; if (!equal(changes, source.changes)) entries.set(id, freeze({ ...source, changes })); } @@ -112,10 +119,12 @@ export function planCreationSize(document: Document, history: HistoryState, regi ]); const nextDocument = applyChanges(document, changes); validateDocument(nextDocument, registry); - // 最后用组件自己的 resize handler 对照约束,自动尺寸不能绕过组件 min/max/aspect 政策。 + // 调用已安装 resize handler,把其生成变化应用后的目标记录与直接候选比较;不一致就拒绝。 + // 这能执行组件实际实现的限制,不凭 capability 名称宣称所有组件都有 min/max/aspect 政策。 const handler = registry.handlers.get(current!.componentId)?.get('geometry.resize'); const proposed = handler!({ element: current!, input: freeze({ patch: { width: after.width, height: after.height } }) }); for (const change of proposed) if (change.target.id !== target.id || change.target.incarnation !== target.incarnation || change.field !== 'geometry') fail('invalid_handler_change'); if (!equal(applyChanges(document, proposed).elements.get(target.id), nextDocument.elements.get(target.id))) fail('creation_size_constraint'); + // 仅返回当前几何 changes,历史 entries 是整条修正时间线;两栈顺序沿用,不把上方 redo 检查变成实际重做。 return Object.freeze({ document: nextDocument, changes, history: Object.freeze({ undo: history.undo, redo: history.redo, entries: readMap(entries) }) }); } diff --git a/src/lib/canvas-core/structure.ts b/src/lib/canvas-core/structure.ts index 0deee89..74c9a43 100644 --- a/src/lib/canvas-core/structure.ts +++ b/src/lib/canvas-core/structure.ts @@ -1,6 +1,7 @@ /** * 文档结构修改的纯规划函数:删除拥有子树,以及重挂后的连线 owner 修复。 * 只返回条件 Change,不直接发布 editor 状态;调用方把它们合入一次事务再校验。 + * 文档应是有效、已捕获的不可变快照;返回 before 保留记录引用,freeze 不提供任意外部输入隔离。 */ import type {DocumentQueries} from './queries.ts'; import type { Change, Document, Id, Target } from './types.ts'; @@ -18,11 +19,13 @@ export function removalChanges(document:Document,target:Target,queries?:Document 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); + // 按原表顺序输出元素删除,再输出 canvas 删除;最终关系合法性在事务整批完成后检查。 return freeze([...queries.orderedElements(elements).map(id=>({kind:'element' as const,id,before:document.elements.get(id)!,after:null})),...queries.orderedCanvases(canvases).map(id=>({kind:'canvas' as const,id,before:document.canvases.get(id)!,after:null}))]); } // 扫描路径构造 parent→children 临时表;Set 去重,stack 避免 JS 递归调用栈增长。 const element=targetElement(document,target),elements=new Set([element.id]),canvases=new Set(),children=new Map(); for(const canvas of document.canvases.values())if(canvas.parent){const list=children.get(canvas.parent.canvasId)??[];list.push(canvas.id);children.set(canvas.parent.canvasId,list);} + // 只从“目标是入口”的子 canvas 开始,不因节点视觉上处在某个矩形内就扩大删除范围。 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);} // 先收集全部被删成员,再找相关边,防止边扫描先后顺序影响删除结果。 @@ -35,11 +38,13 @@ 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 必须作为同一操作一起转换。 */ +/** 端点或端点 owner 改变后,把边移到两端所属 canvas 的最近共同上级,保持边 ID/incarnation。 + * 曲线坐标属于旧 owner 空间,因此需要转换时 owner 与 path 必须作为同一操作一起提交。 + * 只修复 owner 发生变化的边;不会仅因祖先几何变了就重写同 owner 的局部路径。 */ 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。 @@ -47,5 +52,6 @@ export function repairConnectionOwners(before:Document,document:Document):readon 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}); } + // 这里只返回补偿变化;document 参数不在本函数内被替换或发布。 return freeze(changes); }