/** * 图片元信息异步到达后,把默认尺寸修正合回原创建历史的纯规划器。 * 同时修正后续历史的 before,使 undo/redo 保持可回放;不会改写已经冻结的请求 body。 * 持久授权、Root 版本检查和发布由 coordinator/editor 完成。 */ import type { Change, CreationSizeEffect, Document, ElementRecord, Geometry, HistoryState, Id, Registry, Target } from './types.ts'; import { applyChanges, invert, validateDocument } from './document.ts'; import { closeNumber, unitScale } from './units.ts'; import { equal, fault, freeze, readMap, validJson } from './values.ts'; /** 校验恢复图像中的尺寸 effect;未知协议与格式非法分别拒绝,不能猜旧效果含义。 */ export function validateCreationSizeEffect(value: unknown): asserts value is CreationSizeEffect { const effect = value as CreationSizeEffect | undefined; if (!effect || effect.version !== 1 || effect.kind !== 'creation-size') throw fault('history_effect_interpreter_unavailable'); if (!validJson(effect) || typeof effect.entryId !== 'string' || !effect.entryId || typeof effect.target?.id !== 'string' || !effect.target.id || !Number.isSafeInteger(effect.target.incarnation) || effect.target.incarnation < 1 || !effect.size || ![effect.size.width, effect.size.height].every(value => typeof value === 'number' && Number.isFinite(value) && value > 0)) throw fault('invalid_creation_size_effect'); } /** A pure proposal, not a publication API. The coordinator must still validate a * durable sizing grant and record a recoverable history effect before adopting it. * Already-issued operation bodies are deliberately outside this function. */ /** 仅原创建仍在 undo、目标仍活且未被用户缩放时,准备默认尺寸与历史修正。 * 返回候选 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'); // 必须在指定 entry 中找到唯一一次同 incarnation 的节点创建,不能猜其它创建记录。 const creations = entry!.changes.filter(change => change.kind === 'element' && change.id === target.id && !change.before && change.after?.incarnation === target.incarnation); if (creations.length !== 1 || creations[0].kind !== 'element' || creations[0].after?.structure !== 'node') fail('creation_size_entry_mismatch'); const created = creations[0].after as ElementRecord & { structure: 'node' }; if (registry.resolve(current!) !== 'known' || !registry.supports(current!, 'geometry.resize')) fail('unsupported_capability'); // 以创建时的视觉尺寸为基准;目标换 owner 后 unitScale 可变,但视觉基线仍可比较。 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' && closeNumber(element!.geometry.width / unitScale(element!.data.presentation), baseline.width) && closeNumber(element!.geometry.height / unitScale(element!.data.presentation), baseline.height); if (!unchanged(current)) fail('creation_size_user_resized'); /** 保留位置和旋转,只将需要继承默认修正的宽高按当前 ratio 缩放。 */ const scaled = (geometry: Geometry): Geometry => freeze({ ...geometry, width: geometry.width * ratio.width, height: geometry.height * ratio.height }); // 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 栈还原未来重做时间线。 const timeline = [...applied, ...[...history.redo].reverse()]; for (let cursor = 0; cursor < timeline.length; cursor++) { 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 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 中按宽/高分别判断:被用户改过的维度将 ratio 置 1,保留其 after; * before 重取修正时间线,未被用户改变的另一维仍可继承默认比例。每项最多消费一次。 */ const preserveFutureResize = () => { if (!futureResize || resizeConsumed) return; const value = end.elements.get(target.id) as ElementRecord & { structure: 'node' }; if (!closeNumber(value.geometry.width / unitScale(value.data.presentation), baseline.width)) ratio.width = 1; if (!closeNumber(value.geometry.height / unitScale(value.data.presentation), baseline.height)) ratio.height = 1; resizeConsumed = true; }; const changes: Change[] = []; // 目标整记录/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]; // 从创建点开始继承;遇到删除或其它 incarnation 时停止,不能跨生命周期改尺寸。 if (birth) inheriting = true; else preserveFutureResize(); const after = change.after && inheriting && change.after.incarnation === target.incarnation && change.after.structure === 'node' ? freeze({ ...change.after, geometry: scaled(change.after.geometry) }) : change.after; next = freeze({ ...change, before, after }); if (!change.after || change.after.incarnation !== target.incarnation) inheriting = false; } else if (change.kind === 'field' && change.target.id === target.id && change.target.incarnation === target.incarnation && change.field === 'geometry') { const before = amended.elements.get(target.id); if (before?.structure !== 'node') fail('creation_size_target_changed'); preserveFutureResize(); next = freeze({ ...change, before: (before as ElementRecord & { structure: 'node' }).geometry, after: inheriting ? scaled(change.after as Geometry) : change.after }); } amended = applyChanges(amended, [next]); changes.push(next); } // 整项完成后再推进旧时间线;只有变化内容确实不同才替换 entries 中这一项,operationIds/标签保持。 original = end; if (!equal(changes, source.changes)) entries.set(id, freeze({ ...source, changes })); } // Publishable proposal changes only the currently live geometry. Replaying // redo above was validation; it must never become the working document. // 上方 replay redo 只为校验/修正历史;当前工作文档仅修改当前活元素,不提前重做未来项。 const after = freeze({ ...(current as ElementRecord & { structure: 'node' }).geometry, width: (current as ElementRecord & { structure: 'node' }).geometry.width * size.width / baseline.width, height: (current as ElementRecord & { structure: 'node' }).geometry.height * size.height / baseline.height }); const changes: readonly Change[] = equal(after, (current as ElementRecord & { structure: 'node' }).geometry) ? [] : freeze([ { kind: 'field', target, field: 'geometry', before: (current as ElementRecord & { structure: 'node' }).geometry, after }, ]); const nextDocument = applyChanges(document, changes); validateDocument(nextDocument, registry); // 调用已安装 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) }) }); }