Plan creation size enrichment across undo and redo baselines
This commit is contained in:
@@ -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) }) });
|
||||
}
|
||||
Reference in New Issue
Block a user