@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,77 @@
1
+ // The tool palette of the layout editor (RR-99): a vertical icon toolbar, as in Figma. It sets the model's active tool; the canvas acts on it.
2
+ import { createLayoutModel } from "./layout-model.js";
3
+ import { createDom, handleGlobalShortcut } from "./layout-editor-ui.js";
4
+
5
+ /** The tools, in palette order. `key` is the shortcut letter. */
6
+ export const LAYOUT_TOOL_DEFS = Object.freeze([
7
+ { id: "select", label: "Select", key: "V", icon: "MousePointer2", help: "Click a region to select it. Click again inside a split to select what is inside." },
8
+ { id: "split-row", label: "Split into a row", key: "R", icon: "Columns2", help: "Click a region to split it into two side by side." },
9
+ { id: "split-column", label: "Split into a column", key: "C", icon: "Rows2", help: "Click a region to split it into two, one above the other." },
10
+ { id: "insert", label: "Insert region", key: "I", icon: "BetweenHorizontalStart", help: "Click between regions, or at the edge of a split, to insert a region there." },
11
+ { id: "heading", label: "Heading regions", key: "T", icon: "Heading", help: "Add or remove the title and subtitle regions." },
12
+ { id: "hand", label: "Hand", key: "H", icon: "Hand", help: "Drag to pan the slide when it is zoomed. Hold Space for the same." },
13
+ ]);
14
+ export const LAYOUT_PREVIEW_DEF = Object.freeze({ id: "preview", label: "Preview", key: "P", icon: "Eye", help: "Show sample content in each region." });
15
+
16
+ /**
17
+ * Mount the tool palette in `container`. Options: `model` (shared state; one is created when omitted, with `record`, `density` and the
18
+ * other model options), `orientation` ("vertical" or "horizontal"), `label` (accessible name of the toolbar). Returns `{ element, model,
19
+ * refresh, destroy }`.
20
+ */
21
+ export function createLayoutToolPalette(container, options = {}) {
22
+ if (!container?.ownerDocument) throw new TypeError("A DOM container is required.");
23
+ const dom = createDom(container.ownerDocument);
24
+ const ownModel = !options.model;
25
+ const model = options.model ?? createLayoutModel(options);
26
+ const orientation = options.orientation === "horizontal" ? "horizontal" : "vertical";
27
+ const root = dom.el("div", "opf-lx-palette opf-ui", { role: "toolbar", "aria-orientation": orientation, "aria-label": options.label ?? "Layout tools", "data-density": model.density, "data-orientation": orientation });
28
+ const buttons = new Map();
29
+ const make = (def, toggle) => {
30
+ const node = dom.button("opf-lx-tool", { label: def.label, iconName: def.icon, size: 18, pressed: false, title: `${def.label} (${def.key})`, attributes: { "data-tool": def.id, "aria-keyshortcuts": def.key } });
31
+ node.addEventListener("click", () => { if (toggle) model.setPreview(!model.preview); else model.setTool(model.tool === def.id && def.id !== "select" ? "select" : def.id); });
32
+ buttons.set(def.id, node);
33
+ return node;
34
+ };
35
+ for (const def of LAYOUT_TOOL_DEFS) root.append(make(def, false));
36
+ root.append(dom.el("span", "opf-lx-tool-gap", { role: "separator", "aria-orientation": orientation === "vertical" ? "horizontal" : "vertical" }));
37
+ root.append(make(LAYOUT_PREVIEW_DEF, true));
38
+ container.append(root);
39
+
40
+ let rover = "select";
41
+ function paint() {
42
+ root.dataset.density = model.density;
43
+ for (const [id, node] of buttons) {
44
+ const on = id === "preview" ? model.preview : model.tool === id;
45
+ node.setAttribute("aria-pressed", String(on));
46
+ node.classList.toggle("is-active", on);
47
+ node.tabIndex = id === rover ? 0 : -1;
48
+ node.disabled = model.readOnly && id !== "hand" && id !== "preview" && id !== "select";
49
+ }
50
+ }
51
+ const unsubscribe = model.subscribe((event) => {
52
+ if (event.type === "tool" || event.type === "view" || event.type === "load") { if (event.type === "tool" && buttons.has(model.tool)) rover = model.tool; paint(); }
53
+ });
54
+ root.addEventListener("focusin", (event) => { const id = event.target.dataset?.tool; if (id && buttons.has(id)) { rover = id; for (const [key, node] of buttons) node.tabIndex = key === rover ? 0 : -1; } });
55
+ root.addEventListener("keydown", (event) => {
56
+ if (handleGlobalShortcut(event, model)) return;
57
+ const order = [...buttons.keys()].filter((id) => !buttons.get(id).disabled);
58
+ const at = order.indexOf(event.target.dataset?.tool);
59
+ // Both axes move along the toolbar: it is a column on a wide editor and a row on a narrow one.
60
+ let to = -1;
61
+ if (event.key === "ArrowDown" || event.key === "ArrowRight") to = (at + 1) % order.length;
62
+ else if (event.key === "ArrowUp" || event.key === "ArrowLeft") to = (at - 1 + order.length) % order.length;
63
+ else if (event.key === "Home") to = 0;
64
+ else if (event.key === "End") to = order.length - 1;
65
+ if (to < 0) return;
66
+ event.preventDefault();
67
+ buttons.get(order[to]).focus();
68
+ });
69
+ paint();
70
+
71
+ return {
72
+ element: root,
73
+ model,
74
+ refresh: paint,
75
+ destroy() { unsubscribe(); root.remove(); if (ownModel) model.destroy(); },
76
+ };
77
+ }
@@ -0,0 +1,84 @@
1
+ // Sample content for the layout editor's Preview (RR-99): a one-slide deck that uses the layout and fills each region with blocks of the
2
+ // kinds it accepts, so the host's renderer can draw the layout as it will look. The blocks are the ones the Gallery draws its layout
3
+ // previews with. Pure data; no DOM.
4
+ import { clone } from "./layout-tree.js";
5
+ import { LAYOUT_REGION_KINDS } from "./layout-templates.js";
6
+
7
+ const PICTURE_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 100" width="1600" height="1000"><defs><linearGradient id="s" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#7aa7e6"/><stop offset="1" stop-color="#cfe0f5"/></linearGradient></defs><rect width="160" height="100" fill="url(#s)"/><path d="M0 62 Q40 40 80 58 T160 50 V100 H0 Z" fill="#3f8a55"/><path d="M0 78 Q50 64 100 76 T160 72 V100 H0 Z" fill="#2f6b42"/></svg>';
8
+ const toBase64 = (text) => (typeof globalThis.btoa === "function" ? globalThis.btoa(text) : globalThis.Buffer.from(text).toString("base64"));
9
+ /** A landscape picture as an SVG data URI. */
10
+ export const SAMPLE_PICTURE = `data:image/svg+xml;base64,${toBase64(PICTURE_SVG)}`;
11
+
12
+ const pick = (values, index) => values[index % values.length];
13
+
14
+ /** The `index`-th sample block of a content kind; blocks of one region differ, so a preview reads as real content. */
15
+ export function sampleBlock(kind, index = 0) {
16
+ switch (kind) {
17
+ case "text":
18
+ return { text: pick(["Revenue grew 18% on new accounts while churn held flat.", "Two field teams piloted the new workflow for six weeks.", "Support tickets fell by a third after the redesign."], index) };
19
+ case "list":
20
+ return { items: pick([["Close the audit gaps", "Pilot with two teams", "Report every week"], ["Hire two engineers", "Ship the mobile beta", "Retire the old portal"], ["Agree the budget", "Name the owners", "Review in March"]], index) };
21
+ case "image":
22
+ return { image: { src: SAMPLE_PICTURE, alt: pick(["Field team at work", "The new office", "Product in use"], index) } };
23
+ case "video":
24
+ return { video: { src: SAMPLE_PICTURE, alt: pick(["Product walkthrough", "Customer story", "Launch film"], index) } };
25
+ case "chart":
26
+ return { chart: { type: pick(["column", "line", "bar"], index), data: { columns: ["Quarter", "Current", "Baseline"], rows: [["Q1", 26 + index, 23], ["Q2", 30, 25 + index], ["Q3", 33 - index, 27], ["Q4", 37, 29]] } } };
27
+ case "table":
28
+ return { table: { columns: ["Workstream", "Owner", "Status"], rows: [["Compliance", "Program lead", "On track"], ["Reporting", "Analytics", "Watch"], ["Adoption", "Operations", "At risk"]] } };
29
+ case "code":
30
+ return { code: { language: "javascript", source: pick(["const total = items.reduce((sum, item) => sum + item.value, 0);\nconsole.log(total);", "export function share(part, whole) {\n return whole ? part / whole : 0;\n}"], index) } };
31
+ case "metric":
32
+ return { metric: pick([{ value: "18%", label: "Net revenue growth", trend: "up" }, { value: "3.1M", label: "Active users", trend: "up" }, { value: "42", label: "Net promoter score", trend: "flat" }], index) };
33
+ case "quote":
34
+ return { quote: pick([{ text: "The plan makes the sequence of choices explicit.", attribution: "Program sponsor" }, { text: "We finally see the whole pipeline in one place.", attribution: "Sales lead" }], index) };
35
+ case "timeline":
36
+ return { timeline: { events: [{ when: "Q1", what: "Baseline" }, { when: "Q2", what: "Pilot" }, { when: "Q3", what: "Launch" }, { when: "Q4", what: "Scale" }] } };
37
+ case "group":
38
+ return { blocks: [sampleBlock("image", index), { text: pick(["Ana Ruiz, Chief Executive", "Sam Lee, Head of Product", "Kai Moreau, Engineering Lead"], index) }] };
39
+ default:
40
+ return sampleBlock("text", index);
41
+ }
42
+ }
43
+
44
+ /** The body regions of a record in reading order, as `{ name, region }`. */
45
+ export function bodyRegions(record) {
46
+ const out = [];
47
+ const walk = (list) => {
48
+ for (const entry of Array.isArray(list) ? list : []) {
49
+ if (entry && Array.isArray(entry.regions) && entry.name === undefined) walk(entry.regions);
50
+ else if (entry && typeof entry.name === "string" && entry.name !== "title" && entry.name !== "subtitle") out.push({ name: entry.name, region: entry });
51
+ }
52
+ };
53
+ walk(record?.regions);
54
+ return out;
55
+ }
56
+
57
+ /** How many sample blocks a region gets: one for `flow: "none"`, else up to three (never more than its `max`). */
58
+ export const regionSampleCount = (region) => (region?.flow === "none" ? 1 : Math.min(3, region?.max ?? 6));
59
+ /** The kinds a region's samples cycle through: its `accepts`, or text, list and image when it accepts everything. */
60
+ export const regionSampleKinds = (region) => (Array.isArray(region?.accepts) && region.accepts.length ? region.accepts.filter((kind) => LAYOUT_REGION_KINDS.includes(kind)) : ["text", "list", "image"]);
61
+
62
+ /**
63
+ * A one-slide deck that uses `record` (embedded as a custom layout) with sample content pinned to every body region. The slide has a
64
+ * title and a subtitle, so the heading regions show as well.
65
+ */
66
+ export function sampleDeck(record) {
67
+ const { $schema, id, ...layout } = clone(record);
68
+ void $schema;
69
+ const key = typeof id === "string" && /^[a-z][a-z0-9-]*$/.test(id) && id !== "auto" ? id : "layout-preview";
70
+ const blocks = [];
71
+ const used = {};
72
+ for (const { name, region } of bodyRegions(record)) {
73
+ const kinds = regionSampleKinds(region);
74
+ for (let count = 0; count < regionSampleCount(region); count++) {
75
+ const kind = kinds[count % kinds.length] ?? "text";
76
+ const index = used[kind] ?? 0;
77
+ used[kind] = index + 1;
78
+ blocks.push({ region: name, ...sampleBlock(kind, index) });
79
+ }
80
+ }
81
+ const slide = { layout: key, title: "Quarterly operating plan", subtitle: "Review for the leadership team" };
82
+ if (blocks.length) slide.blocks = blocks;
83
+ return { name: "Layout preview", catalogs: { custom: { layouts: { [key]: layout } } }, slides: [slide] };
84
+ }
@@ -7,18 +7,42 @@ export declare const LAYOUT_REGION_KINDS: readonly string[];
7
7
  export declare const LAYOUT_REGION_ROLES: readonly string[];
8
8
  export declare const LAYOUT_REGION_FLOWS: readonly string[];
9
9
  export declare function groupLayoutChoices<T extends LayoutChoice>(choices: T[]): { name: string; choices: T[] }[];
10
- export interface GridLayoutInput {
10
+ export declare const LAYOUT_DIRECTIONS: readonly ("row" | "column")[];
11
+ export declare const LAYOUT_ANCHORS: readonly ("top" | "middle" | "bottom")[];
12
+ /** Region lists nest at most this deep: the record's own list is level 1, a split's list level 2, a split inside it level 3. */
13
+ export declare const LAYOUT_MAX_DEPTH: number;
14
+ /** A list of regions (the record's, or a split's) holds at most this many entries. */
15
+ export declare const LAYOUT_MAX_ENTRIES: number;
16
+ export type LayoutSize = number | "auto";
17
+ /** The rules of a body region; every one is optional (omitted `accepts`: every body content kind and `group`). */
18
+ export interface LayoutRegionRules {
19
+ accepts?: string[];
20
+ role?: string;
21
+ flow?: string;
22
+ max?: number;
23
+ bleed?: boolean;
24
+ listColumns?: "auto" | 1;
25
+ anchor?: "top" | "middle" | "bottom";
26
+ empty?: "collapse" | "keep";
27
+ }
28
+ /** A region (`name`, `size`, rules); `title` and `subtitle` are heading regions that take only a size. */
29
+ export interface LayoutRegionEntry extends LayoutRegionRules { name: string; size?: LayoutSize }
30
+ /** A split: its own `regions`, laid out in `direction`. */
31
+ export interface LayoutSplitEntry { direction: "row" | "column"; regions: (LayoutRegionEntry | LayoutSplitEntry)[]; size?: LayoutSize; anchor?: "top" | "middle" | "bottom" }
32
+ export interface RegionLayoutInput {
11
33
  id: string;
12
34
  name: string;
13
35
  summary?: string;
14
- cells: string[][];
15
- regions: Record<string, { accepts: string[]; role?: string; flow?: string }>;
16
- columns?: number[];
17
- rows?: (number | "auto")[];
36
+ /** How the record's `regions` are placed: side by side (`row`) or stacked (`column`, the default). */
37
+ direction?: "row" | "column";
38
+ /** Applies to every region under the record unless a split or a region sets its own. */
39
+ anchor?: "top" | "middle" | "bottom";
40
+ regions: (LayoutRegionEntry | LayoutSplitEntry)[];
41
+ overflowRegion?: string;
18
42
  }
19
- export declare function layoutFromGrid(input: GridLayoutInput): { id: string; record: Record<string, unknown> };
20
- export declare function prepareCustomLayout(presentation: any, layout: { id: string; record: Record<string, unknown> }): { presentation: any; patches: JsonPatchOperation[]; id: string };
21
- export declare function saveCustomLayout(editor: EditorSession, layout: { id: string; record: Record<string, unknown> }): EditorChange;
43
+ export declare function layoutFromRegions(input: RegionLayoutInput): { id: string; record: Record<string, unknown> };
44
+ export declare function prepareCustomLayout(presentation: any, layout: { id: string; record: Record<string, unknown> }, options?: { replace?: boolean }): { presentation: any; patches: JsonPatchOperation[]; id: string };
45
+ export declare function saveCustomLayout(editor: EditorSession, layout: { id: string; record: Record<string, unknown> }, options?: { replace?: boolean }): EditorChange;
22
46
  export declare function pinBlockToRegion(editor: EditorSession, slideIndex: number, blockIndex: number, region: string): EditorChange;
23
47
 
24
48
  export declare function layoutRegionSummary(record: unknown): string;
@@ -1,6 +1,6 @@
1
- // OPF 0.19 layout authoring. The catalog owns records; this module owns only editor operations.
1
+ // OPF layout authoring (FA-33: a record is one ordered list of regions, nested for 2D). The catalog owns records; this module owns only editor operations.
2
2
  import { resolveSlideContext } from "@openpresentation/opf";
3
- import { bindRegions, layoutStructure, layoutTemplate } from "@openpresentation/opf/composition";
3
+ import { bindRegions, isLayoutTemplate, layoutStructure, layoutTemplate } from "@openpresentation/opf/composition";
4
4
  import { applyJsonPatch, createValuePatch, opfPathToJsonPointer } from "./index.js";
5
5
  import { checkedDocument, fail } from "./edit-helpers.js";
6
6
  import { checkFormat } from "./checks.js";
@@ -32,45 +32,97 @@ export function groupLayoutChoices(choices) {
32
32
  return groups;
33
33
  }
34
34
 
35
- /** Build the exact v2 record from a rectangular painted grid. `cells` is a row-major array of names or `.`. */
36
- export function layoutFromGrid({ id, name, summary, cells, regions, columns, rows }) {
37
- if (!NAME.test(id) || id === "auto") throw fail("layout-id", "Choose a kebab-case layout id other than auto.");
38
- if (typeof name !== "string" || !name.trim()) throw fail("layout-name", "Name the custom layout.");
39
- if (!Array.isArray(cells) || cells.length < 1 || cells.length > 12 || !cells.every((row) => Array.isArray(row) && row.length === cells[0].length && row.length >= 1 && row.length <= 12))
40
- throw fail("layout-grid", "Draw a rectangular grid of at most 12 by 12 cells.");
41
- const areas = cells.map((row) => [...row]);
42
- const names = new Set(cells.flat().filter((cell) => cell !== "."));
43
- for (const area of names) if (!NAME.test(area) || (RESERVED.has(area) && area !== "title" && area !== "subtitle")) throw fail("layout-area", `Invalid area name: ${area}.`);
44
- if (names.has("subtitle") && !names.has("title")) throw fail("layout-area", "A subtitle area needs a title area.");
45
- const body = [...names].filter((area) => area !== "title" && area !== "subtitle");
46
- for (const area of body) {
47
- const points = cells.flatMap((row, y) => row.map((cell, x) => cell === area ? [x, y] : null).filter(Boolean));
48
- const xs = points.map(([x]) => x), ys = points.map(([, y]) => y);
49
- if (points.length !== (Math.max(...xs) - Math.min(...xs) + 1) * (Math.max(...ys) - Math.min(...ys) + 1))
50
- throw fail("layout-area", `Area ${area} must fill one rectangle.`);
35
+ /** Region lists nest at most this deep: the record's own list is level 1, a split's list level 2, a split inside it level 3. */
36
+ export const LAYOUT_MAX_DEPTH = 3;
37
+ /** A list of regions (the record's, or a split's) holds at most this many entries. */
38
+ export const LAYOUT_MAX_ENTRIES = 12;
39
+ export const LAYOUT_DIRECTIONS = Object.freeze(["row", "column"]);
40
+ export const LAYOUT_ANCHORS = Object.freeze(["top", "middle", "bottom"]);
41
+ const HEADINGS = new Set(["title", "subtitle"]);
42
+
43
+ const validSize = (size) => size === "auto" || (typeof size === "number" && Number.isFinite(size) && size > 0 && size <= 100);
44
+
45
+ /** One region's rules, checked and in the record's key order; `accepts` is optional (omitted: every body kind and group). */
46
+ function regionRules(entry, name) {
47
+ const out = {};
48
+ if (entry.accepts !== undefined) {
49
+ if (!Array.isArray(entry.accepts) || !entry.accepts.length || entry.accepts.some((kind) => !LAYOUT_REGION_KINDS.includes(kind)))
50
+ throw fail("layout-region", `Choose accepted content kinds for ${name}.`);
51
+ out.accepts = [...new Set(entry.accepts)];
51
52
  }
52
- if (body.length < 1) throw fail("layout-region", "Draw at least one content region.");
53
- const selected = {};
54
- for (const area of body) {
55
- const input = regions?.[area];
56
- if (!input || !Array.isArray(input.accepts) || !input.accepts.length || input.accepts.some((kind) => !LAYOUT_REGION_KINDS.includes(kind)))
57
- throw fail("layout-region", `Choose accepted content kinds for ${area}.`);
58
- if (input.role && !LAYOUT_REGION_ROLES.includes(input.role)) throw fail("layout-region", `Choose a valid role for ${area}.`);
59
- if (input.flow && !LAYOUT_REGION_FLOWS.includes(input.flow)) throw fail("layout-region", `Choose a valid flow for ${area}.`);
60
- selected[area] = { accepts: [...new Set(input.accepts)], ...(input.role ? { role: input.role } : {}), ...(input.flow ? { flow: input.flow } : {}) };
53
+ if (entry.role !== undefined) { if (!LAYOUT_REGION_ROLES.includes(entry.role)) throw fail("layout-region", `Choose a valid role for ${name}.`); out.role = entry.role; }
54
+ if (entry.flow !== undefined) { if (!LAYOUT_REGION_FLOWS.includes(entry.flow)) throw fail("layout-region", `Choose a valid flow for ${name}.`); out.flow = entry.flow; }
55
+ if (entry.max !== undefined) {
56
+ if (!Number.isInteger(entry.max) || entry.max < 1 || entry.max > 12) throw fail("layout-region", `The most blocks ${name} holds is a whole number from 1 to 12.`);
57
+ out.max = entry.max;
61
58
  }
62
- const record = { name: name.trim(), areas, regions: selected };
59
+ if (entry.bleed !== undefined) { if (typeof entry.bleed !== "boolean") throw fail("layout-region", `Bleed for ${name} is true or false.`); out.bleed = entry.bleed; }
60
+ if (entry.listColumns !== undefined) { if (entry.listColumns !== "auto" && entry.listColumns !== 1) throw fail("layout-region", `List columns for ${name} is auto or 1.`); out.listColumns = entry.listColumns; }
61
+ if (entry.anchor !== undefined) { if (!LAYOUT_ANCHORS.includes(entry.anchor)) throw fail("layout-region", `Choose a valid anchor for ${name}.`); out.anchor = entry.anchor; }
62
+ if (entry.empty !== undefined) { if (entry.empty !== "collapse" && entry.empty !== "keep") throw fail("layout-region", `Empty for ${name} is collapse or keep.`); out.empty = entry.empty; }
63
+ return out;
64
+ }
65
+
66
+ /**
67
+ * Build the exact record from one ordered list of regions (OPF layout list form). `direction` ("row" or "column", default
68
+ * "column") lays the record's `regions` out; an entry is a region (`{ name, size?, ...rules }`) or a split (`{ direction,
69
+ * regions, size?, anchor? }`) that lays its own regions out in its own direction. Names are unique across the tree, `title`
70
+ * and `subtitle` are heading regions that take only a size, and `size` is a number above 0 up to 100 or `"auto"` (a child
71
+ * of a column only). The list is the reading order.
72
+ */
73
+ export function layoutFromRegions({ id, name, summary, direction = "column", anchor, regions, overflowRegion }) {
74
+ if (!NAME.test(id) || id === "auto") throw fail("layout-id", "Choose a kebab-case layout id other than auto.");
75
+ if (typeof name !== "string" || !name.trim()) throw fail("layout-name", "Name the custom layout.");
76
+ if (!LAYOUT_DIRECTIONS.includes(direction)) throw fail("layout-direction", "Choose row or column for the direction.");
77
+ if (anchor !== undefined && !LAYOUT_ANCHORS.includes(anchor)) throw fail("layout-anchor", "Choose top, middle or bottom for the anchor.");
78
+ const names = new Set();
79
+ let body = 0;
80
+ const walk = (list, parent, depth) => {
81
+ if (!Array.isArray(list) || list.length < 1 || list.length > LAYOUT_MAX_ENTRIES)
82
+ throw fail("layout-regions", `A list holds 1 to ${LAYOUT_MAX_ENTRIES} regions and splits.`);
83
+ return list.map((entry) => {
84
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) throw fail("layout-regions", "Each entry is a region or a split.");
85
+ const split = entry.name === undefined;
86
+ if (entry.size !== undefined && !validSize(entry.size)) throw fail("layout-size", `A size is a number above 0 up to 100, or auto${entry.name ? ` (${entry.name})` : ""}.`);
87
+ if (entry.size === "auto" && parent !== "column") throw fail("layout-size", `A size of auto is allowed only in a column${entry.name ? ` (${entry.name})` : ""}.`);
88
+ const size = entry.size === undefined ? {} : { size: entry.size };
89
+ if (split) {
90
+ if (!LAYOUT_DIRECTIONS.includes(entry.direction)) throw fail("layout-direction", "Choose row or column for each split.");
91
+ if (entry.anchor !== undefined && !LAYOUT_ANCHORS.includes(entry.anchor)) throw fail("layout-anchor", "Choose top, middle or bottom for the anchor.");
92
+ if (depth >= LAYOUT_MAX_DEPTH) throw fail("layout-depth", `Regions nest at most ${LAYOUT_MAX_DEPTH} levels deep.`);
93
+ return { direction: entry.direction, ...size, ...(entry.anchor ? { anchor: entry.anchor } : {}), regions: walk(entry.regions, entry.direction, depth + 1) };
94
+ }
95
+ if (typeof entry.name !== "string" || !NAME.test(entry.name)) throw fail("layout-area", `Invalid region name: ${entry.name}.`);
96
+ if (RESERVED.has(entry.name) && !HEADINGS.has(entry.name)) throw fail("layout-area", `Invalid region name: ${entry.name} is reserved.`);
97
+ if (names.has(entry.name)) throw fail("layout-area", `Region names are unique; ${entry.name} is used twice.`);
98
+ names.add(entry.name);
99
+ if (HEADINGS.has(entry.name)) {
100
+ const extra = Object.keys(entry).find((key) => key !== "name" && key !== "size");
101
+ if (extra) throw fail("layout-region", `${entry.name} is a heading region and takes only a size.`);
102
+ return { name: entry.name, ...size };
103
+ }
104
+ body++;
105
+ return { name: entry.name, ...size, ...regionRules(entry, entry.name) };
106
+ });
107
+ };
108
+ const list = walk(regions, direction, 1);
109
+ if (names.has("subtitle") && !names.has("title")) throw fail("layout-area", "A subtitle region needs a title region.");
110
+ if (body < 1) throw fail("layout-region", "Add at least one content region.");
111
+ if (overflowRegion !== undefined && (!names.has(overflowRegion) || HEADINGS.has(overflowRegion))) throw fail("layout-region", `${overflowRegion} is not a content region of this layout.`);
112
+ const record = { name: name.trim() };
63
113
  if (summary?.trim()) record.summary = summary.trim();
64
- if (columns) record.columns = columns;
65
- if (rows) record.rows = rows;
114
+ record.direction = direction;
115
+ if (anchor) record.anchor = anchor;
116
+ record.regions = list;
117
+ if (overflowRegion !== undefined) record.overflowRegion = overflowRegion;
66
118
  return { id, record };
67
119
  }
68
120
 
69
- /** Prepare one validated catalog edit. Existing custom ids are never silently overwritten. */
70
- export function prepareCustomLayout(presentation, layout) {
121
+ /** Prepare one validated catalog edit. Existing custom ids are never silently overwritten, unless the caller says `replace: true` (editing a layout the deck already has). */
122
+ export function prepareCustomLayout(presentation, layout, options = {}) {
71
123
  const { id, record } = layout;
72
124
  if (!NAME.test(id) || id === "auto") throw fail("layout-id", "Choose a kebab-case layout id other than auto.");
73
- if (presentation.catalogs?.custom?.layouts?.[id]) throw fail("layout-exists", `A custom layout named ${id} already exists.`);
125
+ if (presentation.catalogs?.custom?.layouts?.[id] && !options.replace) throw fail("layout-exists", `A custom layout named ${id} already exists.`);
74
126
  const custom = presentation.catalogs?.custom ?? {};
75
127
  const next = { ...presentation, catalogs: { ...presentation.catalogs, custom: { ...custom, layouts: { ...custom.layouts, [id]: record } } } };
76
128
  const patches = [{ op: "replace", path: "", value: next }];
@@ -78,9 +130,9 @@ export function prepareCustomLayout(presentation, layout) {
78
130
  return { presentation: next, patches, id };
79
131
  }
80
132
 
81
- export function saveCustomLayout(editor, layout) {
82
- const prepared = prepareCustomLayout(editor.presentation, layout);
83
- return editor.applyPatch(prepared.patches, { source: "layout-grid", rejectInvalid: true });
133
+ export function saveCustomLayout(editor, layout, options = {}) {
134
+ const prepared = prepareCustomLayout(editor.presentation, layout, options);
135
+ return editor.applyPatch(prepared.patches, { source: "layout-regions", rejectInvalid: true });
84
136
  }
85
137
 
86
138
  /** Pin a top-level block to a template region; the session validates the complete document. */
@@ -101,6 +153,6 @@ export function pinBlockToRegion(editor, slideIndex, blockIndex, region) {
101
153
 
102
154
  /** Region structure of a supported v2 template. */
103
155
  export function layoutRegionSummary(record) {
104
- if (!Array.isArray(record?.areas)) return "";
156
+ if (!isLayoutTemplate(record)) return "";
105
157
  try { layoutTemplate(record); return layoutStructure(record); } catch { return "Invalid region template"; }
106
158
  }
@@ -0,0 +1,87 @@
1
+ import type { LayoutRegionEntry, LayoutSplitEntry } from "./layout-templates.js";
2
+
3
+ /** A layout record as the editor edits it: `id`, `name`, `direction`, `anchor`, the ordered `regions` list, and whatever else the record carries. */
4
+ export interface LayoutRecord {
5
+ $schema?: string;
6
+ id?: string;
7
+ name?: string;
8
+ summary?: string;
9
+ direction?: "row" | "column";
10
+ anchor?: "top" | "middle" | "bottom";
11
+ regions: (LayoutRegionEntry | LayoutSplitEntry)[];
12
+ overflowRegion?: string;
13
+ design?: Record<string, unknown>;
14
+ composition?: Record<string, unknown>;
15
+ [key: string]: unknown;
16
+ }
17
+ /** A region or split. */
18
+ export type LayoutNode = LayoutRegionEntry | LayoutSplitEntry;
19
+ /** Indexes from the record's own list down: `[]` is the record, `[1, 0]` is `regions[1].regions[0]`. */
20
+ export type LayoutPath = number[];
21
+ /** The result of an operation: a new record, and the paths to select afterwards (`null`: keep the selection). */
22
+ export interface LayoutEdit { record: LayoutRecord; selection: LayoutPath[] | null }
23
+
24
+ /** Thrown by an operation that refuses an edit; `message` says why. The record is never changed. */
25
+ export declare class LayoutOperationError extends Error { code: string }
26
+ export declare const HEADING_NAMES: readonly ["title", "subtitle"];
27
+ export declare const REGION_KEYS: readonly string[];
28
+
29
+ export declare const isSplit: (entry: unknown) => entry is LayoutSplitEntry;
30
+ export declare const isRegion: (entry: unknown) => entry is LayoutRegionEntry;
31
+ export declare const isHeading: (entry: unknown) => boolean;
32
+ export declare function clone<T>(value: T): T;
33
+ export declare function samePath(a: readonly number[], b: readonly number[]): boolean;
34
+ export declare function isPrefix(prefix: readonly number[], path: readonly number[]): boolean;
35
+ export declare function pathKey(path: readonly number[]): string;
36
+ export declare function listAt(record: LayoutRecord, path: readonly number[]): LayoutNode[] | undefined;
37
+ export declare function nodeAt(record: LayoutRecord, path: readonly number[]): LayoutNode | undefined;
38
+ /** How the list that holds the node at `path` lays its entries out. */
39
+ export declare function parentDirection(record: LayoutRecord, path: readonly number[]): "row" | "column";
40
+ export declare function listDirection(record: LayoutRecord, listPath: readonly number[]): "row" | "column";
41
+ /** The levels a node adds below its own list: 0 for a region, 1 plus the deepest child for a split. */
42
+ export declare function height(entry: LayoutNode): number;
43
+ export declare function nodes(record: LayoutRecord): { entry: LayoutNode; path: LayoutPath; listPath: LayoutPath; index: number; depth: number }[];
44
+ export declare function regionNames(record: LayoutRecord): string[];
45
+ export declare function pathOfName(record: LayoutRecord, name: string): LayoutPath | undefined;
46
+ /** The size an entry has: its own, else `auto` for a heading in a column, else 1. */
47
+ export declare function effectiveSize(entry: LayoutNode, direction: "row" | "column"): number | "auto";
48
+ export declare function parseSize(value: unknown): number | "auto" | undefined;
49
+ /** The anchor a node inherits from a split above it or the record (never its own). */
50
+ export declare function inheritedAnchor(record: LayoutRecord, path: readonly number[]): { value: "top" | "middle" | "bottom" | undefined; from: "split" | "layout" | "default" };
51
+
52
+ /** Split the region at `path` along `axis`; the original keeps its name and rules, the new region is `region-N`. */
53
+ export declare function splitRegion(record: LayoutRecord, path: readonly number[], axis: "row" | "column"): LayoutEdit;
54
+ /** Insert a new region into the list at `listPath`, before `index`. */
55
+ export declare function insertRegion(record: LayoutRecord, listPath: readonly number[], index: number, entry?: LayoutNode): LayoutEdit;
56
+ /** Add or remove the `title` or `subtitle` heading region. Removing a title removes the subtitle too. */
57
+ export declare function toggleHeading(record: LayoutRecord, name: "title" | "subtitle"): LayoutEdit;
58
+ /** Wrap sibling nodes in one new split. */
59
+ export declare function wrapNodes(record: LayoutRecord, paths: readonly (readonly number[])[], axis: "row" | "column"): LayoutEdit;
60
+ /** Replace a split with its children. */
61
+ export declare function unwrapNode(record: LayoutRecord, path: readonly number[]): LayoutEdit;
62
+ /** Remove nodes; a split left with one child collapses into it. */
63
+ export declare function deleteNodes(record: LayoutRecord, paths: readonly (readonly number[])[]): LayoutEdit;
64
+ export declare function duplicateNodes(record: LayoutRecord, paths: readonly (readonly number[])[]): LayoutEdit;
65
+ /** Move nodes into the list at `targetListPath`, to position `index` of that list. */
66
+ export declare function moveNodes(record: LayoutRecord, paths: readonly (readonly number[])[], targetListPath: readonly number[], index: number): LayoutEdit;
67
+ /** Move a node one place earlier (-1) or later (1); at the end of a split it leaves the split. */
68
+ export declare function reorderNode(record: LayoutRecord, path: readonly number[], delta: -1 | 1): LayoutEdit;
69
+ /** Move a node into the neighboring split. */
70
+ export declare function moveIntoSibling(record: LayoutRecord, path: readonly number[], delta: -1 | 1): LayoutEdit;
71
+ /** Set or (with `undefined`) remove a property of the region or split at `path`. */
72
+ export declare function setProperty(record: LayoutRecord, path: readonly number[], key: string, value: unknown): LayoutEdit;
73
+ export declare function setLayoutProperty(record: LayoutRecord, key: "id" | "name" | "summary" | "direction" | "anchor" | "overflowRegion" | "design", value: unknown): LayoutEdit;
74
+ export declare function removeDesignHint(record: LayoutRecord, key: string): LayoutEdit;
75
+ /** The sizes of siblings `index` and `index + 1` when their divider sits at `fraction` of their joint length. Null for a pair with an `auto` side. */
76
+ export declare function resizeSizes(record: LayoutRecord, listPath: readonly number[], index: number, fraction: number, options?: { free?: boolean }): { a: number; b: number; label: string } | null;
77
+ export declare function setPairSizes(record: LayoutRecord, listPath: readonly number[], index: number, a: number, b: number): LayoutEdit;
78
+ export declare function equalizePair(record: LayoutRecord, listPath: readonly number[], index: number): LayoutEdit;
79
+ export declare function nudgePair(record: LayoutRecord, listPath: readonly number[], index: number, steps: number, big?: boolean): LayoutEdit;
80
+ export declare function ratioLabel(a: number, b: number): string;
81
+
82
+ export interface LayoutBox { x: number; y: number; w: number; h: number }
83
+ export interface LayoutNodeBox extends LayoutBox { path: LayoutPath; entry: LayoutNode; kind: "region" | "split"; depth: number; direction: "row" | "column" }
84
+ export interface LayoutDividerBox extends LayoutBox { listPath: LayoutPath; index: number; direction: "row" | "column"; draggable: boolean; depth: number; pairStart: number; pairLength: number; a: number | "auto"; b: number | "auto" }
85
+ export interface LayoutSlotBox extends LayoutBox { listPath: LayoutPath; index: number; direction: "row" | "column"; depth: number }
86
+ /** Place the tree in a unit box: a box per region and split, the dividers between siblings, the places a region can be inserted, and the band of the implicit title. */
87
+ export declare function layoutBoxes(record: LayoutRecord, options?: { aspect?: number; gap?: number; autoShare?: number }): { nodes: LayoutNodeBox[]; dividers: LayoutDividerBox[]; slots: LayoutSlotBox[]; implicitTitle: LayoutBox | null };