@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.
- package/README.md +124 -2
- package/dist/galleries.js +2 -2
- package/dist/layout-canvas.js +748 -0
- package/dist/layout-diagram.js +25 -0
- package/dist/layout-editor-ui.js +232 -0
- package/dist/layout-editor.d.ts +212 -0
- package/dist/layout-editor.js +197 -0
- package/dist/layout-inspector.js +339 -0
- package/dist/layout-layers.js +331 -0
- package/dist/layout-model.js +299 -0
- package/dist/layout-palette.js +77 -0
- package/dist/layout-samples.js +84 -0
- package/dist/layout-templates.d.ts +32 -8
- package/dist/layout-templates.js +89 -37
- package/dist/layout-tree.d.ts +87 -0
- package/dist/layout-tree.js +704 -0
- package/dist/presenter.d.ts +105 -0
- package/dist/presenter.js +431 -0
- package/dist/read-deck-file.d.ts +59 -0
- package/dist/read-deck-file.js +142 -0
- package/dist/slide-manager.js +3 -7
- package/dist/styles.css +1003 -25
- package/package.json +25 -10
- package/dist/custom-layout-editor.d.ts +0 -7
- package/dist/custom-layout-editor.js +0 -105
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// The layers panel of the layout editor (RR-99): the tree outline of splits and regions in reading order (role="tree"). Select, rename,
|
|
2
|
+
// drag to reorder or to move between splits, and see which nodes have findings; the findings are listed under the tree.
|
|
3
|
+
import { createLayoutModel } from "./layout-model.js";
|
|
4
|
+
import { LayoutOperationError, isHeading, isPrefix, isSplit, moveNodes, nodeAt, pathKey, samePath } from "./layout-tree.js";
|
|
5
|
+
import { SIZE_LABEL, createDom, handleGlobalShortcut, handleSelectionShortcut, isTypingTarget, nextId, nodeMenuItems, openMenu } from "./layout-editor-ui.js";
|
|
6
|
+
|
|
7
|
+
const DRAG_DISTANCE = 4;
|
|
8
|
+
const parsePath = (text) => (text === "" || text === undefined ? [] : text.split(".").map(Number));
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Mount the layers panel in `container`. Options: `model` (shared state; one is created from the other options when omitted),
|
|
12
|
+
* `onFindingSelect(finding)`, `label`. Returns `{ element, model, refresh, focus, destroy }`.
|
|
13
|
+
*/
|
|
14
|
+
export function createLayoutTree(container, options = {}) {
|
|
15
|
+
if (!container?.ownerDocument) throw new TypeError("A DOM container is required.");
|
|
16
|
+
const doc = container.ownerDocument, win = doc.defaultView;
|
|
17
|
+
const dom = createDom(doc);
|
|
18
|
+
const { el } = dom;
|
|
19
|
+
const ownModel = !options.model;
|
|
20
|
+
const model = options.model ?? createLayoutModel(options);
|
|
21
|
+
const id = nextId("opf-lx-layers");
|
|
22
|
+
|
|
23
|
+
const root = el("section", "opf-lx-layers opf-ui", { "aria-labelledby": `${id}-title`, "data-density": model.density });
|
|
24
|
+
const head = el("div", "opf-lx-panel-head");
|
|
25
|
+
head.append(dom.icon("Layers", 14), el("h2", "opf-lx-panel-title", { id: `${id}-title`, text: "Layers" }));
|
|
26
|
+
const scroller = el("div", "opf-lx-layers-scroll");
|
|
27
|
+
const list = el("ul", "opf-lx-tree", { role: "tree", "aria-label": options.label ?? "Layout layers" });
|
|
28
|
+
const dropMark = el("div", "opf-lx-tree-drop", { "aria-hidden": "true" });
|
|
29
|
+
dropMark.hidden = true;
|
|
30
|
+
scroller.append(list, dropMark);
|
|
31
|
+
const findings = el("div", "opf-lx-findings");
|
|
32
|
+
const findingsHead = el("p", "opf-lx-findings-head", { role: "status" });
|
|
33
|
+
const findingsList = el("ul", "opf-lx-findings-list", { "aria-label": "Findings" });
|
|
34
|
+
findings.append(findingsHead, findingsList);
|
|
35
|
+
root.append(head, scroller, findings);
|
|
36
|
+
container.append(root);
|
|
37
|
+
|
|
38
|
+
const collapsed = new Set();
|
|
39
|
+
let drag = null;
|
|
40
|
+
let pending = null;
|
|
41
|
+
let menu = null;
|
|
42
|
+
let renameInput = null;
|
|
43
|
+
|
|
44
|
+
const itemOf = (path) => list.querySelector(`[data-path="${pathKey(path)}"]`);
|
|
45
|
+
const isSelected = (path) => (path.length ? model.selection.some((entry) => samePath(entry, path)) : model.selection.length === 0);
|
|
46
|
+
|
|
47
|
+
function build(path, entry, level, direction, position) {
|
|
48
|
+
const rootItem = path.length === 0, split = !rootItem && isSplit(entry);
|
|
49
|
+
const li = el("li", "opf-lx-item", { role: "treeitem", "aria-level": level, "data-path": pathKey(path), "data-kind": rootItem ? "layout" : split ? "split" : "region" });
|
|
50
|
+
li.setAttribute("aria-selected", String(isSelected(path)));
|
|
51
|
+
if (rootItem || split) li.setAttribute("aria-expanded", String(!collapsed.has(pathKey(path))));
|
|
52
|
+
if (!rootItem) { li.setAttribute("aria-setsize", String(position.size)); li.setAttribute("aria-posinset", String(position.index + 1)); }
|
|
53
|
+
const issues = model.findingsFor(path);
|
|
54
|
+
const within = model.findingsWithin(path);
|
|
55
|
+
const row = el("div", "opf-lx-row", { "data-selected": String(isSelected(path)), "data-finding": issues.length ? "error" : within.length ? "within" : "" });
|
|
56
|
+
row.style.paddingInlineStart = `calc(${level - 1} * var(--opf-lx-indent) + 6px)`;
|
|
57
|
+
if (!rootItem) { const grip = el("span", "opf-lx-grip", { "aria-hidden": "true" }); grip.append(dom.icon("GripVertical", 14)); row.append(grip); }
|
|
58
|
+
if (rootItem || split) {
|
|
59
|
+
const caret = el("span", "opf-lx-caret", { "aria-hidden": "true" });
|
|
60
|
+
caret.append(dom.icon(collapsed.has(pathKey(path)) ? "ChevronRight" : "ChevronDown", 14));
|
|
61
|
+
caret.addEventListener("click", (event) => { event.stopPropagation(); toggle(path); });
|
|
62
|
+
row.append(caret);
|
|
63
|
+
} else row.append(el("span", "opf-lx-caret"));
|
|
64
|
+
row.append(dom.icon(rootItem ? "Layers" : split ? (entry.direction === "row" ? "Columns2" : "Rows2") : isHeading(entry) ? "Heading" : "Square", 14));
|
|
65
|
+
const name = rootItem ? model.record.name || model.record.id || "Untitled layout" : split ? `${entry.direction === "row" ? "Row" : "Column"} split` : entry.name;
|
|
66
|
+
row.append(el("span", "opf-lx-row-name", { text: name }));
|
|
67
|
+
if (!rootItem) row.append(el("span", "opf-lx-row-size", { "aria-hidden": "true", text: SIZE_LABEL(entry, direction) }));
|
|
68
|
+
else row.append(el("span", "opf-lx-row-size", { "aria-hidden": "true", text: model.record.direction === "row" ? "row" : "column" }));
|
|
69
|
+
if (issues.length || within.length) {
|
|
70
|
+
const flag = el("span", "opf-lx-row-flag", { title: (issues.length ? issues : within).map((finding) => finding.message).join("\n") });
|
|
71
|
+
flag.append(dom.icon("CircleX", 14), el("span", "opf-lx-visually-hidden", { text: `${(issues.length ? issues : within).length} finding${(issues.length ? issues : within).length === 1 ? "" : "s"}` }));
|
|
72
|
+
row.append(flag);
|
|
73
|
+
}
|
|
74
|
+
li.append(row);
|
|
75
|
+
if (split && !collapsed.has(pathKey(path)) && Array.isArray(entry.regions)) li.append(group(path, entry.regions, level + 1, entry.direction === "row" ? "row" : "column"));
|
|
76
|
+
if (rootItem && !collapsed.has("")) li.append(group([], model.record.regions, level + 1, model.record.direction === "row" ? "row" : "column"));
|
|
77
|
+
return li;
|
|
78
|
+
}
|
|
79
|
+
function group(path, entries, level, direction) {
|
|
80
|
+
const ul = el("ul", "opf-lx-group", { role: "group" });
|
|
81
|
+
entries.forEach((entry, index) => ul.append(build([...path, index], entry, level, direction, { index, size: entries.length })));
|
|
82
|
+
return ul;
|
|
83
|
+
}
|
|
84
|
+
function toggle(path) {
|
|
85
|
+
const key = pathKey(path);
|
|
86
|
+
if (collapsed.has(key)) collapsed.delete(key); else collapsed.add(key);
|
|
87
|
+
render();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function render() {
|
|
91
|
+
const active = doc.activeElement;
|
|
92
|
+
const focusedKey = list.contains(active) ? active.closest?.("[data-path]")?.dataset.path : undefined;
|
|
93
|
+
// A selection hidden inside a collapsed split opens it.
|
|
94
|
+
for (const path of model.selection) for (let n = 1; n < path.length; n++) collapsed.delete(pathKey(path.slice(0, n)));
|
|
95
|
+
if (model.selection.length) collapsed.delete("");
|
|
96
|
+
list.replaceChildren(build([], model.record, 1, "column", { index: 0, size: 1 }));
|
|
97
|
+
const primary = model.primary ?? [];
|
|
98
|
+
const stop = itemOf(primary) ?? itemOf([]);
|
|
99
|
+
for (const item of list.querySelectorAll(".opf-lx-item")) item.tabIndex = item === stop ? 0 : -1;
|
|
100
|
+
if (focusedKey !== undefined) { const next = itemOf(primary) ?? itemOf(parsePath(focusedKey)) ?? stop; next?.focus({ preventScroll: true }); }
|
|
101
|
+
paintFindings();
|
|
102
|
+
syncRename();
|
|
103
|
+
}
|
|
104
|
+
function paintFindings() {
|
|
105
|
+
const items = model.findings;
|
|
106
|
+
const errors = items.filter((finding) => finding.severity === "error").length;
|
|
107
|
+
findingsHead.textContent = items.length ? `${items.length} finding${items.length === 1 ? "" : "s"}` : "No findings";
|
|
108
|
+
findingsHead.dataset.state = items.length ? (errors ? "error" : "warning") : "valid";
|
|
109
|
+
findingsHead.prepend(dom.icon(items.length ? "CircleX" : "Check", 14));
|
|
110
|
+
findingsList.replaceChildren();
|
|
111
|
+
findingsList.hidden = !items.length;
|
|
112
|
+
for (const finding of items) {
|
|
113
|
+
const li = el("li", "opf-lx-finding-item");
|
|
114
|
+
const button = el("button", "opf-lx-finding", { type: "button", "data-rule": finding.ruleId ?? "" });
|
|
115
|
+
const where = finding.node.length ? `${model.describe(finding.node)}${finding.field ? ` · ${finding.field}` : ""}` : `layout${finding.field ? ` · ${finding.field}` : ""}`;
|
|
116
|
+
button.append(el("span", "opf-lx-finding-message", { text: finding.message }), el("span", "opf-lx-finding-where", { text: `${finding.ruleId ?? "finding"} · ${where}` }));
|
|
117
|
+
button.addEventListener("click", () => { model.select(finding.node.length ? [finding.node] : []); options.onFindingSelect?.(finding); itemOf(finding.node)?.focus({ preventScroll: true }); });
|
|
118
|
+
li.append(button);
|
|
119
|
+
findingsList.append(li);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- rename in place ---------------------------------------------------------------------------------------------------------------
|
|
124
|
+
function syncRename() {
|
|
125
|
+
const request = model.renaming;
|
|
126
|
+
const wanted = request?.surface === "layers" ? request.path : null;
|
|
127
|
+
if (renameInput && (!wanted || renameInput._path !== pathKey(wanted))) { renameInput = null; }
|
|
128
|
+
if (!wanted || renameInput) return;
|
|
129
|
+
const item = itemOf(wanted);
|
|
130
|
+
const entry = nodeAt(model.record, wanted);
|
|
131
|
+
if (!item || !entry || isSplit(entry)) { model.requestRename(null); return; }
|
|
132
|
+
const label = item.querySelector(":scope > .opf-lx-row > .opf-lx-row-name");
|
|
133
|
+
const input = el("input", "opf-lx-rename opf-lx-rename-row", { type: "text", value: entry.name, "aria-label": `Rename ${entry.name}`, spellcheck: "false", autocomplete: "off" });
|
|
134
|
+
input._path = pathKey(wanted);
|
|
135
|
+
label.replaceWith(input);
|
|
136
|
+
let done = false;
|
|
137
|
+
const finish = (commit) => {
|
|
138
|
+
if (done) return;
|
|
139
|
+
done = true;
|
|
140
|
+
const value = input.value.trim();
|
|
141
|
+
renameInput = null;
|
|
142
|
+
model.requestRename(null);
|
|
143
|
+
if (commit && value && value !== entry.name) model.setProperty(wanted, "name", value, { coalesce: null });
|
|
144
|
+
else render();
|
|
145
|
+
itemOf(model.primary ?? wanted)?.focus({ preventScroll: true });
|
|
146
|
+
};
|
|
147
|
+
input.addEventListener("keydown", (event) => {
|
|
148
|
+
event.stopPropagation();
|
|
149
|
+
if (event.key === "Enter") { event.preventDefault(); finish(true); }
|
|
150
|
+
else if (event.key === "Escape") { event.preventDefault(); finish(false); }
|
|
151
|
+
});
|
|
152
|
+
input.addEventListener("blur", () => finish(true));
|
|
153
|
+
input.addEventListener("pointerdown", (event) => event.stopPropagation());
|
|
154
|
+
renameInput = input;
|
|
155
|
+
input.focus();
|
|
156
|
+
input.select();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// --- pointer: select, context menu, drag ---------------------------------------------------------------------------------------------
|
|
160
|
+
const rowPath = (event) => { const item = event.target.closest?.(".opf-lx-item"); return item ? parsePath(item.dataset.path) : null; };
|
|
161
|
+
list.addEventListener("click", (event) => {
|
|
162
|
+
if (event.target.closest(".opf-lx-rename, .opf-lx-caret")) return;
|
|
163
|
+
const path = rowPath(event);
|
|
164
|
+
if (!path) return;
|
|
165
|
+
if (!path.length) { model.select([]); itemOf([])?.focus({ preventScroll: true }); return; }
|
|
166
|
+
if (event.shiftKey) model.selectAdd(path); else model.select([path]);
|
|
167
|
+
itemOf(path)?.focus({ preventScroll: true });
|
|
168
|
+
});
|
|
169
|
+
list.addEventListener("dblclick", (event) => {
|
|
170
|
+
const path = rowPath(event);
|
|
171
|
+
if (!path?.length || model.readOnly) return;
|
|
172
|
+
const entry = nodeAt(model.record, path);
|
|
173
|
+
if (entry && !isSplit(entry)) model.requestRename(path, "layers");
|
|
174
|
+
});
|
|
175
|
+
list.addEventListener("focusin", (event) => {
|
|
176
|
+
const item = event.target.closest?.(".opf-lx-item");
|
|
177
|
+
if (!item || event.target !== item || drag || pending) return;
|
|
178
|
+
const path = parsePath(item.dataset.path);
|
|
179
|
+
if (!path.length) { if (model.selection.length) model.select([]); } else if (!isSelected(path)) model.select([path]);
|
|
180
|
+
});
|
|
181
|
+
list.addEventListener("contextmenu", (event) => {
|
|
182
|
+
const path = rowPath(event);
|
|
183
|
+
if (!path?.length) return;
|
|
184
|
+
event.preventDefault();
|
|
185
|
+
if (!isSelected(path)) model.select([path]);
|
|
186
|
+
openRowMenu({ x: event.clientX, y: event.clientY }, itemOf(path));
|
|
187
|
+
});
|
|
188
|
+
function openRowMenu(point, returnFocus) {
|
|
189
|
+
menu?.close({ restore: false });
|
|
190
|
+
if (!model.selection.length) return;
|
|
191
|
+
menu = openMenu(dom, root, { x: point.x, y: point.y, label: "Layer actions", returnFocus, onClose: () => { menu = null; }, items: nodeMenuItems(model, model.selection, "layers", win) });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
list.addEventListener("pointerdown", (event) => {
|
|
195
|
+
if (model.readOnly || (event.pointerType === "mouse" && event.button !== 0) || event.target.closest(".opf-lx-rename, .opf-lx-caret")) return;
|
|
196
|
+
if (event.pointerType === "touch" && !event.target.closest(".opf-lx-grip")) return;
|
|
197
|
+
const path = rowPath(event);
|
|
198
|
+
if (!path?.length) return;
|
|
199
|
+
const paths = model.selection.some((entry) => samePath(entry, path)) ? model.selection.map((entry) => [...entry]) : [path];
|
|
200
|
+
pending = { x: event.clientX, y: event.clientY, paths, id: event.pointerId };
|
|
201
|
+
});
|
|
202
|
+
list.addEventListener("pointermove", (event) => {
|
|
203
|
+
if (pending && !drag && Math.hypot(event.clientX - pending.x, event.clientY - pending.y) > DRAG_DISTANCE) startDrag();
|
|
204
|
+
if (drag) moveDrag(event);
|
|
205
|
+
});
|
|
206
|
+
list.addEventListener("pointerup", (event) => { if (drag) finishDrag(event); pending = null; });
|
|
207
|
+
list.addEventListener("pointercancel", () => { endDrag(); pending = null; });
|
|
208
|
+
list.addEventListener("touchmove", (event) => { if (drag && event.cancelable) event.preventDefault(); }, { passive: false });
|
|
209
|
+
function startDrag() {
|
|
210
|
+
drag = { paths: pending.paths, drop: null };
|
|
211
|
+
list.setPointerCapture?.(pending.id);
|
|
212
|
+
root.dataset.dragging = "true";
|
|
213
|
+
doc.addEventListener("keydown", dragKey, true);
|
|
214
|
+
for (const path of drag.paths) itemOf(path)?.classList.add("is-dragging");
|
|
215
|
+
}
|
|
216
|
+
function dropAt(event) {
|
|
217
|
+
const rows = [...list.querySelectorAll(".opf-lx-item")].map((item) => ({ item, path: parsePath(item.dataset.path), row: item.firstElementChild }));
|
|
218
|
+
const hit = rows.find(({ row }) => { const box = row.getBoundingClientRect(); return event.clientY >= box.top && event.clientY <= box.bottom; });
|
|
219
|
+
if (!hit || drag.paths.some((path) => isPrefix(path, hit.path))) return null;
|
|
220
|
+
const box = hit.row.getBoundingClientRect();
|
|
221
|
+
const fraction = (event.clientY - box.top) / (box.height || 1);
|
|
222
|
+
const entry = hit.path.length ? nodeAt(model.record, hit.path) : model.record;
|
|
223
|
+
let listPath, index, mode;
|
|
224
|
+
if (!hit.path.length) { listPath = []; index = model.record.regions.length; mode = "into"; }
|
|
225
|
+
else if (isSplit(entry) && fraction >= 0.3 && fraction <= 0.7) { listPath = hit.path; index = (entry.regions ?? []).length; mode = "into"; }
|
|
226
|
+
else { listPath = hit.path.slice(0, -1); index = hit.path[hit.path.length - 1] + (fraction < 0.5 ? 0 : 1); mode = fraction < 0.5 ? "before" : "after"; }
|
|
227
|
+
let reason = "", noop = false;
|
|
228
|
+
try { noop = JSON.stringify(moveNodes(model.record, drag.paths, listPath, index).record) === JSON.stringify(model.record); } catch (error) { if (!(error instanceof LayoutOperationError)) throw error; reason = error.message; }
|
|
229
|
+
return { listPath, index, mode, reason, noop, box, item: hit.item };
|
|
230
|
+
}
|
|
231
|
+
function moveDrag(event) {
|
|
232
|
+
const found = dropAt(event);
|
|
233
|
+
drag.drop = found;
|
|
234
|
+
for (const node of list.querySelectorAll("[data-into]")) node.removeAttribute("data-into");
|
|
235
|
+
if (!found) { dropMark.hidden = true; return; }
|
|
236
|
+
const parent = scroller.getBoundingClientRect();
|
|
237
|
+
dropMark.hidden = false;
|
|
238
|
+
dropMark.dataset.mode = found.mode;
|
|
239
|
+
dropMark.dataset.state = found.reason ? "refused" : found.noop ? "same" : "ok";
|
|
240
|
+
dropMark.title = found.reason;
|
|
241
|
+
const top = found.mode === "after" ? found.box.bottom : found.mode === "before" ? found.box.top : found.box.top;
|
|
242
|
+
dropMark.style.top = `${top - parent.top + scroller.scrollTop}px`;
|
|
243
|
+
dropMark.style.left = `${found.box.left - parent.left}px`;
|
|
244
|
+
dropMark.style.width = `${found.box.width}px`;
|
|
245
|
+
dropMark.style.height = found.mode === "into" ? `${found.box.height}px` : "0px";
|
|
246
|
+
if (found.mode === "into") found.item.setAttribute("data-into", "true");
|
|
247
|
+
}
|
|
248
|
+
function finishDrag() {
|
|
249
|
+
const outcome = drag?.drop, paths = drag?.paths;
|
|
250
|
+
endDrag();
|
|
251
|
+
if (!outcome || !paths) return;
|
|
252
|
+
if (outcome.reason) { model.notify(outcome.reason, "error"); return; }
|
|
253
|
+
if (outcome.noop) return;
|
|
254
|
+
model.move(paths, outcome.listPath, outcome.index);
|
|
255
|
+
}
|
|
256
|
+
function endDrag() {
|
|
257
|
+
doc.removeEventListener("keydown", dragKey, true);
|
|
258
|
+
dropMark.hidden = true;
|
|
259
|
+
delete root.dataset.dragging;
|
|
260
|
+
for (const node of list.querySelectorAll(".is-dragging, [data-into]")) { node.classList.remove("is-dragging"); node.removeAttribute("data-into"); }
|
|
261
|
+
drag = null;
|
|
262
|
+
pending = null;
|
|
263
|
+
}
|
|
264
|
+
function dragKey(event) { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); endDrag(); model.notify("Move cancelled."); } }
|
|
265
|
+
|
|
266
|
+
// --- keyboard (the WAI-ARIA tree pattern) -------------------------------------------------------------------------------------------------
|
|
267
|
+
const visibleItems = () => [...list.querySelectorAll(".opf-lx-item")];
|
|
268
|
+
list.addEventListener("keydown", (event) => {
|
|
269
|
+
if (event.opfLayoutHandled || event.target.closest?.(".opf-lx-rename")) return;
|
|
270
|
+
if (handleGlobalShortcut(event, model)) return;
|
|
271
|
+
if (isTypingTarget(event.target)) return;
|
|
272
|
+
const item = event.target.closest?.(".opf-lx-item");
|
|
273
|
+
if (!item) return;
|
|
274
|
+
const path = parsePath(item.dataset.path);
|
|
275
|
+
if (event.key === "Escape") {
|
|
276
|
+
event.preventDefault();
|
|
277
|
+
if (menu) { menu.close(); return; }
|
|
278
|
+
if (drag) { endDrag(); return; }
|
|
279
|
+
if (path.length) { const parent = path.slice(0, -1); model.select(parent.length ? [parent] : []); itemOf(parent)?.focus({ preventScroll: true }); }
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (path.length && handleSelectionShortcut(event, model, "layers")) return;
|
|
283
|
+
const items = visibleItems();
|
|
284
|
+
const at = items.indexOf(item);
|
|
285
|
+
const go = (target) => { event.preventDefault(); target?.focus(); };
|
|
286
|
+
if (event.key === "ArrowDown") go(items[Math.min(items.length - 1, at + 1)]);
|
|
287
|
+
else if (event.key === "ArrowUp") go(items[Math.max(0, at - 1)]);
|
|
288
|
+
else if (event.key === "Home") go(items[0]);
|
|
289
|
+
else if (event.key === "End") go(items[items.length - 1]);
|
|
290
|
+
else if (event.key === "ArrowRight") {
|
|
291
|
+
if (item.hasAttribute("aria-expanded")) {
|
|
292
|
+
if (item.getAttribute("aria-expanded") === "false") { event.preventDefault(); toggle(path); itemOf(path)?.focus(); } else go(item.querySelector(":scope > ul > .opf-lx-item"));
|
|
293
|
+
}
|
|
294
|
+
} else if (event.key === "ArrowLeft") {
|
|
295
|
+
if (item.getAttribute("aria-expanded") === "true" && (path.length || false)) { event.preventDefault(); toggle(path); itemOf(path)?.focus(); }
|
|
296
|
+
else if (path.length) go(itemOf(path.slice(0, -1)));
|
|
297
|
+
} else if (event.key === "Enter" || event.key === " ") {
|
|
298
|
+
event.preventDefault();
|
|
299
|
+
if (path.length) model.select([path]); else model.select([]);
|
|
300
|
+
} else if (event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey)) {
|
|
301
|
+
if (!path.length) return;
|
|
302
|
+
event.preventDefault();
|
|
303
|
+
const box = item.firstElementChild.getBoundingClientRect();
|
|
304
|
+
openRowMenu({ x: box.left + 24, y: box.bottom }, item);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const unsubscribe = model.subscribe((event) => {
|
|
309
|
+
if (event.type === "notice" || event.type === "tool") return;
|
|
310
|
+
if (event.type === "view") { root.dataset.density = model.density; return; }
|
|
311
|
+
if (drag && event.type === "record") return;
|
|
312
|
+
render();
|
|
313
|
+
});
|
|
314
|
+
render();
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
element: root,
|
|
318
|
+
model,
|
|
319
|
+
refresh: render,
|
|
320
|
+
focus() { (itemOf(model.primary ?? []) ?? itemOf([]))?.focus(); },
|
|
321
|
+
/** Edit the name of the region at `path` in its row. */
|
|
322
|
+
beginRename(path) { model.requestRename(path, "layers"); },
|
|
323
|
+
destroy() {
|
|
324
|
+
unsubscribe();
|
|
325
|
+
endDrag();
|
|
326
|
+
menu?.close({ restore: false });
|
|
327
|
+
root.remove();
|
|
328
|
+
if (ownModel) model.destroy();
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// The state shared by the parts of the layout editor (RR-99): the record being edited, the selection, the active tool, live validation
|
|
2
|
+
// by core, and the editor's own undo history. The palette, canvas, layers and inspector are views of one model, so a host that lays
|
|
3
|
+
// them out differently only passes the same model to each. Every change goes through a command here, which runs a pure operation of
|
|
4
|
+
// `layout-tree.js`, validates the result and records one history step. No DOM, no network.
|
|
5
|
+
import { validateCatalogRecord } from "@openpresentation/opf";
|
|
6
|
+
import { layoutTemplateIssues } from "@openpresentation/opf/composition";
|
|
7
|
+
import {
|
|
8
|
+
LayoutOperationError, clone, deleteNodes, duplicateNodes, equalizePair, insertRegion, isSplit, listAt, moveIntoSibling, moveNodes, nodeAt, nodes, nudgePair,
|
|
9
|
+
parentDirection, removeDesignHint, reorderNode, samePath, setLayoutProperty, setProperty, splitRegion, toggleHeading, unwrapNode, wrapNodes,
|
|
10
|
+
} from "./layout-tree.js";
|
|
11
|
+
|
|
12
|
+
/** The `$schema` of a layout record; core's catalog validation expects it. */
|
|
13
|
+
export const LAYOUT_SCHEMA = "https://openpresentation.org/schema/opf-layout/v2";
|
|
14
|
+
export const LAYOUT_TOOLS = Object.freeze(["select", "split-row", "split-column", "insert", "heading", "hand"]);
|
|
15
|
+
const COALESCE_MS = 1500;
|
|
16
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
17
|
+
|
|
18
|
+
/** A blank layout: a title and one content region. */
|
|
19
|
+
export const blankLayoutRecord = () => ({ id: "my-layout", name: "My layout", direction: "column", regions: [{ name: "title" }, { name: "content" }] });
|
|
20
|
+
|
|
21
|
+
const unescape = (segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
22
|
+
/**
|
|
23
|
+
* Where a finding points: `node` is the path of the region or split (`[]` for the layout itself) and `field` the property
|
|
24
|
+
* (`size`, `name`, `direction`, `regions`...), read from the finding's JSON Pointer (`/regions/1/regions/0/size`).
|
|
25
|
+
*/
|
|
26
|
+
export function findingLocation(pointer) {
|
|
27
|
+
const parts = String(pointer ?? "").split("/").slice(1).map(unescape);
|
|
28
|
+
const node = [];
|
|
29
|
+
let at = 0;
|
|
30
|
+
while (parts[at] === "regions" && /^\d+$/.test(parts[at + 1] ?? "")) { node.push(Number(parts[at + 1])); at += 2; }
|
|
31
|
+
return { node, field: parts[at] ?? null };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Core's validation of a layout record: schema, `opf/layout-template` and `opf/layout-region`, as `{ ruleId, severity, message, path, node, field }`. */
|
|
35
|
+
export function validateLayoutRecord(record) {
|
|
36
|
+
let raw;
|
|
37
|
+
try {
|
|
38
|
+
raw = validateCatalogRecord("layouts", { ...record, $schema: LAYOUT_SCHEMA }).findings.map((finding) => ({ ruleId: finding.ruleId, severity: finding.severity, message: finding.message, path: finding.path ?? "" }));
|
|
39
|
+
} catch {
|
|
40
|
+
raw = layoutTemplateIssues(record).map((issue) => ({ ruleId: `opf/${issue.code}`, severity: "error", message: issue.message, path: issue.path }));
|
|
41
|
+
}
|
|
42
|
+
return raw.map((finding) => {
|
|
43
|
+
const located = { ...finding, ...findingLocation(finding.path) };
|
|
44
|
+
// A name used twice is reported on the later region; the earlier one is part of the problem, so it is marked too (`also`).
|
|
45
|
+
const twice = located.field === "name" ? /'([^']+)' is used twice/.exec(located.message) : null;
|
|
46
|
+
if (twice) located.also = nodes(record).filter(({ entry, path }) => entry.name === twice[1] && !samePath(path, located.node)).map(({ path }) => path);
|
|
47
|
+
return located;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The record as a catalog entry for `saveCustomLayout`: `{ id, record }` without `$schema` and `id` inside the record. */
|
|
52
|
+
export function layoutForCatalog(record) {
|
|
53
|
+
const { $schema, id, ...rest } = clone(record);
|
|
54
|
+
void $schema;
|
|
55
|
+
return { id, record: rest };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function describe(record, path) {
|
|
59
|
+
const node = nodeAt(record, path);
|
|
60
|
+
if (!node) return "the layout";
|
|
61
|
+
return isSplit(node) ? `the ${node.direction === "row" ? "row" : "column"} split` : node.name;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create the shared state. Options: `record` (default a blank layout), `density` ("comfortable" | "touch"), `readOnly`, `onChange(record)`
|
|
66
|
+
* (called after each edit, undo and redo, not after `setRecord`) and `historyLimit` (default 200 steps).
|
|
67
|
+
*/
|
|
68
|
+
export function createLayoutModel(options = {}) {
|
|
69
|
+
const listeners = new Set();
|
|
70
|
+
const state = {
|
|
71
|
+
record: normalize(options.record ?? blankLayoutRecord()),
|
|
72
|
+
selection: [],
|
|
73
|
+
tool: "select",
|
|
74
|
+
preview: false,
|
|
75
|
+
density: options.density === "touch" ? "touch" : "comfortable",
|
|
76
|
+
readOnly: Boolean(options.readOnly),
|
|
77
|
+
findings: [],
|
|
78
|
+
notice: null,
|
|
79
|
+
renaming: null,
|
|
80
|
+
};
|
|
81
|
+
const history = { entries: [], index: 0 };
|
|
82
|
+
const limit = options.historyLimit ?? 200;
|
|
83
|
+
let gesture = null;
|
|
84
|
+
let destroyed = false;
|
|
85
|
+
let onChange = options.onChange;
|
|
86
|
+
state.findings = validateLayoutRecord(state.record);
|
|
87
|
+
history.entries.push({ record: clone(state.record), selection: [] });
|
|
88
|
+
|
|
89
|
+
const emit = (event) => { if (!destroyed) for (const listener of [...listeners]) listener({ ...event, model }); };
|
|
90
|
+
const notice = (message, kind = "info") => { state.notice = message ? { message, kind, at: Date.now() } : null; emit({ type: "notice" }); };
|
|
91
|
+
const validate = () => { state.findings = validateLayoutRecord(state.record); };
|
|
92
|
+
const existing = (paths) => (paths ?? []).filter((path) => path.length && nodeAt(state.record, path));
|
|
93
|
+
const snapshot = () => ({ record: clone(state.record), selection: state.selection.map((path) => [...path]) });
|
|
94
|
+
const fire = () => { try { onChange?.(clone(state.record)); } catch (error) { console.error(error); } };
|
|
95
|
+
|
|
96
|
+
function push(snap, coalesce) {
|
|
97
|
+
const head = history.entries[history.index];
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
if (coalesce && head?.coalesce === coalesce && now - head.time < COALESCE_MS && history.index === history.entries.length - 1 && history.index > 0) {
|
|
100
|
+
history.entries[history.index] = { ...snap, coalesce, time: now };
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
history.entries.length = history.index + 1;
|
|
104
|
+
history.entries.push({ ...snap, coalesce, time: now });
|
|
105
|
+
if (history.entries.length > limit) history.entries.shift(); else history.index++;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Run `operation(record)` -> `{ record, selection }`; a refusal is a notice, and nothing changes. */
|
|
109
|
+
function run(message, operation, { coalesce } = {}) {
|
|
110
|
+
if (state.readOnly) { notice("This layout is read only.", "error"); return false; }
|
|
111
|
+
if (gesture) return false;
|
|
112
|
+
let outcome;
|
|
113
|
+
try { outcome = operation(state.record); } catch (error) {
|
|
114
|
+
if (error instanceof LayoutOperationError) { notice(error.message, "error"); return false; }
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
if (JSON.stringify(outcome.record) === JSON.stringify(state.record)) { if (outcome.selection) select(outcome.selection); return false; }
|
|
118
|
+
state.record = outcome.record;
|
|
119
|
+
if (outcome.selection) state.selection = outcome.selection.filter((path) => nodeAt(state.record, path));
|
|
120
|
+
else state.selection = existing(state.selection);
|
|
121
|
+
validate();
|
|
122
|
+
push(snapshot(), coalesce);
|
|
123
|
+
state.notice = message ? { message: typeof message === "function" ? message(outcome) : message, kind: "info", at: Date.now() } : null;
|
|
124
|
+
emit({ type: "record", message: state.notice?.message });
|
|
125
|
+
fire();
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function select(paths, { source } = {}) {
|
|
130
|
+
const next = existing(paths).map((path) => [...path]);
|
|
131
|
+
if (next.length === state.selection.length && next.every((path, index) => samePath(path, state.selection[index]))) return false;
|
|
132
|
+
state.selection = next;
|
|
133
|
+
emit({ type: "selection", source });
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const model = {
|
|
138
|
+
get record() { return state.record; },
|
|
139
|
+
get selection() { return state.selection; },
|
|
140
|
+
get primary() { return state.selection[state.selection.length - 1] ?? null; },
|
|
141
|
+
get tool() { return state.tool; },
|
|
142
|
+
get preview() { return state.preview; },
|
|
143
|
+
get density() { return state.density; },
|
|
144
|
+
get readOnly() { return state.readOnly; },
|
|
145
|
+
get findings() { return state.findings; },
|
|
146
|
+
get notice() { return state.notice; },
|
|
147
|
+
get renaming() { return state.renaming; },
|
|
148
|
+
get gesturing() { return gesture !== null; },
|
|
149
|
+
|
|
150
|
+
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
|
151
|
+
setOnChange(callback) { onChange = callback; },
|
|
152
|
+
getRecord: () => clone(state.record),
|
|
153
|
+
/** Replace the record (a layout the host loaded). Resets the selection and, by default, the history; does not call `onChange`. */
|
|
154
|
+
setRecord(record, { keepHistory = false } = {}) {
|
|
155
|
+
state.record = normalize(record);
|
|
156
|
+
state.selection = [];
|
|
157
|
+
gesture = null;
|
|
158
|
+
state.renaming = null;
|
|
159
|
+
validate();
|
|
160
|
+
if (keepHistory) { history.entries.length = history.index + 1; history.entries.push({ ...snapshot(), time: Date.now() }); history.index++; }
|
|
161
|
+
else { history.entries = [{ ...snapshot() }]; history.index = 0; }
|
|
162
|
+
emit({ type: "load" });
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
select,
|
|
166
|
+
/** Shift-click: add a sibling of the selection, else replace it. */
|
|
167
|
+
selectAdd(path) {
|
|
168
|
+
const current = state.selection;
|
|
169
|
+
if (!current.length || !existing([path]).length) return select([path]);
|
|
170
|
+
const parent = path.slice(0, -1);
|
|
171
|
+
if (!samePath(current[0].slice(0, -1), parent)) return select([path]);
|
|
172
|
+
const has = current.some((entry) => samePath(entry, path));
|
|
173
|
+
return select(has ? current.filter((entry) => !samePath(entry, path)) : [...current, path]);
|
|
174
|
+
},
|
|
175
|
+
selectParent() {
|
|
176
|
+
const path = model.primary;
|
|
177
|
+
if (!path) return false;
|
|
178
|
+
return select(path.length > 1 ? [path.slice(0, -1)] : []);
|
|
179
|
+
},
|
|
180
|
+
selectChild() {
|
|
181
|
+
const path = model.primary;
|
|
182
|
+
const node = path && nodeAt(state.record, path);
|
|
183
|
+
if (!node || !isSplit(node) || !node.regions?.length) return false;
|
|
184
|
+
return select([[...path, 0]]);
|
|
185
|
+
},
|
|
186
|
+
/** Move the selection to a sibling (`delta` -1 or 1), or to the first entry when nothing is selected. */
|
|
187
|
+
selectSibling(delta) {
|
|
188
|
+
const path = model.primary;
|
|
189
|
+
if (!path) return select(state.record.regions.length ? [[0]] : []);
|
|
190
|
+
const list = listAt(state.record, path.slice(0, -1));
|
|
191
|
+
const at = path[path.length - 1] + delta;
|
|
192
|
+
if (!list || at < 0 || at >= list.length) return false;
|
|
193
|
+
return select([[...path.slice(0, -1), at]]);
|
|
194
|
+
},
|
|
195
|
+
setTool(tool) {
|
|
196
|
+
if (!LAYOUT_TOOLS.includes(tool) || state.tool === tool) return false;
|
|
197
|
+
state.tool = tool;
|
|
198
|
+
emit({ type: "tool" });
|
|
199
|
+
return true;
|
|
200
|
+
},
|
|
201
|
+
setPreview(on) { if (state.preview === Boolean(on)) return false; state.preview = Boolean(on); emit({ type: "view" }); return true; },
|
|
202
|
+
setDensity(density) { const next = density === "touch" ? "touch" : "comfortable"; if (state.density === next) return; state.density = next; emit({ type: "view" }); },
|
|
203
|
+
setReadOnly(on) { state.readOnly = Boolean(on); emit({ type: "view" }); },
|
|
204
|
+
notify: notice,
|
|
205
|
+
/** Ask one surface (`"canvas"` or `"layers"`) to edit the name of the region at `path`; `null` ends it. */
|
|
206
|
+
requestRename(path, surface = "canvas") { state.renaming = path ? { path: [...path], surface } : null; emit({ type: "rename" }); },
|
|
207
|
+
|
|
208
|
+
// --- commands: each is one history step ---
|
|
209
|
+
splitRegion: (path, axis) => run((o) => `Split ${describe(o.record, path)} into a ${axis}`, (record) => splitRegion(record, path, axis)),
|
|
210
|
+
insertRegion: (listPath, index, entry) => run((o) => `Inserted ${describe(o.record, o.selection[0])}`, (record) => insertRegion(record, listPath, index, entry)),
|
|
211
|
+
toggleHeading: (name) => run((o) => (nodes(o.record).some(({ entry }) => entry.name === name) ? `Added the ${name} region` : `Removed the ${name} region`), (record) => toggleHeading(record, name)),
|
|
212
|
+
wrap: (paths, axis) => run(`Wrapped in a ${axis}`, (record) => wrapNodes(record, paths, axis)),
|
|
213
|
+
unwrap: (path) => run("Unwrapped the split", (record) => unwrapNode(record, path)),
|
|
214
|
+
duplicate: (paths) => run((o) => `Duplicated as ${o.selection.map((path) => describe(o.record, path)).join(", ")}`, (record) => duplicateNodes(record, paths)),
|
|
215
|
+
remove: (paths) => run(paths.length > 1 ? `Deleted ${paths.length} nodes` : `Deleted ${describe(state.record, paths[0])}`, (record) => deleteNodes(record, paths)),
|
|
216
|
+
move: (paths, listPath, index) => run((o) => `Moved ${o.selection.map((path) => describe(o.record, path)).join(", ")}`, (record) => moveNodes(record, paths, listPath, index)),
|
|
217
|
+
reorder: (path, delta) => run((o) => `Moved ${describe(o.record, o.selection[0])} ${delta < 0 ? "earlier" : "later"}`, (record) => reorderNode(record, path, delta)),
|
|
218
|
+
moveInto: (path, delta) => run((o) => `Moved ${describe(o.record, o.selection[0])} into the ${delta < 0 ? "previous" : "next"} split`, (record) => moveIntoSibling(record, path, delta)),
|
|
219
|
+
setProperty: (path, key, value, opts) => run(null, (record) => setProperty(record, path, key, value), { coalesce: opts?.coalesce ?? `p:${path.join(".")}:${key}` }),
|
|
220
|
+
setLayoutProperty: (key, value, opts) => run(null, (record) => setLayoutProperty(record, key, value), { coalesce: opts?.coalesce ?? `l:${key}` }),
|
|
221
|
+
removeDesignHint: (key) => run(`Removed the ${key} design hint`, (record) => removeDesignHint(record, key)),
|
|
222
|
+
equalize: (listPath, index) => run("Made the two sides equal", (record) => equalizePair(record, listPath, index)),
|
|
223
|
+
nudge: (listPath, index, steps, big) => run(null, (record) => nudgePair(record, listPath, index, steps, big), { coalesce: `n:${listPath.join(".")}:${index}` }),
|
|
224
|
+
|
|
225
|
+
// --- gestures: a drag is one step ---
|
|
226
|
+
beginGesture(label) {
|
|
227
|
+
if (state.readOnly || gesture) return false;
|
|
228
|
+
gesture = { label, record: state.record, selection: state.selection };
|
|
229
|
+
return true;
|
|
230
|
+
},
|
|
231
|
+
/** Show a record while the gesture runs (no history, no `onChange`). */
|
|
232
|
+
updateGesture(record, message) {
|
|
233
|
+
if (!gesture) return;
|
|
234
|
+
state.record = record;
|
|
235
|
+
validate();
|
|
236
|
+
state.notice = message ? { message, kind: "info", at: Date.now(), live: true } : state.notice;
|
|
237
|
+
emit({ type: "record", source: "gesture" });
|
|
238
|
+
},
|
|
239
|
+
endGesture(message) {
|
|
240
|
+
if (!gesture) return false;
|
|
241
|
+
const { record, label } = gesture;
|
|
242
|
+
gesture = null;
|
|
243
|
+
if (JSON.stringify(record) === JSON.stringify(state.record)) { emit({ type: "record", source: "gesture-end" }); return false; }
|
|
244
|
+
state.selection = existing(state.selection);
|
|
245
|
+
push(snapshot());
|
|
246
|
+
state.notice = { message: message ?? label, kind: "info", at: Date.now() };
|
|
247
|
+
emit({ type: "record", source: "gesture-end", message: state.notice.message });
|
|
248
|
+
fire();
|
|
249
|
+
return true;
|
|
250
|
+
},
|
|
251
|
+
cancelGesture() {
|
|
252
|
+
if (!gesture) return;
|
|
253
|
+
state.record = gesture.record;
|
|
254
|
+
state.selection = gesture.selection;
|
|
255
|
+
gesture = null;
|
|
256
|
+
validate();
|
|
257
|
+
emit({ type: "record", source: "gesture-cancel" });
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
// --- history ---
|
|
261
|
+
canUndo: () => history.index > 0 && !gesture,
|
|
262
|
+
canRedo: () => history.index < history.entries.length - 1 && !gesture,
|
|
263
|
+
undo() { return jump(-1); },
|
|
264
|
+
redo() { return jump(1); },
|
|
265
|
+
|
|
266
|
+
// --- findings ---
|
|
267
|
+
/** The findings that point at the node at `path` (`[]` is the layout), optionally of one `field`. */
|
|
268
|
+
findingsFor(path, field) {
|
|
269
|
+
return state.findings.filter((finding) => (samePath(finding.node, path) || finding.also?.some((other) => samePath(other, path))) && (field === undefined || finding.field === field));
|
|
270
|
+
},
|
|
271
|
+
/** The findings at or below the node at `path` (every finding for `[]`). */
|
|
272
|
+
findingsWithin(path) { return state.findings.filter((finding) => [finding.node, ...(finding.also ?? [])].some((node) => path.every((part, index) => node[index] === part))); },
|
|
273
|
+
nodeAt: (path) => nodeAt(state.record, path),
|
|
274
|
+
parentDirection: (path) => parentDirection(state.record, path),
|
|
275
|
+
describe: (path) => describe(state.record, path),
|
|
276
|
+
destroy() { destroyed = true; listeners.clear(); },
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
function jump(step) {
|
|
280
|
+
const target = history.index + step;
|
|
281
|
+
if (gesture || target < 0 || target >= history.entries.length) return false;
|
|
282
|
+
history.index = target;
|
|
283
|
+
const entry = history.entries[target];
|
|
284
|
+
state.record = clone(entry.record);
|
|
285
|
+
state.selection = existing(entry.selection);
|
|
286
|
+
validate();
|
|
287
|
+
state.notice = { message: step < 0 ? "Undid the last change" : "Redid the change", kind: "info", at: Date.now() };
|
|
288
|
+
emit({ type: "record", source: "history", message: state.notice.message });
|
|
289
|
+
fire();
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return model;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function normalize(record) {
|
|
297
|
+
if (!isObject(record) || !Array.isArray(record.regions)) throw new TypeError("A layout record needs a regions list.");
|
|
298
|
+
return clone(record);
|
|
299
|
+
}
|