@openpresentation/opf-editor 0.24.2 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,704 @@
1
+ // Tree operations on an OPF layout record (RR-99): the record's `regions` list of regions and splits, edited as pure functions.
2
+ // Every operation takes a record and returns `{ record, selection }` (a new record; the input is never changed, and `selection` is
3
+ // the list of node paths to select afterwards) or throws a `LayoutOperationError` whose message says why the edit is refused.
4
+ // A node path is the list of indexes from the record's own list down: `[]` is the record, `[1, 0]` is `regions[1].regions[0]`.
5
+ // The operations keep a valid record valid: names stay unique, sizes stay legal (`auto` only in a column), a list stays within the
6
+ // depth and entry limits of core, and a split left with one child collapses into it. Geometry (`layoutBoxes`) places the same tree
7
+ // in a unit box for the canvas. No DOM, no network.
8
+ import { layoutTemplateIssues } from "@openpresentation/opf/composition";
9
+ import { LAYOUT_ANCHORS, LAYOUT_DIRECTIONS, LAYOUT_MAX_DEPTH, LAYOUT_MAX_ENTRIES, LAYOUT_REGION_KINDS } from "./layout-templates.js";
10
+
11
+ export class LayoutOperationError extends Error {
12
+ constructor(message, code = "layout-operation") {
13
+ super(message);
14
+ this.name = "LayoutOperationError";
15
+ this.code = code;
16
+ }
17
+ }
18
+ const refuse = (message, code) => { throw new LayoutOperationError(message, code); };
19
+
20
+ export const HEADING_NAMES = Object.freeze(["title", "subtitle"]);
21
+ /** Rules a body region can carry, in the order the record writes them. */
22
+ export const REGION_KEYS = Object.freeze(["accepts", "role", "flow", "max", "bleed", "listColumns", "anchor", "empty"]);
23
+ const RECORD_ORDER = ["$schema", "id", "name", "summary", "direction", "anchor", "regions", "overflowRegion", "design", "composition"];
24
+ const OTHER = { row: "column", column: "row" };
25
+
26
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
27
+ export const isSplit = (entry) => isObject(entry) && entry.name === undefined;
28
+ export const isHeading = (entry) => isObject(entry) && HEADING_NAMES.includes(entry.name);
29
+ export const isRegion = (entry) => isObject(entry) && entry.name !== undefined;
30
+ export const clone = (value) => structuredClone(value);
31
+ export const samePath = (a, b) => a.length === b.length && a.every((part, index) => part === b[index]);
32
+ export const isPrefix = (prefix, path) => prefix.length <= path.length && prefix.every((part, index) => part === path[index]);
33
+ export const pathKey = (path) => path.join(".");
34
+ export const compareLength = (a, b) => { for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] !== b[i]) return a[i] - b[i]; return a.length - b.length; };
35
+
36
+ /** The entries of the record's list (`[]`) or of the split at `path`. */
37
+ export function listAt(record, path) {
38
+ let list = record.regions;
39
+ for (const index of path) {
40
+ const entry = list?.[index];
41
+ if (!isSplit(entry)) return undefined;
42
+ list = entry.regions;
43
+ }
44
+ return Array.isArray(list) ? list : undefined;
45
+ }
46
+ /** The region or split at `path` (undefined for `[]` and for a path that leads nowhere). */
47
+ export function nodeAt(record, path) {
48
+ if (!path.length) return undefined;
49
+ return listAt(record, path.slice(0, -1))?.[path[path.length - 1]];
50
+ }
51
+ /** `row` or `column`: how the list that holds the node at `path` lays its entries out (the record's `direction` for a top-level node). */
52
+ export function parentDirection(record, path) {
53
+ return listDirection(record, path.slice(0, -1));
54
+ }
55
+ /** How the list at `listPath` (`[]` is the record's own) lays its entries out. */
56
+ export function listDirection(record, listPath) {
57
+ if (!listPath.length) return record.direction === "row" ? "row" : "column";
58
+ const split = nodeAt(record, listPath);
59
+ return split?.direction === "row" ? "row" : "column";
60
+ }
61
+ /** The list level of a list: the record's own list is level 1, a split's list level 2, and so on. */
62
+ export const listLevel = (listPath) => listPath.length + 1;
63
+ /** The levels a node adds below its own list: 0 for a region, 1 plus the deepest child for a split. */
64
+ export function height(entry) {
65
+ if (!isSplit(entry)) return 0;
66
+ return 1 + Math.max(0, ...(entry.regions ?? []).map(height));
67
+ }
68
+ /** Every node, depth first in reading order: `{ entry, path, listPath, index, depth }` (depth 1 is the record's own list). */
69
+ export function nodes(record) {
70
+ const out = [];
71
+ const walk = (list, listPath) => {
72
+ (Array.isArray(list) ? list : []).forEach((entry, index) => {
73
+ const path = [...listPath, index];
74
+ out.push({ entry, path, listPath, index, depth: path.length });
75
+ if (isSplit(entry)) walk(entry.regions, path);
76
+ });
77
+ };
78
+ walk(record.regions, []);
79
+ return out;
80
+ }
81
+ /** The names of the regions, headings included. */
82
+ export const regionNames = (record) => nodes(record).filter(({ entry }) => isRegion(entry)).map(({ entry }) => entry.name);
83
+ /** The path of the region called `name`, or undefined. */
84
+ export const pathOfName = (record, name) => nodes(record).find(({ entry }) => isRegion(entry) && entry.name === name)?.path;
85
+ export const hasHeading = (record, name) => regionNames(record).includes(name);
86
+
87
+ function freeName(record, taken = []) {
88
+ const used = new Set([...regionNames(record), ...taken]);
89
+ for (let n = 1; ; n++) if (!used.has(`region-${n}`)) return `region-${n}`;
90
+ }
91
+ function copyName(name, used) {
92
+ const base = name.replace(/-\d+$/, "");
93
+ for (let n = 2; ; n++) if (!used.has(`${base}-${n}`)) return `${base}-${n}`;
94
+ }
95
+
96
+ /** The size an entry has: its own, else `auto` for a heading in a column, else 1. */
97
+ export function effectiveSize(entry, direction) {
98
+ if (entry.size !== undefined) return entry.size;
99
+ return isHeading(entry) && direction === "column" ? "auto" : 1;
100
+ }
101
+ const writeSize = (entry, size, direction) => {
102
+ if (typeof size === "number") size = Math.min(100, Math.max(0.01, size));
103
+ const normal = isHeading(entry) ? effectiveSize({ name: entry.name }, direction) : 1;
104
+ if (size === undefined || size === normal) delete entry.size; else entry.size = size;
105
+ };
106
+ /** Rewrite a key of an entry in the order a record writes them. */
107
+ export function orderEntry(entry) {
108
+ const keys = isSplit(entry) ? ["direction", "size", "anchor", "regions"] : ["name", "size", ...REGION_KEYS];
109
+ const out = {};
110
+ for (const key of keys) if (entry[key] !== undefined) out[key] = entry[key];
111
+ for (const key of Object.keys(entry)) if (!(key in out)) out[key] = entry[key];
112
+ return out;
113
+ }
114
+ export function orderRecord(record) {
115
+ const out = {};
116
+ for (const key of RECORD_ORDER) if (record[key] !== undefined) out[key] = record[key];
117
+ for (const key of Object.keys(record)) if (!(key in out)) out[key] = record[key];
118
+ return out;
119
+ }
120
+ const replaceEntry = (list, index, entry) => { list[index] = orderEntry(entry); return list[index]; };
121
+ /** Put an entry's keys in record order without making a new object (so a reference to it stays good). */
122
+ function orderInPlace(entry) {
123
+ const ordered = orderEntry(entry);
124
+ for (const key of Object.keys(entry)) delete entry[key];
125
+ return Object.assign(entry, ordered);
126
+ }
127
+
128
+ /** After an edit: `auto` is legal only in a column, so a list laid out as a row gives its entries a numeric size. */
129
+ function stripAutoInRows(record) {
130
+ const walk = (list, direction) => {
131
+ for (const entry of list ?? []) {
132
+ if (direction === "row" && entry.size === "auto") delete entry.size;
133
+ if (isSplit(entry)) walk(entry.regions, entry.direction === "row" ? "row" : "column");
134
+ }
135
+ };
136
+ walk(record.regions, record.direction === "row" ? "row" : "column");
137
+ }
138
+ /**
139
+ * An operation's result. With `before` (the record the edit started from) the result is also refused when it makes core's compile fail
140
+ * where it did not, which is a grid of more than 12 rows or columns: a layout the engine could not draw.
141
+ */
142
+ function result(record, selection, before) {
143
+ if (before) {
144
+ const known = new Set(layoutTemplateIssues(before).map((issue) => `${issue.code} ${issue.message}`));
145
+ const fresh = layoutTemplateIssues(record).find((issue) => issue.code === "layout-template" && !known.has(`${issue.code} ${issue.message}`));
146
+ if (fresh) refuse("That would compile to more than 12 rows or columns. Use fewer cuts.", "layout-tracks");
147
+ }
148
+ return { record, selection };
149
+ }
150
+ const countIn = (list) => Array.isArray(list) ? list.length : 0;
151
+
152
+ // --- anchor: written once on the record or a split, inherited by the regions under it -------------------------------------
153
+
154
+ /** The anchor a node inherits (never its own): `{ value, from }`, `from` being "split", "layout" or "default" (top, when nothing sets one). */
155
+ export function inheritedAnchor(record, path) {
156
+ let value = record.anchor, from = value === undefined ? "default" : "layout";
157
+ let list = record.regions;
158
+ for (const index of path.slice(0, -1)) {
159
+ const split = list?.[index];
160
+ if (!isSplit(split)) break;
161
+ if (split.anchor !== undefined) { value = split.anchor; from = "split"; }
162
+ list = split.regions;
163
+ }
164
+ return { value, from };
165
+ }
166
+ function pushAnchor(split, children) {
167
+ if (split.anchor === undefined) return;
168
+ for (const child of children) if (!isHeading(child) && child.anchor === undefined) child.anchor = split.anchor;
169
+ }
170
+
171
+ // --- structure edits ------------------------------------------------------------------------------------------------------
172
+
173
+ /**
174
+ * Split the region at `path` along `axis` ("row": two side by side, "column": two stacked). The original keeps its name and rules; the new
175
+ * region is called `region-N`. In a list that already runs along `axis` the new region is a sibling that shares the original's size;
176
+ * otherwise a split of `axis` holds both.
177
+ */
178
+ export function splitRegion(record, path, axis) {
179
+ if (!LAYOUT_DIRECTIONS.includes(axis)) refuse("Choose row or column.");
180
+ const next = clone(record);
181
+ const node = nodeAt(next, path);
182
+ if (!node) refuse("Choose a region to split.");
183
+ if (isSplit(node)) refuse("Choose a region to split, not a split. Select a region inside it.");
184
+ const listPath = path.slice(0, -1), index = path[path.length - 1];
185
+ const list = listAt(next, listPath), direction = listDirection(next, listPath);
186
+ const fresh = { name: freeName(next) };
187
+ if (direction === axis) {
188
+ if (list.length >= LAYOUT_MAX_ENTRIES) refuse(`A list holds at most ${LAYOUT_MAX_ENTRIES} regions and splits.`, "layout-entries");
189
+ const size = effectiveSize(node, direction);
190
+ if (typeof size === "number") {
191
+ const half = Math.max(0.01, Math.round(size / 2 * 1000) / 1000);
192
+ writeSize(node, half, direction);
193
+ fresh.size = half;
194
+ if (fresh.size === 1) delete fresh.size;
195
+ replaceEntry(list, index, node);
196
+ }
197
+ list.splice(index + 1, 0, orderEntry(fresh));
198
+ return result(next, [[...listPath, index + 1]], record);
199
+ }
200
+ if (listLevel(listPath) + 1 > LAYOUT_MAX_DEPTH) refuse(`Regions nest at most ${LAYOUT_MAX_DEPTH} levels deep.`, "layout-depth");
201
+ const size = effectiveSize(node, direction);
202
+ const wrapper = { direction: axis, regions: [node, fresh] };
203
+ if (size !== 1) wrapper.size = size;
204
+ delete node.size;
205
+ stripAutoInRows({ direction: axis, regions: wrapper.regions });
206
+ list[index] = orderEntry({ ...wrapper, regions: wrapper.regions.map(orderEntry) });
207
+ return result(next, [[...path, 1]], record);
208
+ }
209
+
210
+ /** Insert a new region called `region-N` into the list at `listPath`, before `index`. */
211
+ export function insertRegion(record, listPath, index, entry) {
212
+ const next = clone(record);
213
+ const list = listAt(next, listPath);
214
+ if (!list) refuse("Choose a list to insert into.");
215
+ if (list.length >= LAYOUT_MAX_ENTRIES) refuse(`A list holds at most ${LAYOUT_MAX_ENTRIES} regions and splits.`, "layout-entries");
216
+ const at = Math.max(0, Math.min(index, list.length));
217
+ list.splice(at, 0, orderEntry(entry ? clone(entry) : { name: freeName(next) }));
218
+ return result(next, [[...listPath, at]], record);
219
+ }
220
+
221
+ /** Add or remove the `title` or `subtitle` heading region. Removing a title removes the subtitle too. */
222
+ export function toggleHeading(record, name) {
223
+ if (!HEADING_NAMES.includes(name)) refuse("Choose title or subtitle.");
224
+ const found = pathOfName(record, name);
225
+ if (found) return deleteNodes(record, [found]);
226
+ const next = clone(record);
227
+ if (name === "subtitle" && !hasHeading(next, "title")) {
228
+ const withTitle = toggleHeading(next, "title").record;
229
+ return toggleHeading(withTitle, "subtitle");
230
+ }
231
+ if (name === "title") {
232
+ if (next.direction !== "row") {
233
+ next.regions.unshift({ name: "title" });
234
+ if (next.regions.length > LAYOUT_MAX_ENTRIES) refuse(`A list holds at most ${LAYOUT_MAX_ENTRIES} regions and splits.`, "layout-entries");
235
+ return result(next, [[0]], record);
236
+ }
237
+ // A row: the title goes on top of the whole row when the row's regions can sit one level lower, else it joins the row.
238
+ if (next.regions.every((entry) => 2 + height(entry) <= LAYOUT_MAX_DEPTH)) {
239
+ next.direction = "column";
240
+ next.regions = [{ name: "title" }, { direction: "row", regions: next.regions }];
241
+ return result(next, [[0]], record);
242
+ }
243
+ if (next.regions.length >= LAYOUT_MAX_ENTRIES) refuse(`A list holds at most ${LAYOUT_MAX_ENTRIES} regions and splits.`, "layout-entries");
244
+ next.regions.unshift({ name: "title" });
245
+ return result(next, [[0]], record);
246
+ }
247
+ const titlePath = pathOfName(next, "title");
248
+ const list = listAt(next, titlePath.slice(0, -1));
249
+ if (list.length >= LAYOUT_MAX_ENTRIES) refuse(`A list holds at most ${LAYOUT_MAX_ENTRIES} regions and splits.`, "layout-entries");
250
+ const at = titlePath[titlePath.length - 1] + 1;
251
+ list.splice(at, 0, { name: "subtitle" });
252
+ return result(next, [[...titlePath.slice(0, -1), at]], record);
253
+ }
254
+
255
+ const sameParent = (paths) => paths.every((path) => samePath(path.slice(0, -1), paths[0].slice(0, -1)));
256
+ const sortedPaths = (paths) => [...paths].sort(compareLength);
257
+
258
+ /** Wrap the sibling nodes at `paths` in one new split of `axis`. */
259
+ export function wrapNodes(record, paths, axis) {
260
+ if (!LAYOUT_DIRECTIONS.includes(axis)) refuse("Choose row or column.");
261
+ if (!paths.length) refuse("Choose a region to wrap.");
262
+ if (!sameParent(paths)) refuse("Only siblings in the same list can be wrapped together.");
263
+ const next = clone(record);
264
+ const ordered = sortedPaths(paths);
265
+ const listPath = ordered[0].slice(0, -1), list = listAt(next, listPath), direction = listDirection(next, listPath);
266
+ const entries = ordered.map((path) => nodeAt(next, path));
267
+ if (entries.some((entry) => !entry)) refuse("Choose a region to wrap.");
268
+ if (listLevel(listPath) + 1 + Math.max(...entries.map(height)) > LAYOUT_MAX_DEPTH) refuse(`Regions nest at most ${LAYOUT_MAX_DEPTH} levels deep.`, "layout-depth");
269
+ const sizes = entries.map((entry) => effectiveSize(entry, direction));
270
+ const numeric = sizes.filter((size) => typeof size === "number");
271
+ const size = numeric.length ? Math.round(numeric.reduce((sum, value) => sum + value, 0) * 1000) / 1000 : "auto";
272
+ const first = ordered[0][ordered[0].length - 1];
273
+ const wrapper = { direction: axis, regions: entries.map((entry) => { const copy = clone(entry); if (axis !== direction) delete copy.size; return orderEntry(copy); }) };
274
+ if (size !== 1) wrapper.size = size;
275
+ stripAutoInRows({ direction: axis, regions: wrapper.regions });
276
+ for (const path of [...ordered].reverse()) list.splice(path[path.length - 1], 1);
277
+ list.splice(first, 0, orderEntry(wrapper));
278
+ return result(next, [[...listPath, first]], record);
279
+ }
280
+
281
+ /** Replace the split at `path` with its children, which keep their share of the split's length. */
282
+ export function unwrapNode(record, path) {
283
+ const next = clone(record);
284
+ const split = nodeAt(next, path);
285
+ if (!split) refuse("Choose a split to unwrap.");
286
+ if (!isSplit(split)) refuse("Only a split can be unwrapped.");
287
+ const listPath = path.slice(0, -1), index = path[path.length - 1];
288
+ const list = listAt(next, listPath), direction = listDirection(next, listPath);
289
+ const children = split.regions ?? [];
290
+ if (list.length - 1 + children.length > LAYOUT_MAX_ENTRIES) refuse(`A list holds at most ${LAYOUT_MAX_ENTRIES} regions and splits.`, "layout-entries");
291
+ const own = effectiveSize(split, direction), inner = split.direction === "row" ? "row" : "column";
292
+ const childSizes = children.map((child) => effectiveSize(child, inner));
293
+ const total = childSizes.filter((size) => typeof size === "number").reduce((sum, value) => sum + value, 0);
294
+ if (typeof own === "number" && total > 0) {
295
+ const factor = own / total;
296
+ children.forEach((child, at) => { if (typeof childSizes[at] === "number") writeSize(child, Math.round(childSizes[at] * factor * 1000) / 1000, direction); });
297
+ } else if (own === "auto" && children.length === 1) writeSize(children[0], "auto", direction);
298
+ pushAnchor(split, children);
299
+ list.splice(index, 1, ...children.map(orderEntry));
300
+ stripAutoInRows(next);
301
+ return result(next, children.map((_, at) => [...listPath, index + at]), record);
302
+ }
303
+
304
+ /** Where the node `target` is, as `{ list, index, listPath }` (searching by identity). */
305
+ function locate(record, target) {
306
+ const walk = (list, listPath) => {
307
+ for (let index = 0; index < list.length; index++) {
308
+ if (list[index] === target) return { list, index, listPath };
309
+ if (isSplit(list[index])) { const found = walk(list[index].regions ?? [], [...listPath, index]); if (found) return found; }
310
+ }
311
+ return undefined;
312
+ };
313
+ return walk(record.regions, []);
314
+ }
315
+ const pathOf = (record, target) => { const found = locate(record, target); return found ? [...found.listPath, found.index] : undefined; };
316
+
317
+ /**
318
+ * Remove an emptied split, and collapse a split left with one child into that child (which takes the split's size and the anchor it
319
+ * passed down). `exempt` is a list that must stay as it is (the list being inserted into).
320
+ */
321
+ function tidy(record, split, exempt) {
322
+ const found = locate(record, split);
323
+ if (!found || split === exempt) return;
324
+ const children = split.regions ?? [];
325
+ if (children.length === 0) {
326
+ found.list.splice(found.index, 1);
327
+ const parent = found.listPath.length ? nodeAt(record, found.listPath) : undefined;
328
+ if (parent) tidy(record, parent, exempt);
329
+ return;
330
+ }
331
+ if (children.length === 1) {
332
+ const only = children[0];
333
+ pushAnchor(split, [only]);
334
+ writeSize(only, effectiveSize(split, listDirection(record, found.listPath)), listDirection(record, found.listPath));
335
+ found.list[found.index] = orderInPlace(only);
336
+ stripAutoInRows(record);
337
+ }
338
+ }
339
+
340
+ /** Remove the nodes at `paths`. A split left with one child collapses into it; deleting a title removes the subtitle too. */
341
+ export function deleteNodes(record, paths) {
342
+ if (!paths.length) refuse("Choose a region to delete.");
343
+ const next = clone(record);
344
+ const targets = sortedPaths(paths).map((path) => nodeAt(next, path));
345
+ if (targets.some((entry) => !entry)) refuse("Choose a region to delete.");
346
+ const doomed = new Set(targets);
347
+ if (targets.some((entry) => nameSet(entry).includes("title"))) for (const { entry } of nodes(next)) if (entry.name === "subtitle") doomed.add(entry);
348
+ // A list keeps at least one entry at the top level.
349
+ const remaining = next.regions.filter((entry) => !doomed.has(entry));
350
+ if (!remaining.length) refuse("A layout keeps at least one region.", "layout-empty");
351
+ const parents = new Set();
352
+ let last;
353
+ for (const entry of doomed) {
354
+ const found = locate(next, entry);
355
+ if (!found) continue;
356
+ const parent = found.listPath.length ? nodeAt(next, found.listPath) : undefined;
357
+ if (parent) parents.add(parent);
358
+ found.list.splice(found.index, 1);
359
+ if (!last || compareLength([...found.listPath, found.index], last.at) > 0) last = { list: found.list, index: found.index, at: [...found.listPath, found.index], parent };
360
+ }
361
+ // What takes the place of the last node removed (its next sibling, else the one before, else its split) is selected afterwards.
362
+ const neighbor = last && (last.list[last.index] ?? last.list[last.index - 1] ?? last.parent);
363
+ for (const parent of parents) tidy(next, parent);
364
+ if (!next.regions.length) refuse("A layout keeps at least one region.", "layout-empty");
365
+ const doomedNames = new Set([...doomed].flatMap((entry) => nameSet(entry)));
366
+ if (doomedNames.has(next.overflowRegion)) delete next.overflowRegion;
367
+ stripAutoInRows(next);
368
+ const at = neighbor ? pathOf(next, neighbor) : undefined;
369
+ return result(next, at ? [at] : []);
370
+ }
371
+ function nameSet(entry) {
372
+ if (isSplit(entry)) return (entry.regions ?? []).flatMap(nameSet);
373
+ return [entry.name];
374
+ }
375
+
376
+ /** Copy the nodes at `paths` next to the originals. Names get `-2`, `-3`; heading regions are unique and cannot be copied. */
377
+ export function duplicateNodes(record, paths) {
378
+ if (!paths.length) refuse("Choose a region to duplicate.");
379
+ const next = clone(record);
380
+ const ordered = sortedPaths(paths);
381
+ const originals = ordered.map((path) => nodeAt(next, path));
382
+ if (originals.some((entry) => !entry)) refuse("Choose a region to duplicate.");
383
+ const used = new Set(regionNames(next));
384
+ const copies = originals.map((entry) => {
385
+ const copy = clone(entry);
386
+ const rename = (node) => {
387
+ if (isSplit(node)) { for (const child of node.regions ?? []) rename(child); return; }
388
+ if (isHeading(node)) refuse("The title and subtitle regions are unique, so they cannot be duplicated.");
389
+ node.name = copyName(node.name, used);
390
+ used.add(node.name);
391
+ };
392
+ rename(copy);
393
+ return copy;
394
+ });
395
+ const touched = new Map();
396
+ for (const entry of originals) { const found = locate(next, entry); touched.set(found.list, (touched.get(found.list) ?? 0) + 1); }
397
+ for (const [list, count] of touched) if (list.length + count > LAYOUT_MAX_ENTRIES) refuse(`A list holds at most ${LAYOUT_MAX_ENTRIES} regions and splits.`, "layout-entries");
398
+ const selection = [];
399
+ // Insert from the last to the first so earlier indexes stay valid.
400
+ for (let at = originals.length - 1; at >= 0; at--) {
401
+ const found = locate(next, originals[at]);
402
+ found.list.splice(found.index + 1, 0, copies[at]);
403
+ }
404
+ for (const copy of copies) selection.push(pathOf(next, copy));
405
+ return result(next, selection, record);
406
+ }
407
+
408
+ /** Move the nodes at `paths` into the list at `targetListPath` (`[]` is the record's), to position `index` of that list. */
409
+ export function moveNodes(record, paths, targetListPath, index) {
410
+ if (!paths.length) refuse("Choose a region to move.");
411
+ const next = clone(record);
412
+ const ordered = sortedPaths(paths);
413
+ const moved = ordered.map((path) => nodeAt(next, path));
414
+ if (moved.some((entry) => !entry)) refuse("Choose a region to move.");
415
+ const target = listAt(next, targetListPath);
416
+ if (!target) refuse("Choose a list to move into.");
417
+ if (paths.some((path) => isPrefix(path, targetListPath))) refuse("A split cannot move into itself.");
418
+ const level = listLevel(targetListPath);
419
+ if (moved.some((entry) => level + height(entry) > LAYOUT_MAX_DEPTH)) refuse(`Regions nest at most ${LAYOUT_MAX_DEPTH} levels deep.`, "layout-depth");
420
+ const targetSplit = targetListPath.length ? nodeAt(next, targetListPath) : undefined;
421
+ const incoming = moved.filter((entry) => !target.includes(entry)).length;
422
+ if (target.length + incoming > LAYOUT_MAX_ENTRIES) refuse(`A list holds at most ${LAYOUT_MAX_ENTRIES} regions and splits.`, "layout-entries");
423
+ // Where the entries come from, so emptied splits can be tidied afterwards.
424
+ const parents = new Set();
425
+ let before = 0;
426
+ for (const entry of moved) {
427
+ const found = locate(next, entry);
428
+ if (found.list === target && found.index < index) before++;
429
+ if (found.listPath.length) parents.add(nodeAt(next, found.listPath));
430
+ found.list.splice(found.index, 1);
431
+ }
432
+ const at = Math.max(0, Math.min(index - before, target.length));
433
+ target.splice(at, 0, ...moved);
434
+ for (const parent of parents) tidy(next, parent, targetSplit);
435
+ stripAutoInRows(next);
436
+ const selection = moved.map((entry) => pathOf(next, entry)).filter(Boolean);
437
+ const same = JSON.stringify(next) === JSON.stringify(record);
438
+ return same ? result(record, paths) : result(next, selection, record);
439
+ }
440
+
441
+ /**
442
+ * Move a node one place earlier (`delta` -1) or later (+1) among its siblings. At the end of a list inside a split it leaves the split,
443
+ * landing just before or after it.
444
+ */
445
+ export function reorderNode(record, path, delta) {
446
+ const listPath = path.slice(0, -1), index = path[path.length - 1];
447
+ const list = listAt(record, listPath);
448
+ if (!list || !list[index]) refuse("Choose a region to move.");
449
+ const to = index + delta;
450
+ if (to >= 0 && to < list.length) return moveNodes(record, [path], listPath, delta > 0 ? to + 1 : to);
451
+ if (!listPath.length) refuse(delta < 0 ? "Already first." : "Already last.", "layout-edge");
452
+ const outer = listPath.slice(0, -1), at = listPath[listPath.length - 1];
453
+ return moveNodes(record, [path], outer, delta < 0 ? at : at + 1);
454
+ }
455
+
456
+ /** Move a node into the split that is its next (`delta` +1) or previous (-1) sibling: to the start of its list, or the end. */
457
+ export function moveIntoSibling(record, path, delta) {
458
+ const listPath = path.slice(0, -1), index = path[path.length - 1];
459
+ const list = listAt(record, listPath);
460
+ const sibling = list?.[index + delta];
461
+ if (!sibling) refuse(delta < 0 ? "Already first." : "Already last.", "layout-edge");
462
+ if (!isSplit(sibling)) refuse("The next sibling is not a split.");
463
+ const siblingPath = [...listPath, index + delta];
464
+ return moveNodes(record, [path], siblingPath, delta > 0 ? 0 : countIn(sibling.regions));
465
+ }
466
+
467
+ // --- properties ------------------------------------------------------------------------------------------------------------
468
+
469
+ const REGION_ENUMS = {
470
+ role: ["primary", "secondary", "media", "supporting"],
471
+ flow: ["none", "grid", "column", "auto"],
472
+ anchor: LAYOUT_ANCHORS,
473
+ empty: ["collapse", "keep"],
474
+ listColumns: ["auto", 1],
475
+ };
476
+ export const parseSize = (value) => {
477
+ if (value === "auto") return "auto";
478
+ const number = typeof value === "number" ? value : Number(String(value).trim());
479
+ return Number.isFinite(number) && number > 0 && number <= 100 ? Math.round(number * 1000) / 1000 : undefined;
480
+ };
481
+
482
+ /**
483
+ * Set (or with `undefined`, remove) one property of the region or split at `path`: `name`, `size`, `direction` (a split), `anchor`, or a
484
+ * region rule (`accepts`, `role`, `flow`, `max`, `bleed`, `listColumns`, `empty`). A value that is not allowed is refused.
485
+ */
486
+ export function setProperty(record, path, key, value) {
487
+ const next = clone(record);
488
+ const node = nodeAt(next, path);
489
+ if (!node) refuse("Choose a region or split.");
490
+ const direction = parentDirection(next, path);
491
+ const split = isSplit(node);
492
+ if (key === "name") {
493
+ if (split) refuse("A split has no name.");
494
+ if (typeof value !== "string") refuse("A region name is text.");
495
+ if (isHeading(node) && value !== node.name) refuse("The title and subtitle regions keep their names. Remove the region to drop the heading.");
496
+ if (HEADING_NAMES.includes(value) && value !== node.name) refuse("Use the heading tool to add a title or subtitle region.");
497
+ const old = node.name;
498
+ node.name = value;
499
+ if (next.overflowRegion === old) next.overflowRegion = value;
500
+ } else if (key === "size") {
501
+ if (value === undefined) delete node.size;
502
+ else if (value === "auto") {
503
+ if (direction !== "column") refuse("Auto is available only in a column.", "layout-auto");
504
+ node.size = "auto";
505
+ } else {
506
+ const size = parseSize(value);
507
+ if (size === undefined) refuse("A size is a number above 0 and up to 100, or auto.", "layout-size");
508
+ writeSize(node, size, direction);
509
+ }
510
+ } else if (key === "direction") {
511
+ if (!split) refuse("Only a split has a direction.");
512
+ if (!LAYOUT_DIRECTIONS.includes(value)) refuse("Choose row or column.");
513
+ node.direction = value;
514
+ } else if (key === "anchor") {
515
+ if (isHeading(node)) refuse("A heading region does not take an anchor.");
516
+ if (value !== undefined && !LAYOUT_ANCHORS.includes(value)) refuse("Choose top, middle or bottom.");
517
+ if (value === undefined) delete node.anchor; else node.anchor = value;
518
+ } else if (split || isHeading(node)) {
519
+ refuse(split ? "A split has a direction, a size and an anchor." : `${node.name} is a heading region and takes only a size.`);
520
+ } else if (key === "accepts") {
521
+ const kinds = LAYOUT_REGION_KINDS.filter((kind) => (value ?? []).includes(kind));
522
+ if (kinds.length) node.accepts = kinds; else delete node.accepts;
523
+ } else if (key === "max") {
524
+ if (value === undefined || value === "") delete node.max;
525
+ else {
526
+ const number = Number(value);
527
+ if (!Number.isInteger(number) || number < 1 || number > 12) refuse("The most blocks a region holds is a whole number from 1 to 12.");
528
+ node.max = number;
529
+ }
530
+ } else if (key === "bleed") {
531
+ if (value) node.bleed = true; else delete node.bleed;
532
+ } else if (REGION_ENUMS[key]) {
533
+ if (value === undefined) delete node[key];
534
+ else if (!REGION_ENUMS[key].includes(value)) refuse(`Choose a valid ${key}.`);
535
+ else node[key] = value;
536
+ } else refuse(`${key} is not an editable property.`);
537
+ if (key === "direction" && value === "row") stripAutoInRows(next);
538
+ const holder = listAt(next, path.slice(0, -1));
539
+ replaceEntry(holder, path[path.length - 1], node);
540
+ return result(next, [path], key === "direction" ? record : undefined);
541
+ }
542
+
543
+ /** Set (or with `undefined`, remove) a property of the record itself: `id`, `name`, `summary`, `direction`, `anchor`, `overflowRegion`. */
544
+ export function setLayoutProperty(record, key, value) {
545
+ const next = clone(record);
546
+ if (key === "direction") {
547
+ if (!LAYOUT_DIRECTIONS.includes(value)) refuse("Choose row or column.");
548
+ next.direction = value;
549
+ if (value === "row") stripAutoInRows(next);
550
+ } else if (key === "anchor") {
551
+ if (value !== undefined && !LAYOUT_ANCHORS.includes(value)) refuse("Choose top, middle or bottom.");
552
+ if (value === undefined) delete next.anchor; else next.anchor = value;
553
+ } else if (key === "id" || key === "name" || key === "summary" || key === "overflowRegion") {
554
+ if (value === undefined || value === "") { if (key === "id" || key === "name") next[key] = ""; else delete next[key]; } else next[key] = String(value);
555
+ } else if (key === "design") {
556
+ if (value === undefined) delete next.design; else next.design = clone(value);
557
+ } else refuse(`${key} is not an editable property of the layout.`);
558
+ return result(orderRecord(next), null, key === "direction" ? record : undefined);
559
+ }
560
+
561
+ /** Remove one design hint (`design.<key>`) from the record. */
562
+ export function removeDesignHint(record, key) {
563
+ const next = clone(record);
564
+ if (isObject(next.design)) { delete next.design[key]; if (!Object.keys(next.design).length) delete next.design; }
565
+ return result(next, null);
566
+ }
567
+
568
+ // --- resizing --------------------------------------------------------------------------------------------------------------
569
+
570
+ const round3 = (value) => Math.round(value * 1000) / 1000;
571
+ /** The whole-number ratio p : q with p + q at most 12 that is nearest the fraction `f` (the smallest total wins a tie). */
572
+ function niceRatio(f) {
573
+ let best = { p: 1, q: 1, error: Infinity };
574
+ for (let total = 2; total <= 12; total++) for (let p = 1; p < total; p++) {
575
+ const error = Math.abs(p / total - f);
576
+ if (error < best.error - 1e-9) best = { p, q: total - p, error };
577
+ }
578
+ return best;
579
+ }
580
+ const trim = (value) => String(round3(value));
581
+ export const ratioLabel = (a, b) => `${trim(a)} : ${trim(b)}`;
582
+
583
+ /**
584
+ * The sizes siblings `index` and `index + 1` of the list at `listPath` take when the divider between them sits at `fraction` (0 to 1) of
585
+ * their joint length. With other siblings the pair keeps its joint size and snaps to whole numbers and halves; alone in the list it
586
+ * snaps to the nearest whole-number ratio (1 : 1, 2 : 1, 3 : 2 ...). `free` (Shift) skips snapping. Returns null for a pair that has
587
+ * an `auto` side, which is not a share.
588
+ */
589
+ export function resizeSizes(record, listPath, index, fraction, { free = false } = {}) {
590
+ const list = listAt(record, listPath);
591
+ const direction = listDirection(record, listPath);
592
+ if (!list?.[index] || !list[index + 1]) return null;
593
+ const a = effectiveSize(list[index], direction), b = effectiveSize(list[index + 1], direction);
594
+ if (typeof a !== "number" || typeof b !== "number") return null;
595
+ const others = list.reduce((sum, entry, at) => { if (at === index || at === index + 1) return sum; const size = effectiveSize(entry, direction); return sum + (typeof size === "number" ? size : 0); }, 0);
596
+ const f = Math.max(0, Math.min(1, fraction));
597
+ const total = a + b;
598
+ if (others > 0) {
599
+ const step = 0.5;
600
+ if (free) { const first = Math.max(0.01, Math.min(total - 0.01, round3(f * total))); return { a: first, b: round3(total - first), label: ratioLabel(first, round3(total - first)) }; }
601
+ if (total < 2 * step) return { a, b, label: ratioLabel(a, b) };
602
+ const first = Math.max(step, Math.min(total - step, Math.round(f * total / step) * step));
603
+ return { a: round3(first), b: round3(total - first), label: ratioLabel(first, total - first) };
604
+ }
605
+ if (free) { const first = Math.max(0.01, Math.min(0.99, round3(f))); return { a: first, b: round3(1 - first), label: ratioLabel(first, 1 - first) }; }
606
+ const { p, q } = niceRatio(f);
607
+ return { a: p, b: q, label: ratioLabel(p, q) };
608
+ }
609
+
610
+ /** Give siblings `index` and `index + 1` those sizes (a size of 1 in a body region is left out, as a record writes it). */
611
+ export function setPairSizes(record, listPath, index, a, b) {
612
+ const next = clone(record);
613
+ const list = listAt(next, listPath);
614
+ const direction = listDirection(next, listPath);
615
+ if (!list?.[index] || !list[index + 1]) refuse("Choose two siblings to resize.");
616
+ writeSize(list[index], round3(a), direction);
617
+ writeSize(list[index + 1], round3(b), direction);
618
+ list[index] = orderEntry(list[index]);
619
+ list[index + 1] = orderEntry(list[index + 1]);
620
+ return result(next, null);
621
+ }
622
+
623
+ /** Make siblings `index` and `index + 1` equal. With other siblings they keep their joint size; alone they become 1 : 1. */
624
+ export function equalizePair(record, listPath, index) {
625
+ const list = listAt(record, listPath);
626
+ const direction = listDirection(record, listPath);
627
+ if (!list?.[index] || !list[index + 1]) refuse("Choose two siblings to resize.");
628
+ const a = effectiveSize(list[index], direction), b = effectiveSize(list[index + 1], direction);
629
+ if (typeof a !== "number" || typeof b !== "number") refuse("A pair with an auto size has no ratio to equalize.", "layout-auto");
630
+ const others = list.some((entry, at) => at !== index && at !== index + 1 && typeof effectiveSize(entry, direction) === "number");
631
+ const half = others ? round3((a + b) / 2) : 1;
632
+ return setPairSizes(record, listPath, index, half, half);
633
+ }
634
+
635
+ /** The step a divider takes with an arrow key: `step` of the pair's joint size (0.5 or 1), keeping the sum. */
636
+ export function nudgePair(record, listPath, index, steps, big = false) {
637
+ const list = listAt(record, listPath);
638
+ const direction = listDirection(record, listPath);
639
+ if (!list?.[index] || !list[index + 1]) refuse("Choose two siblings to resize.");
640
+ const a = effectiveSize(list[index], direction), b = effectiveSize(list[index + 1], direction);
641
+ if (typeof a !== "number" || typeof b !== "number") refuse("A pair with an auto size cannot be resized.", "layout-auto");
642
+ const step = big ? 1 : 0.5;
643
+ const first = Math.max(step, Math.min(a + b - step, round3(a + steps * step)));
644
+ return setPairSizes(record, listPath, index, first, round3(a + b - first));
645
+ }
646
+
647
+ // --- geometry --------------------------------------------------------------------------------------------------------------
648
+
649
+ const AUTO_SHARE = 0.18;
650
+ const IMPLICIT_TITLE = 0.14;
651
+ /**
652
+ * Place the tree in a unit box. Returns `nodes` (a box per region and split, in reading order), `dividers` (the gap between two
653
+ * siblings, with the sizes it moves), `slots` (the places a region can be inserted) and `implicitTitle` (the top band the engine adds when
654
+ * there is no title region). `aspect` is the box's width over its height; `gap` is the gap as a fraction of its width (the defaults are
655
+ * the engine's content box and gap on a 1280 x 720 slide). An `auto` size
656
+ * takes a fixed share, since the real height depends on the content.
657
+ */
658
+ export function layoutBoxes(record, { aspect = 1164.8 / 604.8, gap = 24 / 1164.8, autoShare = AUTO_SHARE } = {}) {
659
+ const out = { nodes: [], dividers: [], slots: [], implicitTitle: null };
660
+ const gx = gap, gy = gap * aspect;
661
+ let root = { x: 0, y: 0, w: 1, h: 1 };
662
+ if (!hasHeading(record, "title")) {
663
+ out.implicitTitle = { x: 0, y: 0, w: 1, h: IMPLICIT_TITLE };
664
+ root = { x: 0, y: IMPLICIT_TITLE + gy, w: 1, h: 1 - IMPLICIT_TITLE - gy };
665
+ }
666
+ const place = (list, listPath, direction, box) => {
667
+ const entries = Array.isArray(list) ? list : [];
668
+ const row = direction === "row";
669
+ const length = row ? box.w : box.h, gutter = row ? gx : gy;
670
+ const available = Math.max(0, length - gutter * (entries.length - 1));
671
+ const sizes = entries.map((entry) => { const size = effectiveSize(entry, direction); return size === "auto" && row ? 1 : size; });
672
+ const autos = sizes.filter((size) => size === "auto").length;
673
+ const totalNumeric = sizes.reduce((sum, size) => sum + (typeof size === "number" ? size : 0), 0);
674
+ const autoLength = autos ? (totalNumeric > 0 ? Math.min(autoShare * length, available * 0.5 / autos) : available / autos) : 0;
675
+ const share = available - autoLength * autos;
676
+ let cursor = row ? box.x : box.y;
677
+ const boxes = entries.map((entry, index) => {
678
+ const size = sizes[index];
679
+ const span = size === "auto" ? autoLength : totalNumeric > 0 ? share * size / totalNumeric : 0;
680
+ const placed = row ? { x: cursor, y: box.y, w: span, h: box.h } : { x: box.x, y: cursor, w: box.w, h: span };
681
+ cursor += span + gutter;
682
+ return placed;
683
+ });
684
+ // Slots: before the first entry, in each gap, and after the last.
685
+ for (let at = 0; at <= entries.length; at++) {
686
+ const edge = at === 0 ? (row ? box.x : box.y) : at === entries.length ? (row ? box.x + box.w : box.y + box.h) : (row ? boxes[at - 1].x + boxes[at - 1].w + gutter / 2 : boxes[at - 1].y + boxes[at - 1].h + gutter / 2);
687
+ out.slots.push({ listPath, index: at, direction, ...(row ? { x: edge, y: box.y, w: 0, h: box.h } : { x: box.x, y: edge, w: box.w, h: 0 }), depth: listPath.length + 1 });
688
+ }
689
+ entries.forEach((entry, index) => {
690
+ const path = [...listPath, index];
691
+ const placed = boxes[index];
692
+ out.nodes.push({ path, entry, kind: isSplit(entry) ? "split" : "region", ...placed, depth: path.length, direction });
693
+ if (index < entries.length - 1) {
694
+ const next = boxes[index + 1];
695
+ const divider = row ? { x: placed.x + placed.w, y: box.y, w: next.x - placed.x - placed.w, h: box.h } : { x: box.x, y: placed.y + placed.h, w: box.w, h: next.y - placed.y - placed.h };
696
+ const pair = [sizes[index], sizes[index + 1]];
697
+ out.dividers.push({ listPath, index, direction, ...divider, draggable: pair.every((size) => typeof size === "number"), depth: path.length, pairStart: row ? placed.x : placed.y, pairLength: row ? next.x + next.w - placed.x : next.y + next.h - placed.y, a: sizes[index], b: sizes[index + 1] });
698
+ }
699
+ if (isSplit(entry)) place(entry.regions, path, entry.direction === "row" ? "row" : "column", placed);
700
+ });
701
+ };
702
+ place(record.regions, [], record.direction === "row" ? "row" : "column", root);
703
+ return out;
704
+ }