Plan creation size enrichment across undo and redo baselines

This commit is contained in:
2026-11-08 23:57:53 +08:00
parent 3750648dea
commit fa7dad44e7
2 changed files with 246 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
import type { Change, 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 } from './values.ts';
/** 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.
*/
export function planCreationSize(document: Document, history: HistoryState, registry: Registry,
entryId: Id, target: Target, size: { readonly width: number; readonly height: number }) {
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');
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');
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');
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 };
const matches = (element: ElementRecord | undefined) => element?.incarnation === target.incarnation && element.structure === 'node';
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');
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;
const applied = history.undo.slice(index);
for (const id of [...applied].reverse()) original = applyChanges(original, invert(history.entries.get(id)!.changes));
let amended = original, inheriting = false;
const entries = new Map(history.entries);
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 explicit resize remains a resize to the user's chosen dimensions.
// Only its before value inherits the asynchronous default correction.
const futureResize = !isApplied && inheriting && matches(end.elements.get(target.id)) && !unchanged(end.elements.get(target.id));
let resizeConsumed = false;
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[] = [];
for (const change of source.changes) {
let next = change;
if (change.kind === 'element' && change.id === target.id) {
const before = amended.elements.get(target.id) ?? null;
const birth = id === entryId && change === creations[0];
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);
}
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.
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);
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');
return Object.freeze({ document: nextDocument, changes, history: Object.freeze({ undo: history.undo, redo: history.redo, entries: readMap(entries) }) });
}
+154
View File
@@ -0,0 +1,154 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createDocument, createEditor, createRegistry, builtinComponents } from '../../src/lib/canvas-core/index.ts';
import { planCreationSize } from '../../src/lib/canvas-core/creation-size-plan.ts';
import { applyChanges, invert } from '../../src/lib/canvas-core/document.ts';
import { describeHistoryTransition, applyHistoryTransition } from '../../src/lib/canvas-core/history-transition.ts';
const registry = createRegistry(builtinComponents());
const target = { id: 'image', incarnation: 1 };
function setup(options = {}) {
let id = 0;
const editor = createEditor({ document: createDocument('p', [{ id: 'root', parent: null }], []), registry, createId: () => `id-${++id}`, ...options });
const element = { ...target, ownerCanvasId: 'root', componentId: 'image', dataVersion: 1, structure: 'node', data: { title: 'Image', text: '', assetId: 'asset' }, geometry: { x: 10, y: 20, width: 260, height: 200, rotation: 0 } };
assert.equal(editor.dispatch({ type: 'element.create', element }).status, 'applied');
const entry = editor.getSnapshot().history.undo[0];
return { editor, entry, propose() { const { document, history } = editor.getSnapshot(); return planCreationSize(document, history, registry, entry, target, { width: 260, height: 130 }); } };
}
function traverse(plan) {
let document = plan.document;
for (const id of [...plan.history.undo].reverse()) document = applyChanges(document, invert(plan.history.entries.get(id).changes));
assert.equal(document.elements.size, 0);
for (const id of plan.history.undo) document = applyChanges(document, plan.history.entries.get(id).changes);
assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image'));
return document;
}
test('default size enriches the creation unit across move, rotation and title edits without mutating issued intents', () => {
const { editor, entry, propose } = setup();
editor.dispatch({ type: 'element.geometry', target, patch: { x: 80, rotation: 25 } });
editor.dispatch({ type: 'element.data', target, patch: { title: 'Renamed' } });
const before = editor.getSnapshot(), intents = [...before.operations.values()].map(value => JSON.stringify(value));
const plan = propose();
assert.deepEqual(plan.history.undo, before.history.undo);
assert.equal(plan.history.entries.get(entry).changes[0].after.geometry.height, 130);
assert.deepEqual(plan.document.elements.get('image').geometry, { x: 80, y: 20, width: 260, height: 130, rotation: 25 });
assert.equal(plan.document.elements.get('image').data.title, 'Renamed');
assert.strictEqual(editor.getSnapshot(), before);
assert.deepEqual([...before.operations.values()].map(value => JSON.stringify(value)), intents);
traverse(plan);
});
test('an unrelated redo stack is retained and rebased without becoming the working document', () => {
const { editor, propose } = setup();
editor.dispatch({ type: 'element.geometry', target, patch: { x: 90 } }); editor.history.undo();
const before = editor.getSnapshot(), plan = propose();
assert.deepEqual(plan.history.redo, before.history.redo);
assert.equal(plan.document.elements.get('image').geometry.x, 10);
let document = traverse(plan);
document = applyChanges(document, plan.history.entries.get(plan.history.redo.at(-1)).changes);
assert.equal(document.elements.get('image').geometry.x, 90);
assert.equal(document.elements.get('image').geometry.height, 130);
});
test('a future explicit resize keeps the chosen dimensions, with a corrected undo baseline', () => {
const { editor, propose } = setup();
editor.dispatch({ type: 'element.geometry', target, patch: { width: 400, height: 300 } }); editor.history.undo();
const plan = propose(), resize = plan.history.entries.get(plan.history.redo.at(-1));
const resized = applyChanges(plan.document, resize.changes);
assert.equal(resized.elements.get('image').geometry.height, 300);
assert.equal(applyChanges(resized, invert(resize.changes)).elements.get('image').geometry.height, 130);
});
test('applied resize rejects even after a later resize returns to the initial dimensions', () => {
const { editor, propose } = setup();
editor.dispatch({ type: 'element.geometry', target, patch: { height: 400 } });
editor.dispatch({ type: 'element.geometry', target, patch: { height: 200 } });
const before = editor.getSnapshot();
assert.throws(propose, { code: 'creation_size_user_resized' }); assert.strictEqual(editor.getSnapshot(), before);
});
test('an undone creation cannot be enriched', () => {
const { editor, propose } = setup(); editor.history.undo();
assert.throws(propose, { code: 'creation_size_target_changed' });
});
test('a restored element has a new incarnation and rejects the old asynchronous task', () => {
const { editor, propose } = setup(); editor.dispatch({ type: 'element.remove', target }); editor.history.undo();
// Undo of removal creates a new incarnation: an old asynchronous task is stale.
assert.throws(propose, { code: 'creation_size_target_changed' });
});
test('the complete history proposal is reversible using the existing transition proof format', () => {
const { editor, entry, propose } = setup(); editor.dispatch({ type: 'element.geometry', target, patch: { x: 50 } });
const before = editor.getSnapshot(), plan = propose();
const transition = describeHistoryTransition(before.history, plan.history, before.rootRevision + 1, entry);
const replay = applyHistoryTransition(before.history, JSON.parse(JSON.stringify(transition)), 'forward');
assert.deepEqual([...replay.entries], [...plan.history.entries]);
const restored = applyHistoryTransition(replay, transition, 'reverse');
assert.deepEqual([...restored.entries], [...before.history.entries]);
assert.equal(plan.history.entries.set, undefined);
});
test('a logical unit conversion preserves size, latest position and the full undo path', () => {
const { editor, propose } = setup({ allowRawChanges: true });
const before = editor.getSnapshot().document.elements.get('image');
editor.transaction(tx => {
tx.dispatch({ type: 'changes.apply', changes: [
{ kind: 'field', target, field: 'geometry', before: before.geometry, after: { ...before.geometry, x: 45, width: 520, height: 400 } },
{ kind: 'field', target, field: 'data.presentation', before: null, beforePresent: false, after: { unitScale: 2 } },
] });
});
const plan = propose();
assert.equal(plan.document.elements.get('image').geometry.height, 260);
assert.equal(plan.document.elements.get('image').geometry.x, 45);
traverse(plan);
});
test('a real move into a scaled child canvas is retained through enrichment and undo', () => {
const frame = { id: 'frame', incarnation: 1, ownerCanvasId: 'root', componentId: 'frame', dataVersion: 1, structure: 'node', data: { title: 'Frame', text: '', presentation: { contentScale: 2 } }, geometry: { x: 0, y: 0, width: 600, height: 400, rotation: 0 } };
const { editor, propose } = setup({ document: createDocument('p', [{ id: 'root', parent: null }, { id: 'child', parent: { canvasId: 'root', entryElementId: 'frame' } }], [frame]) });
assert.equal(editor.dispatch({ type: 'element.move', target, canvasId: 'child' }).status, 'applied');
const current = editor.getSnapshot().document.elements.get('image'), plan = propose();
assert.equal(plan.document.elements.get('image').ownerCanvasId, 'child');
assert.equal(plan.document.elements.get('image').geometry.x, current.geometry.x);
assert.equal(plan.document.elements.get('image').geometry.height, 65);
let document = plan.document;
for (const id of [...plan.history.undo].reverse()) document = applyChanges(document, invert(plan.history.entries.get(id).changes));
assert.equal(document.elements.has('image'), false); assert.equal(document.elements.has('frame'), true);
for (const id of plan.history.undo) document = applyChanges(document, plan.history.entries.get(id).changes);
assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image'));
});
test('evicted creation history is rejected without synthesizing a new undo entry', () => {
const { editor, propose } = setup({ historyLimit: 1 });
editor.dispatch({ type: 'element.data', target, patch: { title: 'New title' } });
assert.throws(propose, { code: 'creation_size_target_changed' });
});
test('a future resize followed by movement retains the user size through both redo entries', () => {
const { editor, propose } = setup();
editor.dispatch({ type: 'element.geometry', target, patch: { height: 300 } });
editor.dispatch({ type: 'element.geometry', target, patch: { x: 75 } });
editor.history.undo(); editor.history.undo();
const plan = propose(); let document = plan.document;
for (const id of [...plan.history.redo].reverse()) document = applyChanges(document, plan.history.entries.get(id).changes);
assert.equal(document.elements.get('image').geometry.height, 300);
assert.equal(document.elements.get('image').geometry.x, 75);
for (const id of plan.history.redo) document = applyChanges(document, invert(plan.history.entries.get(id).changes));
assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image'));
});
test('invalid sizes and stale document baselines are rejected without mutation', () => {
const { editor, entry } = setup(); const root = editor.getSnapshot();
assert.throws(() => planCreationSize(root.document, root.history, registry, entry, target, { width: Infinity, height: 1 }), { code: 'invalid_creation_size' });
editor.dispatch({ type: 'element.data', target, patch: { title: 'Remote change' } }, { origin: 'remote' });
const stale = editor.getSnapshot();
assert.throws(() => planCreationSize(stale.document, stale.history, registry, entry, target, { width: 260, height: 130 }), { code: 'element_conflict' });
assert.strictEqual(editor.getSnapshot(), stale);
});
test('component constraints cannot be bypassed by a default size proposal', () => {
const { editor, entry } = setup(); const root = editor.getSnapshot();
assert.throws(() => planCreationSize(root.document, root.history, registry, entry, target, { width: 260, height: 0.5 }), { code: 'creation_size_constraint' });
});
test('a redo edit to one size axis preserves the corrected default on the other axis', () => {
const { editor, entry } = setup();
editor.dispatch({ type: 'element.geometry', target, patch: { height: 300 } });
editor.dispatch({ type: 'element.geometry', target, patch: { x: 75 } });
editor.history.undo(); editor.history.undo();
const root = editor.getSnapshot(), plan = planCreationSize(root.document, root.history, registry, entry, target, { width: 520, height: 130 });
assert.equal(plan.document.elements.get('image').geometry.width, 520);
assert.equal(plan.document.elements.get('image').geometry.height, 130);
let document = plan.document;
for (const id of [...plan.history.redo].reverse()) document = applyChanges(document, plan.history.entries.get(id).changes);
assert.equal(document.elements.get('image').geometry.width, 520);
assert.equal(document.elements.get('image').geometry.height, 300);
});