docs(core): clarify creation sizing and structural planning boundaries
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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<object,{base:ReadonlyMap<Id,unknown>;added:ReadonlyMa
|
||||
function append<T>(input:ReadonlyMap<Id,T>,id:Id,value:T):ReadonlyMap<Id,T>{
|
||||
if(input.has(id))throw fault('duplicate_identity',[id]);
|
||||
let previous=overlays.get(input) as {base:ReadonlyMap<Id,T>;added:ReadonlyMap<Id,T>}|undefined;
|
||||
// 控制覆盖层大小;到阈值后重建 base,而不是让链条随着事务长度无限加深。
|
||||
// 当前 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<Id,T>=Object.freeze({size:base.size+added.size,get:(key:Id)=>added.has(key)?added.get(key):base.get(key),has:(key:Id)=>added.has(key)||base.has(key),entries,
|
||||
*keys():Generator<Id,undefined,unknown>{for(const [key]of entries())yield key;return undefined;},*values():Generator<T,undefined,unknown>{for(const [,item]of entries())yield item;return undefined;},[Symbol.iterator]:entries,
|
||||
@@ -29,14 +32,17 @@ function append<T>(input:ReadonlyMap<Id,T>,id:Id,value:T):ReadonlyMap<Id,T>{
|
||||
}
|
||||
/** 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<Change,{kind:'canvas'|'element'}>):Document{
|
||||
if(change.before!==null||!change.after)throw fault('invalid_creation_draft');
|
||||
if(!validJson(change.after))throw fault(change.kind==='canvas'?'invalid_canvas':'invalid_element',[change.id]);
|
||||
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;
|
||||
|
||||
@@ -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) }) });
|
||||
}
|
||||
|
||||
@@ -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<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);}
|
||||
// 只从“目标是入口”的子 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user