@openpresentation/opf-editor 0.24.3 → 0.25.1

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,25 @@
1
+ // A nested-box diagram of a layout record (FA-33): one box per region in reading order, a split as a flex row or column, and each size
2
+ // as the flex weight of its box. Used by the layout picker and the custom layout editor; it needs a DOM document, importing needs none.
3
+
4
+ /** Append the boxes of one region list to `box`: a region is a `span` named by the region, a split a nested `div`. */
5
+ function fill(doc, box, regions, direction) {
6
+ box.style.flexDirection = direction;
7
+ for (const entry of Array.isArray(regions) ? regions : []) {
8
+ if (entry === null || typeof entry !== "object") continue;
9
+ const split = entry.name === undefined;
10
+ const child = doc.createElement(split ? "div" : "span");
11
+ const size = entry.size ?? (!split && (entry.name === "title" || entry.name === "subtitle") && direction === "column" ? "auto" : 1);
12
+ child.style.flex = size === "auto" ? "0 0 auto" : `${Number.isFinite(size) ? size : 1} 1 0`;
13
+ if (split) { child.className = "opf-layout-split"; fill(doc, child, entry.regions, entry.direction === "row" ? "row" : "column"); }
14
+ else { child.textContent = entry.name; child.dataset.region = entry.name; }
15
+ box.append(child);
16
+ }
17
+ }
18
+
19
+ /** The diagram of `record` (its `direction` and `regions`); a `div.opf-layout-diagram` holding the region boxes. */
20
+ export function buildLayoutDiagram(doc, record) {
21
+ const diagram = doc.createElement("div");
22
+ diagram.className = "opf-layout-diagram";
23
+ fill(doc, diagram, record?.regions, record?.direction === "row" ? "row" : "column");
24
+ return diagram;
25
+ }
@@ -0,0 +1,232 @@
1
+ // Shared pieces of the layout editor's parts (RR-99): DOM helpers, the inline icons (Lucide, ISC license), the menu used by the context
2
+ // menu and the heading tool, and the keyboard shortcuts every part honors. Plain DOM; no network.
3
+
4
+ // Lucide icons (ISC license), as path data.
5
+ const ICONS = {
6
+ MousePointer2: '<path d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z"/>',
7
+ Columns2: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M12 3v18"/>',
8
+ Rows2: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 12h18"/>',
9
+ Plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
10
+ Heading: '<path d="M6 12h12"/><path d="M6 20V4"/><path d="M18 20V4"/>',
11
+ Hand: '<path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/>',
12
+ Eye: '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>',
13
+ Undo2: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11"/>',
14
+ Redo2: '<path d="m15 14 5-5-5-5"/><path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5A5.5 5.5 0 0 0 9.5 20H13"/>',
15
+ ChevronRight: '<path d="m9 18 6-6-6-6"/>',
16
+ ChevronDown: '<path d="m6 9 6 6 6-6"/>',
17
+ X: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
18
+ Check: '<path d="M20 6 9 17l-5-5"/>',
19
+ CircleX: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
20
+ Info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
21
+ Trash2: '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
22
+ Copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
23
+ GripVertical: '<circle cx="9" cy="12" r="1"/><circle cx="9" cy="5" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="19" r="1"/>',
24
+ ZoomIn: '<circle cx="11" cy="11" r="8"/><line x1="21" x2="16.65" y1="21" y2="16.65"/><line x1="11" x2="11" y1="8" y2="14"/><line x1="8" x2="14" y1="11" y2="11"/>',
25
+ ZoomOut: '<circle cx="11" cy="11" r="8"/><line x1="21" x2="16.65" y1="21" y2="16.65"/><line x1="8" x2="14" y1="11" y2="11"/>',
26
+ Maximize: '<path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/>',
27
+ Square: '<rect width="18" height="18" x="3" y="3" rx="2"/>',
28
+ Layers: '<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>',
29
+ Pencil: '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
30
+ Unfold: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
31
+ BetweenHorizontalStart: '<rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/>',
32
+ AlignLeft: '<path d="M15 12H3"/><path d="M17 18H3"/><path d="M21 6H3"/>',
33
+ List: '<path d="M3 12h.01"/><path d="M3 18h.01"/><path d="M3 6h.01"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M8 6h13"/>',
34
+ Image: '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
35
+ Play: '<polygon points="6 3 20 12 6 21 6 3"/>',
36
+ ChartColumn: '<path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>',
37
+ Table: '<path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/>',
38
+ Code: '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
39
+ Hash: '<line x1="4" x2="20" y1="9" y2="9"/><line x1="4" x2="20" y1="15" y2="15"/><line x1="10" x2="8" y1="3" y2="21"/><line x1="16" x2="14" y1="3" y2="21"/>',
40
+ Quote: '<path d="M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/><path d="M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/>',
41
+ GitCommitHorizontal: '<circle cx="12" cy="12" r="3"/><line x1="3" x2="9" y1="12" y2="12"/><line x1="15" x2="21" y1="12" y2="12"/>',
42
+ Group: '<path d="M3 7V5c0-1.1.9-2 2-2h2"/><path d="M17 3h2c1.1 0 2 .9 2 2v2"/><path d="M21 17v2c0 1.1-.9 2-2 2h-2"/><path d="M7 21H5c-1.1 0-2-.9-2-2v-2"/><rect width="7" height="5" x="7" y="7" rx="1"/><rect width="7" height="5" x="10" y="12" rx="1"/>',
43
+ };
44
+ /** The Lucide icon for a content kind, shown in the wireframe and on the chips. */
45
+ export const KIND_ICONS = { text: "AlignLeft", list: "List", image: "Image", video: "Play", chart: "ChartColumn", table: "Table", code: "Code", metric: "Hash", quote: "Quote", timeline: "GitCommitHorizontal", group: "Group" };
46
+
47
+ const SVG = "http://www.w3.org/2000/svg";
48
+ let serial = 0;
49
+ export const nextId = (prefix) => `${prefix}-${++serial}`;
50
+
51
+ /** DOM helpers bound to one document. */
52
+ export function createDom(doc) {
53
+ const el = (tag, className, attributes = {}) => {
54
+ const node = doc.createElement(tag);
55
+ if (className) node.className = className;
56
+ for (const [key, value] of Object.entries(attributes)) {
57
+ if (value === undefined || value === null || value === false) continue;
58
+ if (key === "text") node.textContent = value; else node.setAttribute(key, value === true ? "" : String(value));
59
+ }
60
+ return node;
61
+ };
62
+ const icon = (name, size = 16) => {
63
+ const svg = doc.createElementNS(SVG, "svg");
64
+ svg.setAttribute("viewBox", "0 0 24 24");
65
+ svg.setAttribute("width", String(size));
66
+ svg.setAttribute("height", String(size));
67
+ svg.setAttribute("fill", "none");
68
+ svg.setAttribute("stroke", "currentColor");
69
+ svg.setAttribute("stroke-width", "2");
70
+ svg.setAttribute("stroke-linecap", "round");
71
+ svg.setAttribute("stroke-linejoin", "round");
72
+ svg.setAttribute("aria-hidden", "true");
73
+ svg.setAttribute("focusable", "false");
74
+ svg.setAttribute("class", `opf-lx-icon opf-lx-icon-${name.toLowerCase()}`);
75
+ svg.innerHTML = ICONS[name] ?? "";
76
+ return svg;
77
+ };
78
+ /** A real button; with `label` and an icon only, the label is its accessible name. */
79
+ const button = (className, { label, text, iconName, size = 16, pressed, title, attributes = {} } = {}) => {
80
+ const node = el("button", className, { type: "button", "aria-label": label, title: title ?? (text ? undefined : label), ...attributes });
81
+ if (pressed !== undefined) node.setAttribute("aria-pressed", String(pressed));
82
+ if (iconName) node.append(icon(iconName, size));
83
+ if (text) node.append(el("span", "opf-lx-button-text", { text }));
84
+ return node;
85
+ };
86
+ return { doc, el, icon, button };
87
+ }
88
+
89
+ /** Whether keyboard events from this element belong to a field the person is typing in. */
90
+ export function isTypingTarget(target) {
91
+ if (!target || target.nodeType !== 1) return false;
92
+ const tag = target.tagName;
93
+ if (tag === "TEXTAREA" || tag === "SELECT") return true;
94
+ if (tag === "INPUT") return !["checkbox", "radio", "button", "submit", "reset"].includes(target.type);
95
+ return target.isContentEditable === true;
96
+ }
97
+ export const isMac = (win) => /Mac|iPhone|iPad/.test(win?.navigator?.platform ?? "");
98
+ const modifier = (event) => event.metaKey || event.ctrlKey;
99
+ /** `[` and `]` by character or by physical key, so a layout where they need AltGr still has the shortcut elsewhere. */
100
+ export const bracket = (event) => (event.key === "[" || event.code === "BracketLeft" ? -1 : event.key === "]" || event.code === "BracketRight" ? 1 : 0);
101
+
102
+ export const TOOL_KEYS = Object.freeze({ v: "select", r: "split-row", c: "split-column", i: "insert", t: "heading", h: "hand" });
103
+
104
+ /**
105
+ * The shortcuts every part honors: undo and redo, the tool letters, and Preview. Returns true when the event was handled. Not while the person
106
+ * types in a field (the browser's own undo applies there).
107
+ */
108
+ export function handleGlobalShortcut(event, model) {
109
+ if (event.opfLayoutHandled || event.defaultPrevented || isTypingTarget(event.target) || event.isComposing) return false;
110
+ const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
111
+ if (modifier(event) && !event.altKey) {
112
+ if (key === "z") { event.preventDefault(); (event.shiftKey ? model.redo() : model.undo()); event.opfLayoutHandled = true; return true; }
113
+ if (key === "y" && event.ctrlKey) { event.preventDefault(); model.redo(); event.opfLayoutHandled = true; return true; }
114
+ return false;
115
+ }
116
+ if (event.altKey || event.shiftKey) return false;
117
+ if (TOOL_KEYS[key] && !event.repeat) { event.preventDefault(); model.setTool(TOOL_KEYS[key]); event.opfLayoutHandled = true; return true; }
118
+ if (key === "p" && !event.repeat) { event.preventDefault(); model.setPreview(!model.preview); event.opfLayoutHandled = true; return true; }
119
+ return false;
120
+ }
121
+
122
+ /**
123
+ * The shortcuts for a selection (the canvas and the layers): reorder with `[` and `]`, duplicate, delete, rename. Returns true when handled.
124
+ * `Shift` with a bracket moves the node into the neighboring split.
125
+ */
126
+ export function handleSelectionShortcut(event, model, surface = "canvas") {
127
+ if (event.opfLayoutHandled || event.defaultPrevented || isTypingTarget(event.target) || event.isComposing) return false;
128
+ const paths = model.selection;
129
+ if (!paths.length) return false;
130
+ const done = () => { event.preventDefault(); event.opfLayoutHandled = true; return true; };
131
+ const delta = bracket(event);
132
+ if (delta && !modifier(event) && !event.altKey) {
133
+ if (event.shiftKey) model.moveInto(paths[paths.length - 1], delta); else model.reorder(paths[paths.length - 1], delta);
134
+ return done();
135
+ }
136
+ if (modifier(event) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "d") { model.duplicate(paths); return done(); }
137
+ if (!modifier(event) && !event.altKey && (event.key === "Delete" || event.key === "Backspace")) { model.remove(paths); return done(); }
138
+ if (!modifier(event) && !event.altKey && !event.shiftKey && event.key === "F2" && paths.length === 1) { model.requestRename(paths[0], surface); return done(); }
139
+ return false;
140
+ }
141
+
142
+ /**
143
+ * A menu (`role="menu"`) at a point, inside `host` (a positioned element). Items are `{ label, onSelect, disabled, role, checked, hint }`.
144
+ * Arrow keys, Home, End, Enter and Space work; Escape closes and returns focus to `returnFocus`. Returns `{ element, close }`.
145
+ */
146
+ export function openMenu(dom, host, { x, y, items, label, returnFocus, onClose, className = "" }) {
147
+ const menu = dom.el("div", `opf-lx-menu ${className}`.trim(), { role: "menu", "aria-label": label });
148
+ const buttons = items.map((item) => {
149
+ const role = item.role ?? "menuitem";
150
+ const node = dom.el("button", "opf-lx-menuitem", { type: "button", role, tabindex: "-1", "aria-disabled": item.disabled ? "true" : undefined, "aria-checked": role === "menuitemcheckbox" ? String(Boolean(item.checked)) : undefined });
151
+ node.append(dom.el("span", "opf-lx-menu-label", { text: item.label }));
152
+ if (item.hint) node.append(dom.el("span", "opf-lx-menu-hint", { text: item.hint }));
153
+ if (role === "menuitemcheckbox") node.prepend(dom.icon(item.checked ? "Check" : "Square", 14));
154
+ node.addEventListener("click", () => {
155
+ if (item.disabled) return;
156
+ close();
157
+ item.onSelect?.();
158
+ });
159
+ return node;
160
+ });
161
+ menu.append(...buttons);
162
+ let closed = false;
163
+ const outside = (event) => { if (!menu.contains(event.target)) close({ restore: false }); };
164
+ function close({ restore = true } = {}) {
165
+ if (closed) return;
166
+ closed = true;
167
+ dom.doc.removeEventListener("pointerdown", outside, true);
168
+ menu.remove();
169
+ if (restore) returnFocus?.focus?.({ preventScroll: true });
170
+ onClose?.();
171
+ }
172
+ const enabled = () => buttons.filter((node) => node.getAttribute("aria-disabled") !== "true");
173
+ menu.addEventListener("keydown", (event) => {
174
+ const list = enabled();
175
+ const at = list.indexOf(dom.doc.activeElement);
176
+ const go = (index) => { event.preventDefault(); event.stopPropagation(); list[(index + list.length) % list.length]?.focus(); };
177
+ if (event.key === "ArrowDown") go(at + 1);
178
+ else if (event.key === "ArrowUp") go(at < 0 ? -1 : at - 1);
179
+ else if (event.key === "Home") go(0);
180
+ else if (event.key === "End") go(-1);
181
+ else if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
182
+ else if (event.key === "Tab") close({ restore: false });
183
+ else if (event.key === "Enter" || event.key === " ") { event.preventDefault(); event.stopPropagation(); if (dom.doc.activeElement?.click) dom.doc.activeElement.click(); }
184
+ });
185
+ host.append(menu);
186
+ const bounds = host.getBoundingClientRect();
187
+ const width = menu.offsetWidth || 200, height = menu.offsetHeight || buttons.length * 32;
188
+ menu.style.left = `${Math.max(4, Math.min(x - bounds.left, bounds.width - width - 4))}px`;
189
+ menu.style.top = `${Math.max(4, Math.min(y - bounds.top, bounds.height - height - 4))}px`;
190
+ dom.doc.addEventListener("pointerdown", outside, true);
191
+ enabled()[0]?.focus();
192
+ return { element: menu, close };
193
+ }
194
+
195
+ /** The items of the context menu for the selection `paths` (wrap in row or column, unwrap, duplicate, delete, rename). */
196
+ export function nodeMenuItems(model, paths, surface, win) {
197
+ const node = paths.length === 1 ? model.nodeAt(paths[0]) : null;
198
+ const split = Boolean(node) && node.name === undefined;
199
+ const mod = isMac(win) ? "⌘" : "Ctrl+";
200
+ return [
201
+ { label: "Wrap in row", onSelect: () => model.wrap(paths, "row") },
202
+ { label: "Wrap in column", onSelect: () => model.wrap(paths, "column") },
203
+ { label: "Unwrap", disabled: !split, onSelect: () => model.unwrap(paths[0]) },
204
+ { label: "Duplicate", hint: `${mod}D`, onSelect: () => model.duplicate(paths) },
205
+ { label: "Delete", hint: "⌫", onSelect: () => model.remove(paths) },
206
+ { label: "Rename", hint: "F2", disabled: !node || split, onSelect: () => model.requestRename(paths[0], surface) },
207
+ ];
208
+ }
209
+
210
+ /** A small wireframe of the kinds a region accepts: up to three blocks, one glyph and a word each (or every kind: text, list, image). */
211
+ export function buildWireframe(dom, accepts, { count = 3, flow = "auto" } = {}) {
212
+ const kinds = Array.isArray(accepts) && accepts.length ? accepts : ["text", "list", "image"];
213
+ const wire = dom.el("div", "opf-lx-wire", { "aria-hidden": "true", "data-flow": flow });
214
+ const shown = Math.max(1, Math.min(count, kinds.length > 1 ? Math.min(3, kinds.length) : flow === "none" ? 1 : 2));
215
+ for (let index = 0; index < shown; index++) {
216
+ const kind = kinds[index % kinds.length];
217
+ const block = dom.el("span", "opf-lx-wire-block", { "data-kind": kind });
218
+ block.append(dom.icon(KIND_ICONS[kind] ?? "Square", 14), dom.el("span", "opf-lx-wire-name", { text: kind }));
219
+ wire.append(block);
220
+ }
221
+ return wire;
222
+ }
223
+
224
+ /** `+/- x`: round a fraction to a CSS percentage. */
225
+ export const pct = (fraction) => `${Math.round(fraction * 10000) / 100}%`;
226
+
227
+ export const SIZE_LABEL = (entry, direction) => {
228
+ const size = entry.size;
229
+ if (size === "auto") return "auto";
230
+ if (size === undefined) return entry.name === "title" || entry.name === "subtitle" ? (direction === "column" ? "auto" : "1") : "1";
231
+ return String(size);
232
+ };
@@ -0,0 +1,212 @@
1
+ import type { EditorChange, EditorSession } from "./index.js";
2
+ import type { LayoutPath, LayoutRecord } from "./layout-tree.js";
3
+
4
+ export type { LayoutRecord, LayoutPath } from "./layout-tree.js";
5
+ export type LayoutEditorDensity = "comfortable" | "touch";
6
+ export type LayoutTool = "select" | "split-row" | "split-column" | "insert" | "heading" | "hand";
7
+
8
+ /** One finding of core's layout validation (`opf/layout-template`, `opf/layout-region`, or a schema finding). */
9
+ export interface LayoutFinding {
10
+ ruleId: string;
11
+ severity: "error" | "warning" | "info";
12
+ message: string;
13
+ /** The JSON Pointer inside the record, `/regions/1/regions/0/size`. */
14
+ path: string;
15
+ /** The region or split it points at (`[]`: the layout itself). */
16
+ node: LayoutPath;
17
+ /** The property it points at (`size`, `name`, `direction`, ...), or null. */
18
+ field: string | null;
19
+ /** Other nodes that are part of the problem: the earlier regions that share a name with the one it points at. */
20
+ also?: LayoutPath[];
21
+ }
22
+
23
+ export interface LayoutModelEvent { type: "record" | "selection" | "tool" | "view" | "load" | "notice" | "rename"; source?: string; message?: string; model: LayoutModel }
24
+ export interface LayoutModelOptions {
25
+ /** The record to edit (default: a title and one content region). */
26
+ record?: LayoutRecord;
27
+ density?: LayoutEditorDensity;
28
+ readOnly?: boolean;
29
+ /** Called with a copy of the record after each edit, undo and redo (not after `setRecord`). */
30
+ onChange?(record: LayoutRecord): void;
31
+ /** Undo steps kept (default 200). */
32
+ historyLimit?: number;
33
+ }
34
+
35
+ /** The state the parts share: the record, the selection, the tool, validation, and the editor's own undo history. Every edit is a command. */
36
+ export interface LayoutModel {
37
+ readonly record: LayoutRecord;
38
+ readonly selection: LayoutPath[];
39
+ readonly primary: LayoutPath | null;
40
+ readonly tool: LayoutTool;
41
+ readonly preview: boolean;
42
+ readonly density: LayoutEditorDensity;
43
+ readonly readOnly: boolean;
44
+ readonly findings: LayoutFinding[];
45
+ readonly notice: { message: string; kind: "info" | "error" } | null;
46
+ subscribe(listener: (event: LayoutModelEvent) => void): () => void;
47
+ setOnChange(callback: ((record: LayoutRecord) => void) | undefined): void;
48
+ getRecord(): LayoutRecord;
49
+ setRecord(record: LayoutRecord, options?: { keepHistory?: boolean }): void;
50
+ select(paths: LayoutPath[]): boolean;
51
+ selectAdd(path: LayoutPath): boolean;
52
+ selectParent(): boolean;
53
+ selectChild(): boolean;
54
+ selectSibling(delta: number): boolean;
55
+ setTool(tool: LayoutTool): boolean;
56
+ setPreview(on: boolean): boolean;
57
+ setDensity(density: LayoutEditorDensity): void;
58
+ setReadOnly(on: boolean): void;
59
+ notify(message: string, kind?: "info" | "error"): void;
60
+ requestRename(path: LayoutPath | null, surface?: "canvas" | "layers"): void;
61
+ splitRegion(path: LayoutPath, axis: "row" | "column"): boolean;
62
+ insertRegion(listPath: LayoutPath, index: number, entry?: Record<string, unknown>): boolean;
63
+ toggleHeading(name: "title" | "subtitle"): boolean;
64
+ wrap(paths: LayoutPath[], axis: "row" | "column"): boolean;
65
+ unwrap(path: LayoutPath): boolean;
66
+ duplicate(paths: LayoutPath[]): boolean;
67
+ remove(paths: LayoutPath[]): boolean;
68
+ move(paths: LayoutPath[], listPath: LayoutPath, index: number): boolean;
69
+ reorder(path: LayoutPath, delta: -1 | 1): boolean;
70
+ moveInto(path: LayoutPath, delta: -1 | 1): boolean;
71
+ setProperty(path: LayoutPath, key: string, value: unknown, options?: { coalesce?: string | null }): boolean;
72
+ setLayoutProperty(key: string, value: unknown, options?: { coalesce?: string | null }): boolean;
73
+ removeDesignHint(key: string): boolean;
74
+ equalize(listPath: LayoutPath, index: number): boolean;
75
+ nudge(listPath: LayoutPath, index: number, steps: number, big?: boolean): boolean;
76
+ beginGesture(label: string): boolean;
77
+ updateGesture(record: LayoutRecord, message?: string): void;
78
+ endGesture(message?: string): boolean;
79
+ cancelGesture(): void;
80
+ canUndo(): boolean;
81
+ canRedo(): boolean;
82
+ undo(): boolean;
83
+ redo(): boolean;
84
+ findingsFor(path: LayoutPath, field?: string): LayoutFinding[];
85
+ findingsWithin(path: LayoutPath): LayoutFinding[];
86
+ describe(path: LayoutPath): string;
87
+ destroy(): void;
88
+ }
89
+ export declare function createLayoutModel(options?: LayoutModelOptions): LayoutModel;
90
+ export declare function blankLayoutRecord(): LayoutRecord;
91
+ /** The `$schema` of a layout record. */
92
+ export declare const LAYOUT_SCHEMA: string;
93
+ export declare const LAYOUT_TOOLS: readonly LayoutTool[];
94
+ /** Core's validation of a layout record (`opf/layout-template`, `opf/layout-region` and the schema). */
95
+ export declare function validateLayoutRecord(record: LayoutRecord): LayoutFinding[];
96
+ /** Where a finding's JSON Pointer points: the node path and the property. */
97
+ export declare function findingLocation(pointer: string): { node: LayoutPath; field: string | null };
98
+ /** `{ id, record }` for `saveCustomLayout`: the record without `$schema` and `id`. */
99
+ export declare function layoutForCatalog(record: LayoutRecord): { id: string; record: Record<string, unknown> };
100
+ /** A one-slide deck that uses the layout (embedded as a custom layout) with sample blocks pinned to every body region. */
101
+ export declare function sampleDeck(record: LayoutRecord): Record<string, unknown>;
102
+
103
+ export interface LayoutToolDef { id: string; label: string; key: string; icon: string; help: string }
104
+ export declare const LAYOUT_TOOL_DEFS: readonly LayoutToolDef[];
105
+ export declare const LAYOUT_PREVIEW_DEF: LayoutToolDef;
106
+
107
+ /** Options of every part: the shared `model`, or the model options to create one. */
108
+ interface LayoutPartOptions extends LayoutModelOptions {
109
+ /** The shared model, so several parts edit the same record. */
110
+ model?: LayoutModel;
111
+ }
112
+ export interface LayoutPartHandle {
113
+ element: HTMLElement;
114
+ model: LayoutModel;
115
+ refresh(): void;
116
+ destroy(): void;
117
+ }
118
+
119
+ export interface LayoutToolPaletteOptions extends LayoutPartOptions {
120
+ orientation?: "vertical" | "horizontal";
121
+ /** Accessible name of the toolbar (default "Layout tools"). */
122
+ label?: string;
123
+ }
124
+ /** The tool palette: Select (V), Split into a row (R), Split into a column (C), Insert region (I), Heading regions (T), Hand (H) and Preview (P). */
125
+ export declare function createLayoutToolPalette(container: HTMLElement, options?: LayoutToolPaletteOptions): LayoutPartHandle;
126
+
127
+ export interface LayoutRenderOptions {
128
+ /** The host's renderer: `toSvg` from `@openpresentation/opf-render/svg`. Preview draws a wireframe without it. */
129
+ toSvg?(deck: Record<string, unknown>, slide: number, options?: Record<string, unknown>): string | Promise<string>;
130
+ /** The renderer's fonts handle (`loadFonts()`), the same one the host's canvas and export use. */
131
+ fonts?: unknown;
132
+ /** Extra render options (`catalogs`, ...), or a function that returns them when Preview draws. */
133
+ renderOptions?: Record<string, unknown> | (() => Record<string, unknown>);
134
+ }
135
+ export interface LayoutCanvasOptions extends LayoutPartOptions, LayoutRenderOptions {
136
+ label?: string;
137
+ }
138
+ export interface LayoutCanvas extends LayoutPartHandle {
139
+ setZoom(zoom: number): void;
140
+ getZoom(): number;
141
+ focus(): void;
142
+ }
143
+ /** The canvas: a 16:9 slide frame with the region tree as labeled boxes, dividers to resize, drag to reorder, a context menu and the keyboard. */
144
+ export declare function createLayoutCanvas(container: HTMLElement, options?: LayoutCanvasOptions): LayoutCanvas;
145
+
146
+ export interface LayoutTreeOptions extends LayoutPartOptions {
147
+ label?: string;
148
+ onFindingSelect?(finding: LayoutFinding): void;
149
+ }
150
+ export interface LayoutTree extends LayoutPartHandle {
151
+ focus(): void;
152
+ beginRename(path: LayoutPath): void;
153
+ }
154
+ /** The layers: the outline of splits and regions (role="tree") with the findings under it. */
155
+ export declare function createLayoutTree(container: HTMLElement, options?: LayoutTreeOptions): LayoutTree;
156
+
157
+ export interface LayoutInspector extends LayoutPartHandle {
158
+ focus(): void;
159
+ }
160
+ /** The inspector: the layout, a split or a region as form fields. */
161
+ export declare function createLayoutInspector(container: HTMLElement, options?: LayoutPartOptions & { label?: string }): LayoutInspector;
162
+
163
+ export interface LayoutEditorOptions extends LayoutPartOptions, LayoutRenderOptions {
164
+ label?: string;
165
+ onFindingSelect?(finding: LayoutFinding): void;
166
+ }
167
+ export interface LayoutEditor {
168
+ element: HTMLElement;
169
+ model: LayoutModel;
170
+ palette: LayoutPartHandle;
171
+ canvas: LayoutCanvas;
172
+ layers: LayoutTree;
173
+ inspector: LayoutInspector;
174
+ refresh(): void;
175
+ destroy(): void;
176
+ getRecord(): LayoutRecord;
177
+ /** Replace the record (a layout the host loaded). Clears the selection and, unless `keepHistory`, the history. Does not call `onChange`. */
178
+ setRecord(record: LayoutRecord, options?: { keepHistory?: boolean }): void;
179
+ getFindings(): LayoutFinding[];
180
+ getSelection(): LayoutPath[];
181
+ select(path: LayoutPath | null): boolean;
182
+ getTool(): LayoutTool;
183
+ setTool(tool: LayoutTool): boolean;
184
+ getPreview(): boolean;
185
+ setPreview(on: boolean): boolean;
186
+ setDensity(density: LayoutEditorDensity): void;
187
+ setReadOnly(on: boolean): void;
188
+ undo(): boolean;
189
+ redo(): boolean;
190
+ canUndo(): boolean;
191
+ canRedo(): boolean;
192
+ subscribe(listener: (event: LayoutModelEvent) => void): () => void;
193
+ /**
194
+ * Write the record into `catalogs.custom.layouts` through `saveCustomLayout`, as one undoable edit. Refused while an error finding is open,
195
+ * and for an id the deck already has unless `replace` is true.
196
+ */
197
+ saveToDeck(editor: EditorSession, options?: { replace?: boolean }): EditorChange & { layoutId: string };
198
+ /** Save the layout (replacing the deck's copy of the same id) and make it the layout of slide `slideIndex`, in one undoable edit. */
199
+ applyToSlide(editor: EditorSession, slideIndex: number, options?: { replace?: boolean; catalogs?: unknown }): EditorChange & { layoutId: string; slideIndex: number };
200
+ }
201
+ /**
202
+ * The layout editor: the tool palette, canvas, layers and inspector around one shared model. Edits a layout record (`direction` plus an
203
+ * ordered `regions` list of regions and splits), validates it live with core, and keeps its own undo history.
204
+ */
205
+ export declare function createLayoutEditor(container: HTMLElement, options?: LayoutEditorOptions): LayoutEditor;
206
+
207
+ /** What a layout save returns: the editor change and the id of the saved layout. */
208
+ export type LayoutSaveChange = EditorChange & { layoutId: string };
209
+ /** Save the model's record into `catalogs.custom.layouts` as one undoable edit. For a host that arranges the parts itself; the composed editor's `saveToDeck` calls it. */
210
+ export declare function saveLayoutToDeck(editor: EditorSession, model: LayoutModel, options?: { replace?: boolean }): LayoutSaveChange;
211
+ /** Save the layout and make it the layout of a slide, in one undoable edit. The composed editor's `applyToSlide` calls it. */
212
+ export declare function applyLayoutToSlide(editor: EditorSession, model: LayoutModel, slideIndex: number, options?: { replace?: boolean; catalogs?: unknown }): LayoutSaveChange & { slideIndex: number };