Files
Endoleap/src/lib/canvas-core/creation-size-plan.ts
T

131 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 图片元信息异步到达后,把默认尺寸修正合回原创建历史的纯规划器。
* 同时修正后续历史的 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) }) });
}