@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.
- package/README.md +69 -6
- package/dist/galleries.js +2 -2
- package/dist/index.d.ts +8 -0
- package/dist/index.js +17 -0
- 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/persistence.js +2 -2
- package/dist/presenter.d.ts +4 -1
- package/dist/presenter.js +5 -3
- package/dist/slide-manager.js +3 -7
- package/dist/source-pane.d.ts +19 -1
- package/dist/source-pane.js +77 -1
- package/dist/styles.css +852 -31
- package/package.json +15 -10
- package/dist/custom-layout-editor.d.ts +0 -7
- package/dist/custom-layout-editor.js +0 -105
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
// The canvas of the layout editor (RR-99): a 16:9 slide frame that draws the region tree as labeled boxes and edits it directly. Select,
|
|
2
|
+
// split, insert, add the heading regions, pan, and preview with sample content; drag a divider to resize, drag a region to reorder or move
|
|
3
|
+
// it into another split; a context menu and the keyboard do the same. The boxes are positioned from `layoutBoxes` (layout-tree.js), so
|
|
4
|
+
// the canvas keeps no tree of its own: every edit is a command on the shared model, and every pointer gesture is one history step.
|
|
5
|
+
import { createLayoutModel } from "./layout-model.js";
|
|
6
|
+
import { LAYOUT_TOOL_DEFS } from "./layout-palette.js";
|
|
7
|
+
import { sampleDeck } from "./layout-samples.js";
|
|
8
|
+
import { whenFontsReady } from "./font-gate.js";
|
|
9
|
+
import {
|
|
10
|
+
LayoutOperationError, effectiveSize, isHeading, isPrefix, isSplit, layoutBoxes, listAt, moveNodes, nodeAt, pathKey, resizeSizes, samePath, setPairSizes,
|
|
11
|
+
} from "./layout-tree.js";
|
|
12
|
+
import {
|
|
13
|
+
SIZE_LABEL, buildWireframe, createDom, handleGlobalShortcut, handleSelectionShortcut, isTypingTarget, nextId, nodeMenuItems, openMenu, pct,
|
|
14
|
+
} from "./layout-editor-ui.js";
|
|
15
|
+
|
|
16
|
+
const ZOOMS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
|
|
17
|
+
const DRAG_DISTANCE = 4;
|
|
18
|
+
const HEADING_ITEMS = [{ name: "title", label: "Title region" }, { name: "subtitle", label: "Subtitle region" }];
|
|
19
|
+
const inside = (box, p, slack = 0) => p.x >= box.x - slack && p.x <= box.x + box.w + slack && p.y >= box.y - slack && p.y <= box.y + box.h + slack;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Mount the canvas in `container`. Options: `model` (shared state; one is created from the other options when omitted), `toSvg` and `fonts`
|
|
23
|
+
* (the host's renderer and fonts handle, for Preview; a wireframe is drawn without them), `renderOptions`, `label`. Returns `{ element,
|
|
24
|
+
* model, refresh, setZoom, getZoom, focus, destroy }`.
|
|
25
|
+
*/
|
|
26
|
+
export function createLayoutCanvas(container, options = {}) {
|
|
27
|
+
if (!container?.ownerDocument) throw new TypeError("A DOM container is required.");
|
|
28
|
+
const doc = container.ownerDocument, win = doc.defaultView;
|
|
29
|
+
const dom = createDom(doc);
|
|
30
|
+
const { el } = dom;
|
|
31
|
+
const ownModel = !options.model;
|
|
32
|
+
const model = options.model ?? createLayoutModel(options);
|
|
33
|
+
const id = nextId("opf-lx-canvas");
|
|
34
|
+
|
|
35
|
+
const root = el("div", "opf-lx-canvas opf-ui", { "data-density": model.density, "data-tool": model.tool });
|
|
36
|
+
const viewport = el("div", "opf-lx-viewport");
|
|
37
|
+
const stage = el("div", "opf-lx-stage");
|
|
38
|
+
const frame = el("div", "opf-lx-frame", { role: "group", "aria-label": options.label ?? "Layout canvas, a 16 by 9 slide", tabindex: "-1" });
|
|
39
|
+
const rendered = el("div", "opf-lx-render", { "aria-hidden": "true" });
|
|
40
|
+
const content = el("div", "opf-lx-content");
|
|
41
|
+
const implicit = el("div", "opf-lx-implicit", { "aria-hidden": "true" });
|
|
42
|
+
implicit.append(el("span", "opf-lx-implicit-label", { text: "title · implicit" }));
|
|
43
|
+
const layer = el("div", "opf-lx-layer");
|
|
44
|
+
const overlay = el("div", "opf-lx-overlay");
|
|
45
|
+
const cut = el("div", "opf-lx-cut", { "aria-hidden": "true" });
|
|
46
|
+
const drop = el("div", "opf-lx-drop", { "aria-hidden": "true" });
|
|
47
|
+
const ratio = el("div", "opf-lx-ratio", { "aria-hidden": "true" });
|
|
48
|
+
cut.hidden = true;
|
|
49
|
+
drop.hidden = true;
|
|
50
|
+
ratio.hidden = true;
|
|
51
|
+
overlay.append(cut, drop, ratio);
|
|
52
|
+
content.append(implicit, layer, overlay);
|
|
53
|
+
frame.append(rendered, content);
|
|
54
|
+
stage.append(frame);
|
|
55
|
+
viewport.append(stage);
|
|
56
|
+
const banner = el("p", "opf-lx-banner", { role: "alert" });
|
|
57
|
+
banner.hidden = true;
|
|
58
|
+
const footer = el("div", "opf-lx-footer");
|
|
59
|
+
const status = el("p", "opf-lx-status", { role: "status", "aria-live": "polite" });
|
|
60
|
+
const alert = el("p", "opf-lx-alert", { role: "alert" });
|
|
61
|
+
const zoombar = el("div", "opf-lx-zoombar", { role: "group", "aria-label": "Zoom" });
|
|
62
|
+
const zoomOut = dom.button("opf-lx-zoom-button", { label: "Zoom out", iconName: "ZoomOut", size: 16 });
|
|
63
|
+
const zoomLabel = el("span", "opf-lx-zoom-label", { "aria-live": "off" });
|
|
64
|
+
const zoomIn = dom.button("opf-lx-zoom-button", { label: "Zoom in", iconName: "ZoomIn", size: 16 });
|
|
65
|
+
const zoomFit = dom.button("opf-lx-zoom-button", { label: "Fit to screen", iconName: "Maximize", size: 16 });
|
|
66
|
+
zoombar.append(zoomOut, zoomLabel, zoomIn, zoomFit);
|
|
67
|
+
footer.append(status, alert, zoombar);
|
|
68
|
+
root.append(banner, viewport, footer);
|
|
69
|
+
container.append(root);
|
|
70
|
+
|
|
71
|
+
const keyed = new Map();
|
|
72
|
+
let geo = layoutBoxes(model.record);
|
|
73
|
+
let hover = null;
|
|
74
|
+
let zoom = 1;
|
|
75
|
+
let spaceHeld = false;
|
|
76
|
+
let drag = null;
|
|
77
|
+
let pending = null;
|
|
78
|
+
let pan = null;
|
|
79
|
+
let divider = null;
|
|
80
|
+
let menu = null;
|
|
81
|
+
let renameInput = null;
|
|
82
|
+
let headingMenu = null;
|
|
83
|
+
let previewToken = 0;
|
|
84
|
+
let previewTimer = 0;
|
|
85
|
+
let lastPreview = "";
|
|
86
|
+
|
|
87
|
+
// --- geometry helpers -------------------------------------------------------------------------------------------------------
|
|
88
|
+
const unit = (event) => {
|
|
89
|
+
const box = content.getBoundingClientRect();
|
|
90
|
+
return { x: (event.clientX - box.left) / (box.width || 1), y: (event.clientY - box.top) / (box.height || 1) };
|
|
91
|
+
};
|
|
92
|
+
const boxOf = (path) => geo.nodes.find((box) => samePath(box.path, path));
|
|
93
|
+
const chainAt = (point, skip) => geo.nodes.filter((box) => inside(box, point, 0.0005) && !(skip && skip(box))).sort((a, b) => a.depth - b.depth);
|
|
94
|
+
const nodeElement = (path) => keyed.get(`n:${pathKey(path)}`);
|
|
95
|
+
const isSelected = (path) => model.selection.some((entry) => samePath(entry, path));
|
|
96
|
+
|
|
97
|
+
function setBox(node, box, pad = 0) {
|
|
98
|
+
node.style.left = pct(box.x - pad);
|
|
99
|
+
node.style.top = pct(box.y - pad);
|
|
100
|
+
node.style.width = pct(box.w + 2 * pad);
|
|
101
|
+
node.style.height = pct(box.h + 2 * pad);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- the descriptions a screen reader hears ---------------------------------------------------------------------------------
|
|
105
|
+
function describeNode(box) {
|
|
106
|
+
const { entry, path, direction } = box;
|
|
107
|
+
const list = listAt(model.record, path.slice(0, -1)) ?? [];
|
|
108
|
+
const place = `${path[path.length - 1] + 1} of ${list.length} in a ${direction}`;
|
|
109
|
+
const issues = model.findingsFor(path);
|
|
110
|
+
const problem = issues.length ? `. ${issues.length === 1 ? "1 finding" : `${issues.length} findings`}: ${issues[0].message}` : "";
|
|
111
|
+
if (isSplit(entry)) return `${entry.direction === "row" ? "Row" : "Column"} split of ${(entry.regions ?? []).length}, ${place}${problem}`;
|
|
112
|
+
return `${entry.name}, ${isHeading(entry) ? "heading region" : "region"}, size ${SIZE_LABEL(entry, direction)}, ${place}${problem}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --- render ---------------------------------------------------------------------------------------------------------------
|
|
116
|
+
function ensure(key, create, seen) {
|
|
117
|
+
let node = keyed.get(key);
|
|
118
|
+
if (!node) { node = create(); keyed.set(key, node); }
|
|
119
|
+
seen.add(key);
|
|
120
|
+
return node;
|
|
121
|
+
}
|
|
122
|
+
function makeNode() {
|
|
123
|
+
const node = el("div", "opf-lx-node");
|
|
124
|
+
node.append(el("span", "opf-lx-label", { "aria-hidden": "true" }));
|
|
125
|
+
node.addEventListener("click", (event) => { if (event.detail === 0 && node.dataset.path !== undefined) model.select([node.dataset.path.split(".").map(Number)]); });
|
|
126
|
+
return node;
|
|
127
|
+
}
|
|
128
|
+
function makeDivider(key) {
|
|
129
|
+
const node = el("div", "opf-lx-divider");
|
|
130
|
+
node.dataset.key = key;
|
|
131
|
+
node.addEventListener("pointerdown", (event) => startResize(event, node));
|
|
132
|
+
node.addEventListener("dblclick", () => { if (node._info?.draggable) model.equalize(node._info.listPath, node._info.index); });
|
|
133
|
+
node.addEventListener("keydown", (event) => dividerKey(event, node));
|
|
134
|
+
return node;
|
|
135
|
+
}
|
|
136
|
+
function makeSlot() {
|
|
137
|
+
const node = dom.button("opf-lx-slot", {});
|
|
138
|
+
node.append(dom.icon("Plus", 14));
|
|
139
|
+
node.addEventListener("click", () => {
|
|
140
|
+
const info = node._info;
|
|
141
|
+
if (!info) return;
|
|
142
|
+
if (model.insertRegion(info.listPath, info.index)) model.setTool("select");
|
|
143
|
+
});
|
|
144
|
+
return node;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function render() {
|
|
148
|
+
// Focus that is on a box (or the frame) follows the selection afterwards; a divider, slot, menu or field keeps its own.
|
|
149
|
+
const keepFocus = doc.activeElement === frame || Boolean(doc.activeElement?.classList?.contains("opf-lx-node"));
|
|
150
|
+
geo = layoutBoxes(model.record);
|
|
151
|
+
const seen = new Set();
|
|
152
|
+
const order = [];
|
|
153
|
+
const primary = model.primary;
|
|
154
|
+
const tabStop = primary && boxOf(primary) ? pathKey(primary) : geo.nodes[0] ? pathKey(geo.nodes[0].path) : null;
|
|
155
|
+
for (const box of geo.nodes) {
|
|
156
|
+
const key = `n:${pathKey(box.path)}`;
|
|
157
|
+
const node = ensure(key, makeNode, seen);
|
|
158
|
+
const split = isSplit(box.entry);
|
|
159
|
+
const issues = model.findingsFor(box.path);
|
|
160
|
+
node.dataset.path = pathKey(box.path);
|
|
161
|
+
node.dataset.kind = split ? "split" : "region";
|
|
162
|
+
node.dataset.depth = String(box.depth);
|
|
163
|
+
if (split) { delete node.dataset.name; node.dataset.direction = box.entry.direction === "row" ? "row" : "column"; }
|
|
164
|
+
else { node.dataset.name = box.entry.name; delete node.dataset.direction; }
|
|
165
|
+
node.dataset.heading = !split && isHeading(box.entry) ? "true" : "false";
|
|
166
|
+
node.dataset.finding = issues.length ? issues[0].severity : model.findingsWithin(box.path).length ? "within" : "";
|
|
167
|
+
node.classList.toggle("is-selected", isSelected(box.path));
|
|
168
|
+
node.classList.toggle("is-dragging", Boolean(drag?.valid?.some((path) => isPrefix(path, box.path))));
|
|
169
|
+
node.setAttribute("role", split ? "group" : "button");
|
|
170
|
+
node.setAttribute("aria-label", describeNode(box));
|
|
171
|
+
if (split) node.removeAttribute("aria-pressed"); else node.setAttribute("aria-pressed", String(isSelected(box.path)));
|
|
172
|
+
node.tabIndex = pathKey(box.path) === tabStop ? 0 : -1;
|
|
173
|
+
setBox(node, box);
|
|
174
|
+
// The name and size are a chip outside the box's top edge (a split's tag sits at the top right), so they never cover the content drawn
|
|
175
|
+
// inside. The words come from data attributes (CSS content), so the box's own accessible name is the one description a screen reader hears.
|
|
176
|
+
const label = node.firstElementChild;
|
|
177
|
+
label.dataset.text = split ? (box.entry.direction === "row" ? "row" : "column") : box.entry.name;
|
|
178
|
+
label.dataset.size = SIZE_LABEL(box.entry, box.direction);
|
|
179
|
+
let badge = node.querySelector(":scope > .opf-lx-badge");
|
|
180
|
+
if (issues.length && !badge) { badge = el("span", "opf-lx-badge", { "aria-hidden": "true" }); badge.append(dom.icon("CircleX", 14)); node.append(badge); }
|
|
181
|
+
if (badge) { if (issues.length) badge.title = issues.map((issue) => issue.message).join("\n"); else badge.remove(); }
|
|
182
|
+
let wire = node.querySelector(":scope > .opf-lx-wire");
|
|
183
|
+
const wanted = model.preview && !split;
|
|
184
|
+
const wireKey = wanted ? JSON.stringify([isHeading(box.entry), box.entry.accepts, box.entry.flow, box.entry.max]) : "";
|
|
185
|
+
if (wire && (!wanted || wire.dataset.key !== wireKey)) { wire.remove(); wire = null; }
|
|
186
|
+
if (wanted && !wire) {
|
|
187
|
+
if (isHeading(box.entry)) { wire = el("div", "opf-lx-wire opf-lx-wire-heading", { "aria-hidden": "true" }); wire.append(el("span", "opf-lx-bar"), el("span", "opf-lx-bar opf-lx-bar-short")); }
|
|
188
|
+
else wire = buildWireframe(dom, box.entry.accepts, { count: box.entry.flow === "none" ? 1 : Math.min(3, box.entry.max ?? 3), flow: box.entry.flow ?? "auto" });
|
|
189
|
+
wire.dataset.key = wireKey;
|
|
190
|
+
node.append(wire);
|
|
191
|
+
}
|
|
192
|
+
order.push(node);
|
|
193
|
+
}
|
|
194
|
+
// Dividers: the gap between two siblings.
|
|
195
|
+
for (const info of geo.dividers) {
|
|
196
|
+
const key = `d:${pathKey(info.listPath)}:${info.index}`;
|
|
197
|
+
const node = ensure(key, () => makeDivider(key), seen);
|
|
198
|
+
node._info = info;
|
|
199
|
+
node.dataset.orientation = info.direction === "row" ? "vertical" : "horizontal";
|
|
200
|
+
node.dataset.depth = String(info.depth);
|
|
201
|
+
node.dataset.fixed = info.draggable ? "false" : "true";
|
|
202
|
+
node.dataset.active = divider?.key === key ? "true" : "false";
|
|
203
|
+
setBox(node, info);
|
|
204
|
+
node.setAttribute("role", "separator");
|
|
205
|
+
node.setAttribute("aria-orientation", info.direction === "row" ? "vertical" : "horizontal");
|
|
206
|
+
const first = [...info.listPath, info.index], second = [...info.listPath, info.index + 1];
|
|
207
|
+
if (info.draggable) {
|
|
208
|
+
const total = info.a + info.b;
|
|
209
|
+
node.tabIndex = model.tool === "select" || model.tool === "hand" ? 0 : -1;
|
|
210
|
+
node.setAttribute("aria-label", `Resize ${model.describe(first)} and ${model.describe(second)}`);
|
|
211
|
+
node.setAttribute("aria-valuemin", "0");
|
|
212
|
+
node.setAttribute("aria-valuemax", "100");
|
|
213
|
+
node.setAttribute("aria-valuenow", String(Math.round(info.a / total * 100)));
|
|
214
|
+
node.setAttribute("aria-valuetext", `${trim(info.a)} : ${trim(info.b)}`);
|
|
215
|
+
node.removeAttribute("aria-disabled");
|
|
216
|
+
node.title = "Drag to resize. Double-click to make both sides equal.";
|
|
217
|
+
} else {
|
|
218
|
+
node.removeAttribute("tabindex");
|
|
219
|
+
for (const name of ["aria-label", "aria-valuemin", "aria-valuemax", "aria-valuenow", "aria-valuetext", "title"]) node.removeAttribute(name);
|
|
220
|
+
node.setAttribute("aria-disabled", "true");
|
|
221
|
+
}
|
|
222
|
+
order.push(node);
|
|
223
|
+
}
|
|
224
|
+
// Insert slots, only for the Insert tool.
|
|
225
|
+
if (model.tool === "insert" && !model.readOnly) {
|
|
226
|
+
geo.slots.forEach((slot) => {
|
|
227
|
+
const key = `s:${pathKey(slot.listPath)}:${slot.index}`;
|
|
228
|
+
const node = ensure(key, makeSlot, seen);
|
|
229
|
+
node._info = slot;
|
|
230
|
+
node.dataset.orientation = slot.direction === "row" ? "vertical" : "horizontal";
|
|
231
|
+
node.dataset.depth = String(slot.depth);
|
|
232
|
+
const list = listAt(model.record, slot.listPath) ?? [];
|
|
233
|
+
node.disabled = list.length >= 12;
|
|
234
|
+
const before = list[slot.index - 1], after = list[slot.index];
|
|
235
|
+
const names = (entry) => (isSplit(entry) ? `the ${entry.direction} split` : entry.name);
|
|
236
|
+
node.setAttribute("aria-label", before && after ? `Insert a region between ${names(before)} and ${names(after)}` : after ? `Insert a region before ${names(after)}` : before ? `Insert a region after ${names(before)}` : "Insert a region");
|
|
237
|
+
const thick = slot.direction === "row" ? { x: slot.x, y: slot.y, w: 0, h: slot.h } : { x: slot.x, y: slot.y, w: slot.w, h: 0 };
|
|
238
|
+
node.style.left = slot.direction === "row" ? `calc(${pct(thick.x)} - var(--opf-lx-slot) / 2)` : pct(thick.x);
|
|
239
|
+
node.style.top = slot.direction === "row" ? pct(thick.y) : `calc(${pct(thick.y)} - var(--opf-lx-slot) / 2)`;
|
|
240
|
+
node.style.width = slot.direction === "row" ? "var(--opf-lx-slot)" : pct(thick.w);
|
|
241
|
+
node.style.height = slot.direction === "row" ? pct(thick.h) : "var(--opf-lx-slot)";
|
|
242
|
+
order.push(node);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
for (const [key, node] of keyed) if (!seen.has(key)) { node.remove(); keyed.delete(key); }
|
|
246
|
+
let cursor = null;
|
|
247
|
+
for (const node of order) {
|
|
248
|
+
const expected = cursor ? cursor.nextSibling : layer.firstChild;
|
|
249
|
+
if (node.parentNode !== layer || expected !== node) layer.insertBefore(node, expected);
|
|
250
|
+
cursor = node;
|
|
251
|
+
}
|
|
252
|
+
// The implicit title band and the layout-level findings.
|
|
253
|
+
implicit.hidden = !geo.implicitTitle;
|
|
254
|
+
if (geo.implicitTitle) setBox(implicit, geo.implicitTitle);
|
|
255
|
+
const layoutIssues = model.findingsFor([]);
|
|
256
|
+
banner.hidden = !layoutIssues.length;
|
|
257
|
+
banner.replaceChildren();
|
|
258
|
+
if (layoutIssues.length) { banner.append(dom.icon("CircleX", 14), el("span", "", { text: layoutIssues.map((issue) => issue.message).join(" ") })); }
|
|
259
|
+
root.dataset.tool = model.tool;
|
|
260
|
+
root.dataset.density = model.density;
|
|
261
|
+
root.dataset.preview = String(model.preview);
|
|
262
|
+
root.dataset.readonly = String(model.readOnly);
|
|
263
|
+
paintHover();
|
|
264
|
+
paintStatus();
|
|
265
|
+
syncRename();
|
|
266
|
+
syncHeadingMenu();
|
|
267
|
+
schedulePreview();
|
|
268
|
+
if (keepFocus) syncFocus();
|
|
269
|
+
}
|
|
270
|
+
const trim = (value) => String(Math.round(value * 1000) / 1000);
|
|
271
|
+
|
|
272
|
+
function paintHover() {
|
|
273
|
+
for (const node of layer.querySelectorAll(".opf-lx-node[data-hover]")) node.removeAttribute("data-hover");
|
|
274
|
+
if (hover && !drag && !divider) nodeElement(hover)?.setAttribute("data-hover", "true");
|
|
275
|
+
}
|
|
276
|
+
function paintStatus() {
|
|
277
|
+
const notice = model.notice;
|
|
278
|
+
const def = LAYOUT_TOOL_DEFS.find((tool) => tool.id === model.tool);
|
|
279
|
+
status.textContent = notice && notice.kind !== "error" ? notice.message : def?.help ?? "";
|
|
280
|
+
status.dataset.live = notice && notice.kind !== "error" ? "true" : "false";
|
|
281
|
+
alert.textContent = notice?.kind === "error" ? notice.message : "";
|
|
282
|
+
alert.hidden = !alert.textContent;
|
|
283
|
+
zoomLabel.textContent = `${Math.round(zoom * 100)}%`;
|
|
284
|
+
}
|
|
285
|
+
/** Put DOM focus on the selected box (or the frame, when nothing is selected), after the structure changed under it. */
|
|
286
|
+
function syncFocus() {
|
|
287
|
+
if (renameInput || menu || headingMenu) return;
|
|
288
|
+
const primary = model.primary;
|
|
289
|
+
const next = primary ? nodeElement(primary) : null;
|
|
290
|
+
if (next) { if (doc.activeElement !== next) next.focus({ preventScroll: true }); }
|
|
291
|
+
else if (doc.activeElement !== frame) frame.focus({ preventScroll: true });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// --- pointer: select, split, drag, resize, pan -----------------------------------------------------------------------------------
|
|
295
|
+
function target(chain, event) {
|
|
296
|
+
if (!chain.length) return null;
|
|
297
|
+
const deepest = chain[chain.length - 1];
|
|
298
|
+
const primary = model.primary;
|
|
299
|
+
// A split is selected: the next click goes one level in, to the box of the chain under the pointer.
|
|
300
|
+
if (primary && !event.shiftKey) {
|
|
301
|
+
const at = chain.findIndex((box) => samePath(box.path, primary));
|
|
302
|
+
if (at >= 0 && isSplit(chain[at].entry) && at < chain.length - 1) return chain[at + 1];
|
|
303
|
+
}
|
|
304
|
+
return deepest;
|
|
305
|
+
}
|
|
306
|
+
const panMode = () => model.tool === "hand" || spaceHeld;
|
|
307
|
+
|
|
308
|
+
viewport.addEventListener("pointerdown", (event) => {
|
|
309
|
+
if (!panMode() || (event.pointerType === "mouse" && event.button !== 0)) return;
|
|
310
|
+
event.preventDefault();
|
|
311
|
+
event.stopPropagation();
|
|
312
|
+
pan = { x: event.clientX, y: event.clientY, left: viewport.scrollLeft, top: viewport.scrollTop };
|
|
313
|
+
root.dataset.panning = "true";
|
|
314
|
+
viewport.setPointerCapture?.(event.pointerId);
|
|
315
|
+
}, true);
|
|
316
|
+
viewport.addEventListener("pointermove", (event) => {
|
|
317
|
+
if (!pan) return;
|
|
318
|
+
viewport.scrollLeft = pan.left - (event.clientX - pan.x);
|
|
319
|
+
viewport.scrollTop = pan.top - (event.clientY - pan.y);
|
|
320
|
+
});
|
|
321
|
+
const endPan = () => { pan = null; delete root.dataset.panning; };
|
|
322
|
+
viewport.addEventListener("pointerup", endPan);
|
|
323
|
+
viewport.addEventListener("pointercancel", endPan);
|
|
324
|
+
|
|
325
|
+
content.addEventListener("pointerdown", (event) => {
|
|
326
|
+
if (event.target.closest?.(".opf-lx-divider, .opf-lx-slot, .opf-lx-menu, .opf-lx-rename") || panMode()) return;
|
|
327
|
+
if (event.pointerType === "mouse" && event.button !== 0) return;
|
|
328
|
+
const point = unit(event);
|
|
329
|
+
const chain = chainAt(point);
|
|
330
|
+
menu?.close({ restore: false });
|
|
331
|
+
if (model.tool === "split-row" || model.tool === "split-column") {
|
|
332
|
+
const region = [...chain].reverse().find((box) => !isSplit(box.entry));
|
|
333
|
+
if (!region) { model.notify("Click inside a region to split it.", "error"); return; }
|
|
334
|
+
event.preventDefault();
|
|
335
|
+
const axis = model.tool === "split-row" ? "row" : "column";
|
|
336
|
+
if (model.splitRegion(region.path, axis)) model.setTool("select");
|
|
337
|
+
focusPrimary();
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (model.tool !== "select") return;
|
|
341
|
+
const hit = target(chain, event);
|
|
342
|
+
if (!hit) { model.select([]); frame.focus({ preventScroll: true }); return; }
|
|
343
|
+
if (event.shiftKey) { model.selectAdd(hit.path); focusPrimary(); return; }
|
|
344
|
+
if (!isSelected(hit.path)) model.select([hit.path]);
|
|
345
|
+
focusPrimary();
|
|
346
|
+
if (model.readOnly) return;
|
|
347
|
+
const paths = isSelected(hit.path) ? model.selection.map((path) => [...path]) : [hit.path];
|
|
348
|
+
pending = { x: event.clientX, y: event.clientY, id: event.pointerId, paths, type: event.pointerType };
|
|
349
|
+
content.setPointerCapture?.(event.pointerId);
|
|
350
|
+
});
|
|
351
|
+
// The margin of the slide, outside the content box: a click there selects the layout.
|
|
352
|
+
frame.addEventListener("pointerdown", (event) => {
|
|
353
|
+
if (event.target !== frame || panMode() || model.tool !== "select" || (event.pointerType === "mouse" && event.button !== 0)) return;
|
|
354
|
+
menu?.close({ restore: false });
|
|
355
|
+
model.select([]);
|
|
356
|
+
frame.focus({ preventScroll: true });
|
|
357
|
+
});
|
|
358
|
+
content.addEventListener("pointermove", (event) => {
|
|
359
|
+
if (pending && !drag && Math.hypot(event.clientX - pending.x, event.clientY - pending.y) > DRAG_DISTANCE) startDrag();
|
|
360
|
+
if (drag) { moveDrag(event); return; }
|
|
361
|
+
if (event.pointerType === "touch") return;
|
|
362
|
+
// Hover: the node a click would select, and the guide of the split tools.
|
|
363
|
+
const point = unit(event);
|
|
364
|
+
const chain = chainAt(point);
|
|
365
|
+
let next = null;
|
|
366
|
+
if (model.tool === "select") next = target(chain, event)?.path ?? null;
|
|
367
|
+
else if (model.tool === "split-row" || model.tool === "split-column") next = [...chain].reverse().find((box) => !isSplit(box.entry))?.path ?? null;
|
|
368
|
+
if (!(next === hover || (next && hover && samePath(next, hover)))) { hover = next; paintHover(); }
|
|
369
|
+
paintCut();
|
|
370
|
+
});
|
|
371
|
+
content.addEventListener("pointerleave", () => { hover = null; paintHover(); paintCut(); });
|
|
372
|
+
content.addEventListener("pointerup", (event) => {
|
|
373
|
+
if (drag) { finishDrag(event); return; }
|
|
374
|
+
pending = null;
|
|
375
|
+
});
|
|
376
|
+
content.addEventListener("pointercancel", () => { cancelDrag(); pending = null; });
|
|
377
|
+
content.addEventListener("dblclick", (event) => {
|
|
378
|
+
if (model.tool !== "select" || event.target.closest?.(".opf-lx-divider, .opf-lx-slot, .opf-lx-rename") || model.readOnly) return;
|
|
379
|
+
const region = [...chainAt(unit(event))].reverse().find((box) => !isSplit(box.entry));
|
|
380
|
+
if (region) { model.select([region.path]); model.requestRename(region.path, "canvas"); }
|
|
381
|
+
});
|
|
382
|
+
function paintCut() {
|
|
383
|
+
const tool = model.tool;
|
|
384
|
+
const box = (tool === "split-row" || tool === "split-column") && hover ? boxOf(hover) : null;
|
|
385
|
+
cut.hidden = !box;
|
|
386
|
+
if (!box) return;
|
|
387
|
+
if (tool === "split-row") Object.assign(cut.style, { left: pct(box.x + box.w / 2), top: pct(box.y), width: "0", height: pct(box.h) });
|
|
388
|
+
else Object.assign(cut.style, { left: pct(box.x), top: pct(box.y + box.h / 2), width: pct(box.w), height: "0" });
|
|
389
|
+
cut.dataset.axis = tool === "split-row" ? "row" : "column";
|
|
390
|
+
}
|
|
391
|
+
function focusPrimary() {
|
|
392
|
+
const primary = model.primary;
|
|
393
|
+
const node = primary && nodeElement(primary);
|
|
394
|
+
(node ?? frame).focus({ preventScroll: true });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// --- drag a region to reorder it or move it into a split ----------------------------------------------------------------------
|
|
398
|
+
function startDrag() {
|
|
399
|
+
if (!pending || model.readOnly) { pending = null; return; }
|
|
400
|
+
const paths = pending.paths.filter((path) => nodeAt(model.record, path));
|
|
401
|
+
if (!paths.length) { pending = null; return; }
|
|
402
|
+
const ghost = el("div", "opf-lx-ghost", { "aria-hidden": "true", text: paths.length > 1 ? `${paths.length} regions` : model.describe(paths[0]) });
|
|
403
|
+
root.append(ghost);
|
|
404
|
+
drag = { paths, valid: paths, ghost, drop: null };
|
|
405
|
+
root.dataset.dragging = "true";
|
|
406
|
+
doc.addEventListener("keydown", dragKey, true);
|
|
407
|
+
paintHover();
|
|
408
|
+
render();
|
|
409
|
+
}
|
|
410
|
+
function dropAt(point) {
|
|
411
|
+
const skip = (box) => drag.paths.some((path) => isPrefix(path, box.path));
|
|
412
|
+
const chain = chainAt(point, skip);
|
|
413
|
+
const hit = chain[chain.length - 1];
|
|
414
|
+
if (!hit) return null;
|
|
415
|
+
let listPath, index, into = null;
|
|
416
|
+
if (isSplit(hit.entry)) {
|
|
417
|
+
listPath = hit.path;
|
|
418
|
+
const row = hit.entry.direction === "row";
|
|
419
|
+
const kids = geo.nodes.filter((box) => box.path.length === hit.path.length + 1 && isPrefix(hit.path, box.path) && !skip(box));
|
|
420
|
+
index = (hit.entry.regions ?? []).length;
|
|
421
|
+
for (const box of kids) if ((row ? point.x : point.y) < (row ? box.x + box.w / 2 : box.y + box.h / 2)) { index = box.path[box.path.length - 1]; break; }
|
|
422
|
+
into = hit.path;
|
|
423
|
+
} else {
|
|
424
|
+
listPath = hit.path.slice(0, -1);
|
|
425
|
+
const row = hit.direction === "row";
|
|
426
|
+
const after = (row ? point.x : point.y) > (row ? hit.x + hit.w / 2 : hit.y + hit.h / 2);
|
|
427
|
+
index = hit.path[hit.path.length - 1] + (after ? 1 : 0);
|
|
428
|
+
}
|
|
429
|
+
const slot = geo.slots.find((entry) => samePath(entry.listPath, listPath) && entry.index === index);
|
|
430
|
+
let reason = "";
|
|
431
|
+
let noop = false;
|
|
432
|
+
try {
|
|
433
|
+
const moved = moveNodes(model.record, drag.paths, listPath, index);
|
|
434
|
+
noop = JSON.stringify(moved.record) === JSON.stringify(model.record);
|
|
435
|
+
} catch (error) {
|
|
436
|
+
if (!(error instanceof LayoutOperationError)) throw error;
|
|
437
|
+
reason = error.message;
|
|
438
|
+
}
|
|
439
|
+
return { listPath, index, slot, into, reason, noop };
|
|
440
|
+
}
|
|
441
|
+
function moveDrag(event) {
|
|
442
|
+
drag.ghost.style.left = `${event.clientX - root.getBoundingClientRect().left + 12}px`;
|
|
443
|
+
drag.ghost.style.top = `${event.clientY - root.getBoundingClientRect().top + 12}px`;
|
|
444
|
+
const found = dropAt(unit(event));
|
|
445
|
+
drag.drop = found;
|
|
446
|
+
for (const node of layer.querySelectorAll("[data-into]")) node.removeAttribute("data-into");
|
|
447
|
+
if (!found?.slot) { drop.hidden = true; return; }
|
|
448
|
+
drop.hidden = false;
|
|
449
|
+
drop.dataset.orientation = found.slot.direction === "row" ? "vertical" : "horizontal";
|
|
450
|
+
drop.dataset.state = found.reason ? "refused" : found.noop ? "same" : "ok";
|
|
451
|
+
drop.title = found.reason;
|
|
452
|
+
const slot = found.slot;
|
|
453
|
+
if (slot.direction === "row") Object.assign(drop.style, { left: pct(slot.x), top: pct(slot.y), width: "0", height: pct(slot.h) });
|
|
454
|
+
else Object.assign(drop.style, { left: pct(slot.x), top: pct(slot.y), width: pct(slot.w), height: "0" });
|
|
455
|
+
if (found.into && !found.reason) nodeElement(found.into)?.setAttribute("data-into", "true");
|
|
456
|
+
}
|
|
457
|
+
function finishDrag() {
|
|
458
|
+
const outcome = drag?.drop;
|
|
459
|
+
const paths = drag?.paths;
|
|
460
|
+
endDrag();
|
|
461
|
+
if (!outcome || !paths) return;
|
|
462
|
+
if (outcome.reason) { model.notify(outcome.reason, "error"); return; }
|
|
463
|
+
if (outcome.noop) return;
|
|
464
|
+
model.move(paths, outcome.listPath, outcome.index);
|
|
465
|
+
focusPrimary();
|
|
466
|
+
}
|
|
467
|
+
function endDrag() {
|
|
468
|
+
doc.removeEventListener("keydown", dragKey, true);
|
|
469
|
+
drag?.ghost.remove();
|
|
470
|
+
drag = null;
|
|
471
|
+
pending = null;
|
|
472
|
+
drop.hidden = true;
|
|
473
|
+
delete root.dataset.dragging;
|
|
474
|
+
for (const node of layer.querySelectorAll("[data-into]")) node.removeAttribute("data-into");
|
|
475
|
+
render();
|
|
476
|
+
}
|
|
477
|
+
function cancelDrag() { if (drag) endDrag(); }
|
|
478
|
+
function dragKey(event) { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); cancelDrag(); model.notify("Move cancelled."); } }
|
|
479
|
+
|
|
480
|
+
// --- dividers ------------------------------------------------------------------------------------------------------------------
|
|
481
|
+
function startResize(event, node) {
|
|
482
|
+
const info = node._info;
|
|
483
|
+
if (!info?.draggable || model.readOnly || panMode() || (event.pointerType === "mouse" && event.button !== 0)) return;
|
|
484
|
+
event.preventDefault();
|
|
485
|
+
event.stopPropagation();
|
|
486
|
+
if (!model.beginGesture("Resized")) return;
|
|
487
|
+
const startRecord = model.record;
|
|
488
|
+
divider = { key: node.dataset.key, info, startRecord, node, id: event.pointerId };
|
|
489
|
+
node.setPointerCapture?.(event.pointerId);
|
|
490
|
+
node.focus({ preventScroll: true });
|
|
491
|
+
node.dataset.active = "true";
|
|
492
|
+
const onMove = (move) => {
|
|
493
|
+
const p = unit(move);
|
|
494
|
+
const along = info.direction === "row" ? p.x : p.y;
|
|
495
|
+
const fraction = (along - info.pairStart) / info.pairLength;
|
|
496
|
+
const sizes = resizeSizes(startRecord, info.listPath, info.index, fraction, { free: move.shiftKey });
|
|
497
|
+
if (!sizes) return;
|
|
498
|
+
const next = setPairSizes(startRecord, info.listPath, info.index, sizes.a, sizes.b).record;
|
|
499
|
+
model.updateGesture(next, `Resize ${sizes.label}`);
|
|
500
|
+
ratio.hidden = false;
|
|
501
|
+
ratio.textContent = sizes.label;
|
|
502
|
+
ratio.style.left = pct(info.x + info.w / 2);
|
|
503
|
+
ratio.style.top = pct(info.y + info.h / 2);
|
|
504
|
+
divider.label = sizes.label;
|
|
505
|
+
};
|
|
506
|
+
const finish = (commit) => {
|
|
507
|
+
node.removeEventListener("pointermove", onMove);
|
|
508
|
+
node.removeEventListener("pointerup", onUp);
|
|
509
|
+
node.removeEventListener("pointercancel", onCancel);
|
|
510
|
+
doc.removeEventListener("keydown", onKey, true);
|
|
511
|
+
ratio.hidden = true;
|
|
512
|
+
const label = divider?.label;
|
|
513
|
+
divider = null;
|
|
514
|
+
if (commit) model.endGesture(label ? `Resized to ${label}` : "Resized"); else model.cancelGesture();
|
|
515
|
+
node.dataset.active = "false";
|
|
516
|
+
};
|
|
517
|
+
const onUp = () => finish(true);
|
|
518
|
+
const onCancel = () => finish(false);
|
|
519
|
+
const onKey = (key) => { if (key.key === "Escape") { key.preventDefault(); key.stopPropagation(); finish(false); } };
|
|
520
|
+
node.addEventListener("pointermove", onMove);
|
|
521
|
+
node.addEventListener("pointerup", onUp);
|
|
522
|
+
node.addEventListener("pointercancel", onCancel);
|
|
523
|
+
doc.addEventListener("keydown", onKey, true);
|
|
524
|
+
}
|
|
525
|
+
function dividerKey(event, node) {
|
|
526
|
+
const info = node._info;
|
|
527
|
+
if (!info?.draggable || event.altKey || event.metaKey || event.ctrlKey) return;
|
|
528
|
+
const keys = info.direction === "row" ? ["ArrowLeft", "ArrowRight"] : ["ArrowUp", "ArrowDown"];
|
|
529
|
+
const step = event.key === keys[0] ? -1 : event.key === keys[1] ? 1 : 0;
|
|
530
|
+
if (step) { event.preventDefault(); event.stopPropagation(); model.nudge(info.listPath, info.index, step, event.shiftKey); return; }
|
|
531
|
+
if (event.key === "Enter") { event.preventDefault(); event.stopPropagation(); model.equalize(info.listPath, info.index); }
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// --- context menu ----------------------------------------------------------------------------------------------------------------
|
|
535
|
+
function openContextMenu(point, returnFocus) {
|
|
536
|
+
menu?.close({ restore: false });
|
|
537
|
+
const paths = model.selection;
|
|
538
|
+
if (!paths.length) return;
|
|
539
|
+
menu = openMenu(dom, root, { x: point.x, y: point.y, label: "Region actions", returnFocus, onClose: () => { menu = null; }, items: nodeMenuItems(model, paths, "canvas", win) });
|
|
540
|
+
}
|
|
541
|
+
content.addEventListener("contextmenu", (event) => {
|
|
542
|
+
if (model.tool !== "select" || event.target.closest?.(".opf-lx-menu, .opf-lx-rename")) return;
|
|
543
|
+
const chain = chainAt(unit(event));
|
|
544
|
+
const hit = chain[chain.length - 1];
|
|
545
|
+
if (!hit) return;
|
|
546
|
+
event.preventDefault();
|
|
547
|
+
if (!isSelected(hit.path)) model.select([hit.path]);
|
|
548
|
+
openContextMenu({ x: event.clientX, y: event.clientY }, nodeElement(hit.path) ?? frame);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// --- rename in place -----------------------------------------------------------------------------------------------------------
|
|
552
|
+
function syncRename() {
|
|
553
|
+
const request = model.renaming;
|
|
554
|
+
const wanted = request?.surface === "canvas" ? request.path : null;
|
|
555
|
+
if (renameInput && (!wanted || renameInput._path !== pathKey(wanted))) { const input = renameInput; renameInput = null; input.remove(); if (!wanted) syncFocusSoon(); }
|
|
556
|
+
if (!wanted || renameInput) return;
|
|
557
|
+
const box = boxOf(wanted);
|
|
558
|
+
const node = nodeElement(wanted);
|
|
559
|
+
if (!box || !node || isSplit(box.entry)) { model.requestRename(null); return; }
|
|
560
|
+
const input = el("input", "opf-lx-rename", { type: "text", value: box.entry.name, "aria-label": `Rename ${box.entry.name}`, spellcheck: "false", autocomplete: "off" });
|
|
561
|
+
input._path = pathKey(wanted);
|
|
562
|
+
input.style.left = pct(box.x);
|
|
563
|
+
input.style.top = pct(box.y);
|
|
564
|
+
input.style.width = pct(box.w);
|
|
565
|
+
let done = false;
|
|
566
|
+
const finish = (commit) => {
|
|
567
|
+
if (done) return;
|
|
568
|
+
done = true;
|
|
569
|
+
const value = input.value.trim();
|
|
570
|
+
model.requestRename(null);
|
|
571
|
+
if (commit && value && value !== box.entry.name) model.setProperty(wanted, "name", value, { coalesce: null });
|
|
572
|
+
focusPrimary();
|
|
573
|
+
};
|
|
574
|
+
input.addEventListener("keydown", (event) => {
|
|
575
|
+
event.stopPropagation();
|
|
576
|
+
if (event.key === "Enter") { event.preventDefault(); finish(true); }
|
|
577
|
+
else if (event.key === "Escape") { event.preventDefault(); finish(false); }
|
|
578
|
+
});
|
|
579
|
+
input.addEventListener("blur", () => finish(true));
|
|
580
|
+
renameInput = input;
|
|
581
|
+
overlay.append(input);
|
|
582
|
+
input.focus();
|
|
583
|
+
input.select();
|
|
584
|
+
}
|
|
585
|
+
const syncFocusSoon = () => win.requestAnimationFrame?.(() => { if (!renameInput) focusPrimary(); });
|
|
586
|
+
|
|
587
|
+
// --- the heading tool's menu -----------------------------------------------------------------------------------------------------
|
|
588
|
+
function syncHeadingMenu() {
|
|
589
|
+
if (model.tool !== "heading") { if (headingMenu) { const open = headingMenu; headingMenu = null; open.close({ restore: false }); } return; }
|
|
590
|
+
if (headingMenu) return;
|
|
591
|
+
const names = new Set(geo.nodes.filter(({ entry }) => isHeading(entry)).map(({ entry }) => entry.name));
|
|
592
|
+
const frameBox = frame.getBoundingClientRect();
|
|
593
|
+
headingMenu = openMenu(dom, root, {
|
|
594
|
+
x: frameBox.left + 12, y: frameBox.top + 12, label: "Heading regions", returnFocus: frame, className: "opf-lx-heading-menu",
|
|
595
|
+
onClose: () => { headingMenu = null; if (model.tool === "heading") model.setTool("select"); },
|
|
596
|
+
items: HEADING_ITEMS.map((item) => ({
|
|
597
|
+
label: item.label, role: "menuitemcheckbox", checked: names.has(item.name), hint: item.name === "title" ? "adds a row on top" : "needs a title",
|
|
598
|
+
onSelect: () => { model.toggleHeading(item.name); },
|
|
599
|
+
})),
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// --- zoom ------------------------------------------------------------------------------------------------------------------------
|
|
604
|
+
function setZoom(value) {
|
|
605
|
+
const next = Math.max(ZOOMS[0], Math.min(ZOOMS[ZOOMS.length - 1], value));
|
|
606
|
+
zoom = next;
|
|
607
|
+
root.style.setProperty("--opf-lx-zoom", String(zoom));
|
|
608
|
+
root.dataset.zoomed = String(zoom !== 1);
|
|
609
|
+
zoomLabel.textContent = `${Math.round(zoom * 100)}%`;
|
|
610
|
+
}
|
|
611
|
+
const stepZoom = (direction) => {
|
|
612
|
+
const at = ZOOMS.findIndex((value) => value >= zoom - 1e-9);
|
|
613
|
+
const index = direction > 0 ? (ZOOMS[at] > zoom + 1e-9 ? at : at + 1) : at - 1;
|
|
614
|
+
setZoom(ZOOMS[Math.max(0, Math.min(ZOOMS.length - 1, index))]);
|
|
615
|
+
};
|
|
616
|
+
zoomOut.addEventListener("click", () => stepZoom(-1));
|
|
617
|
+
zoomIn.addEventListener("click", () => stepZoom(1));
|
|
618
|
+
zoomFit.addEventListener("click", () => setZoom(1));
|
|
619
|
+
viewport.addEventListener("wheel", (event) => { if (event.ctrlKey || event.metaKey) { event.preventDefault(); stepZoom(event.deltaY < 0 ? 1 : -1); } }, { passive: false });
|
|
620
|
+
setZoom(1);
|
|
621
|
+
|
|
622
|
+
// --- preview with the host's renderer ---------------------------------------------------------------------------------------------
|
|
623
|
+
function schedulePreview() {
|
|
624
|
+
if (!model.preview || typeof options.toSvg !== "function") {
|
|
625
|
+
win.clearTimeout(previewTimer);
|
|
626
|
+
if (rendered.firstChild) rendered.replaceChildren();
|
|
627
|
+
root.dataset.rendered = "false";
|
|
628
|
+
previewToken++;
|
|
629
|
+
lastPreview = "";
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const key = JSON.stringify(model.record);
|
|
633
|
+
if (key === lastPreview) return;
|
|
634
|
+
lastPreview = key;
|
|
635
|
+
win.clearTimeout(previewTimer);
|
|
636
|
+
const token = ++previewToken;
|
|
637
|
+
previewTimer = win.setTimeout(() => drawPreview(token), 120);
|
|
638
|
+
}
|
|
639
|
+
function drawPreview(token) {
|
|
640
|
+
if (token !== previewToken || !model.preview) return;
|
|
641
|
+
if (model.findings.some((finding) => finding.severity === "error")) { rendered.replaceChildren(); root.dataset.rendered = "false"; return; }
|
|
642
|
+
const deck = sampleDeck(model.record);
|
|
643
|
+
const renderOptions = (typeof options.renderOptions === "function" ? options.renderOptions() : options.renderOptions) ?? {};
|
|
644
|
+
const show = (text) => {
|
|
645
|
+
if (token !== previewToken) return;
|
|
646
|
+
const parsed = new win.DOMParser().parseFromString(String(text), "image/svg+xml");
|
|
647
|
+
const svg = parsed.documentElement;
|
|
648
|
+
if (!svg || svg.nodeName.toLowerCase() !== "svg" || parsed.querySelector("parsererror")) { root.dataset.rendered = "false"; return; }
|
|
649
|
+
for (const script of svg.querySelectorAll("script, foreignObject")) script.remove();
|
|
650
|
+
svg.setAttribute("preserveAspectRatio", "xMidYMid slice");
|
|
651
|
+
svg.removeAttribute("width");
|
|
652
|
+
svg.removeAttribute("height");
|
|
653
|
+
rendered.replaceChildren(doc.importNode(svg, true));
|
|
654
|
+
root.dataset.rendered = "true";
|
|
655
|
+
};
|
|
656
|
+
const fail = (error) => { if (token !== previewToken) return; rendered.replaceChildren(); root.dataset.rendered = "false"; model.notify(`Preview: ${error?.message ?? error}`, "error"); };
|
|
657
|
+
whenFontsReady(options.fonts, deck, {
|
|
658
|
+
renderOptions, isCurrent: () => token === previewToken,
|
|
659
|
+
loading: () => { root.dataset.rendered = "loading"; },
|
|
660
|
+
ready: async () => { show(await options.toSvg(deck, 1, { ...renderOptions, fonts: options.fonts, text: "system", trace: false })); },
|
|
661
|
+
failed: fail,
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// --- keyboard ---------------------------------------------------------------------------------------------------------------------
|
|
666
|
+
// Focus moves by script (to the selected box), so a press must not move it to the frame first.
|
|
667
|
+
content.addEventListener("mousedown", (event) => { if (!event.target.closest?.(".opf-lx-rename")) event.preventDefault(); });
|
|
668
|
+
content.addEventListener("focusin", (event) => {
|
|
669
|
+
const node = event.target.closest?.(".opf-lx-node");
|
|
670
|
+
if (node && node.dataset.path !== undefined && !pending && !drag) {
|
|
671
|
+
const path = node.dataset.path.split(".").map(Number);
|
|
672
|
+
if (!isSelected(path)) model.select([path]);
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
root.addEventListener("keydown", (event) => {
|
|
676
|
+
if (event.target === renameInput || event.opfLayoutHandled) return;
|
|
677
|
+
if (event.key === " " && !isTypingTarget(event.target) && (event.target === frame || event.target.closest?.(".opf-lx-node, .opf-lx-viewport")) && !event.repeat) {
|
|
678
|
+
event.preventDefault();
|
|
679
|
+
spaceHeld = true;
|
|
680
|
+
root.dataset.space = "true";
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (handleGlobalShortcut(event, model)) return;
|
|
684
|
+
if (isTypingTarget(event.target)) return;
|
|
685
|
+
if (event.key === "Escape") {
|
|
686
|
+
event.preventDefault();
|
|
687
|
+
event.opfLayoutHandled = true;
|
|
688
|
+
if (menu) { menu.close(); return; }
|
|
689
|
+
if (drag) { cancelDrag(); return; }
|
|
690
|
+
if (model.tool !== "select") { model.setTool("select"); return; }
|
|
691
|
+
model.selectParent();
|
|
692
|
+
focusPrimary();
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
const onNode = event.target === frame || event.target.closest?.(".opf-lx-node");
|
|
696
|
+
if (!onNode) return;
|
|
697
|
+
if (handleSelectionShortcut(event, model, "canvas")) return;
|
|
698
|
+
const arrows = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
|
|
699
|
+
if (arrows[event.key] && !event.altKey && !event.metaKey && !event.ctrlKey && !event.shiftKey) {
|
|
700
|
+
event.preventDefault();
|
|
701
|
+
model.selectSibling(arrows[event.key]);
|
|
702
|
+
focusPrimary();
|
|
703
|
+
} else if (event.key === "Enter" && !event.shiftKey) {
|
|
704
|
+
const primary = model.primary;
|
|
705
|
+
if (!primary) return;
|
|
706
|
+
event.preventDefault();
|
|
707
|
+
if (model.tool === "split-row" || model.tool === "split-column") {
|
|
708
|
+
const node = nodeAt(model.record, primary);
|
|
709
|
+
if (node && !isSplit(node) && model.splitRegion(primary, model.tool === "split-row" ? "row" : "column")) model.setTool("select");
|
|
710
|
+
focusPrimary();
|
|
711
|
+
} else if (model.selectChild()) focusPrimary();
|
|
712
|
+
} else if ((event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey)) && model.primary) {
|
|
713
|
+
event.preventDefault();
|
|
714
|
+
const box = frame.getBoundingClientRect();
|
|
715
|
+
const at = boxOf(model.primary);
|
|
716
|
+
const node = nodeElement(model.primary);
|
|
717
|
+
openContextMenu({ x: box.left + (at ? (at.x + at.w / 2) * box.width : 40), y: box.top + (at ? (at.y + at.h / 2) * box.height : 40) }, node ?? frame);
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
root.addEventListener("keyup", (event) => { if (event.key === " " && spaceHeld) { spaceHeld = false; delete root.dataset.space; } });
|
|
721
|
+
root.addEventListener("focusout", () => { spaceHeld = false; delete root.dataset.space; });
|
|
722
|
+
|
|
723
|
+
const unsubscribe = model.subscribe((event) => {
|
|
724
|
+
if (event.type === "notice") { paintStatus(); return; }
|
|
725
|
+
if (event.type === "load") { hover = null; }
|
|
726
|
+
render();
|
|
727
|
+
});
|
|
728
|
+
render();
|
|
729
|
+
|
|
730
|
+
return {
|
|
731
|
+
element: root,
|
|
732
|
+
model,
|
|
733
|
+
refresh: render,
|
|
734
|
+
setZoom,
|
|
735
|
+
getZoom: () => zoom,
|
|
736
|
+
focus() { focusPrimary(); },
|
|
737
|
+
destroy() {
|
|
738
|
+
unsubscribe();
|
|
739
|
+
win.clearTimeout(previewTimer);
|
|
740
|
+
previewToken++;
|
|
741
|
+
cancelDrag();
|
|
742
|
+
menu?.close({ restore: false });
|
|
743
|
+
headingMenu?.close({ restore: false });
|
|
744
|
+
root.remove();
|
|
745
|
+
if (ownModel) model.destroy();
|
|
746
|
+
},
|
|
747
|
+
};
|
|
748
|
+
}
|