@openpresentation/opf-editor 0.27.0 → 0.29.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.
Files changed (80) hide show
  1. package/README.md +23 -9
  2. package/dist/canvas.d.ts +13 -1
  3. package/dist/canvas.js +39 -9
  4. package/dist/chart-options-panel.js +16 -16
  5. package/dist/codemirror-guard.js +69 -0
  6. package/dist/content-controls.js +127 -74
  7. package/dist/data-grid.js +15 -7
  8. package/dist/design-controls.d.ts +13 -0
  9. package/dist/design-controls.js +136 -125
  10. package/dist/design-options.js +1 -1
  11. package/dist/exact-source.d.ts +30 -0
  12. package/dist/font-gate.js +20 -1
  13. package/dist/json-editor.d.ts +33 -0
  14. package/dist/json-editor.js +91 -4
  15. package/dist/json-options.d.ts +2 -1
  16. package/dist/json-options.js +7 -6
  17. package/dist/layout-adapter.js +96 -0
  18. package/dist/layout-canvas.js +122 -232
  19. package/dist/layout-editor-ui.js +46 -185
  20. package/dist/layout-inspector.js +36 -99
  21. package/dist/layout-layers.js +75 -92
  22. package/dist/layout-model.js +62 -103
  23. package/dist/layout-templates.d.ts +8 -3
  24. package/dist/layout-templates.js +30 -16
  25. package/dist/numbering-panel.js +2 -3
  26. package/dist/react.d.ts +16 -4
  27. package/dist/react.js +47 -6
  28. package/dist/slide-adapter.d.ts +49 -0
  29. package/dist/slide-adapter.js +289 -0
  30. package/dist/slide-edits.d.ts +46 -0
  31. package/dist/slide-edits.js +629 -0
  32. package/dist/slide-manager.js +2 -1
  33. package/dist/slide-scene.d.ts +96 -0
  34. package/dist/slide-scene.js +413 -0
  35. package/dist/source-checker.js +45 -6
  36. package/dist/source-color-scheme.js +60 -0
  37. package/dist/source-completions.js +6 -4
  38. package/dist/source-pane.d.ts +103 -6
  39. package/dist/source-pane.js +196 -9
  40. package/dist/source-worker.js +8 -4
  41. package/dist/studio-canvas.d.ts +34 -0
  42. package/dist/studio-canvas.js +586 -0
  43. package/dist/studio-commands.d.ts +15 -0
  44. package/dist/studio-commands.js +155 -0
  45. package/dist/studio-design.d.ts +16 -0
  46. package/dist/studio-design.js +354 -0
  47. package/dist/studio-kit/commands.d.ts +38 -0
  48. package/dist/studio-kit/commands.js +148 -0
  49. package/dist/studio-kit/dom.d.ts +52 -0
  50. package/dist/studio-kit/dom.js +198 -0
  51. package/dist/studio-kit/drag.d.ts +26 -0
  52. package/dist/studio-kit/drag.js +127 -0
  53. package/dist/studio-kit/gesture.d.ts +29 -0
  54. package/dist/studio-kit/gesture.js +87 -0
  55. package/dist/studio-kit/index.d.ts +29 -0
  56. package/dist/studio-kit/index.js +12 -0
  57. package/dist/studio-kit/inspector.d.ts +22 -0
  58. package/dist/studio-kit/inspector.js +119 -0
  59. package/dist/studio-kit/overlay.d.ts +29 -0
  60. package/dist/studio-kit/overlay.js +182 -0
  61. package/dist/studio-kit/selection.d.ts +30 -0
  62. package/dist/studio-kit/selection.js +106 -0
  63. package/dist/studio-kit/tree.d.ts +9 -0
  64. package/dist/studio-kit/tree.js +82 -0
  65. package/dist/studio-kit/viewport.d.ts +29 -0
  66. package/dist/studio-kit/viewport.js +153 -0
  67. package/dist/studio-layers.d.ts +12 -0
  68. package/dist/studio-layers.js +218 -0
  69. package/dist/studio.d.ts +31 -0
  70. package/dist/studio.js +1608 -0
  71. package/dist/styles.css +1573 -123
  72. package/dist/thumbnail-cache.d.ts +2 -0
  73. package/dist/thumbnail-cache.js +10 -1
  74. package/dist/workspace-measure.js +6 -4
  75. package/dist/workspace-parts.js +20 -0
  76. package/dist/workspace-shell.js +25 -15
  77. package/dist/workspace-theme.js +3 -0
  78. package/dist/workspace.d.ts +114 -10
  79. package/dist/workspace.js +223 -49
  80. package/package.json +30 -19
@@ -2,21 +2,22 @@
2
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
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
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
+ // CE-18: the viewport, overlay, drag, divider and keymap behavior is the studio kit's (src/studio-kit/), over the layout adapter.
5
6
  import { createLayoutModel } from "./layout-model.js";
7
+ import { createLayoutAdapter } from "./layout-adapter.js";
6
8
  import { LAYOUT_TOOL_DEFS } from "./layout-palette.js";
7
9
  import { sampleDeck } from "./layout-samples.js";
8
10
  import { whenFontsReady } from "./font-gate.js";
11
+ import { isHeading, isPrefix, isSplit, listAt, nodeAt, pathKey, resizeSizes, samePath, setPairSizes } from "./layout-tree.js";
9
12
  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,
13
+ SIZE_LABEL, buildWireframe, createDom, handleGlobalShortcut, handleSelectionShortcut, isHandled, isTypingTarget, markHandled, nextId, nodeMenuItems, openMenu, pct,
14
14
  } from "./layout-editor-ui.js";
15
+ import { createViewport } from "./studio-kit/viewport.js";
16
+ import { boxesAt, createKeyedLayer, createOverlay, dividerKeyAction, paintBadge, paintDivider, paintSelectable, ratioLabel, setBox } from "./studio-kit/overlay.js";
17
+ import { createDragController, dragGhost, trackPointer } from "./studio-kit/drag.js";
15
18
 
16
19
  const ZOOMS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
17
- const DRAG_DISTANCE = 4;
18
20
  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
 
21
22
  /**
22
23
  * Mount the canvas in `container`. Options: `model` (shared state; one is created from the other options when omitted), `toSvg` and `fonts`
@@ -30,6 +31,7 @@ export function createLayoutCanvas(container, options = {}) {
30
31
  const { el } = dom;
31
32
  const ownModel = !options.model;
32
33
  const model = options.model ?? createLayoutModel(options);
34
+ const adapter = createLayoutAdapter(model);
33
35
  const id = nextId("opf-lx-canvas");
34
36
 
35
37
  const root = el("div", "opf-lx-canvas opf-ui", { "data-density": model.density, "data-tool": model.tool });
@@ -42,13 +44,8 @@ export function createLayoutCanvas(container, options = {}) {
42
44
  implicit.append(el("span", "opf-lx-implicit-label", { text: "title · implicit" }));
43
45
  const layer = el("div", "opf-lx-layer");
44
46
  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);
47
+ // The kit overlay: the split tools' cut guide, the drop line and the ratio badge.
48
+ const marks = createOverlay(dom, overlay, { guide: "cut" });
52
49
  content.append(implicit, layer, overlay);
53
50
  frame.append(rendered, content);
54
51
  stage.append(frame);
@@ -68,14 +65,9 @@ export function createLayoutCanvas(container, options = {}) {
68
65
  root.append(banner, viewport, footer);
69
66
  container.append(root);
70
67
 
71
- const keyed = new Map();
72
- let geo = layoutBoxes(model.record);
68
+ const keyed = createKeyedLayer(layer);
69
+ let geo = adapter.geometry();
73
70
  let hover = null;
74
- let zoom = 1;
75
- let spaceHeld = false;
76
- let drag = null;
77
- let pending = null;
78
- let pan = null;
79
71
  let divider = null;
80
72
  let menu = null;
81
73
  let renameInput = null;
@@ -84,23 +76,28 @@ export function createLayoutCanvas(container, options = {}) {
84
76
  let previewTimer = 0;
85
77
  let lastPreview = "";
86
78
 
79
+ // --- the kit viewport (zoom and pan) and the drag controller ----------------------------------------------------------------
80
+ const viewportKit = createViewport(viewport, {
81
+ root, steps: ZOOMS, zoomVar: "--opf-lx-zoom", panEnabled: () => model.tool === "hand",
82
+ onZoom: (value) => { zoomLabel.textContent = `${Math.round(value * 100)}%`; },
83
+ });
84
+ const dragger = createDragController({
85
+ doc,
86
+ onStart: (press) => startDrag(press),
87
+ onStarted: () => { root.dataset.dragging = "true"; paintHover(); render(); },
88
+ onMove: (session, event) => moveDrag(session, event),
89
+ onEnd: (session) => endDrag(session),
90
+ onDrop: (session) => finishDrag(session),
91
+ onEscape: () => model.notify("Move cancelled."),
92
+ });
93
+
87
94
  // --- 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
- };
95
+ const unit = (event) => viewportKit.toUnit(event, content);
92
96
  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);
97
+ const chainAt = (point, skip) => boxesAt(geo.nodes, point, { skip });
94
98
  const nodeElement = (path) => keyed.get(`n:${pathKey(path)}`);
95
99
  const isSelected = (path) => model.selection.some((entry) => samePath(entry, path));
96
100
 
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
101
  // --- the descriptions a screen reader hears ---------------------------------------------------------------------------------
105
102
  function describeNode(box) {
106
103
  const { entry, path, direction } = box;
@@ -113,12 +110,6 @@ export function createLayoutCanvas(container, options = {}) {
113
110
  }
114
111
 
115
112
  // --- 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
113
  function makeNode() {
123
114
  const node = el("div", "opf-lx-node");
124
115
  node.append(el("span", "opf-lx-label", { "aria-hidden": "true" }));
@@ -147,14 +138,14 @@ export function createLayoutCanvas(container, options = {}) {
147
138
  function render() {
148
139
  // Focus that is on a box (or the frame) follows the selection afterwards; a divider, slot, menu or field keeps its own.
149
140
  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 = [];
141
+ geo = adapter.geometry();
142
+ const pass = keyed.begin();
143
+ const drag = dragger.active;
153
144
  const primary = model.primary;
154
145
  const tabStop = primary && boxOf(primary) ? pathKey(primary) : geo.nodes[0] ? pathKey(geo.nodes[0].path) : null;
155
146
  for (const box of geo.nodes) {
156
147
  const key = `n:${pathKey(box.path)}`;
157
- const node = ensure(key, makeNode, seen);
148
+ const node = pass.ensure(key, makeNode);
158
149
  const split = isSplit(box.entry);
159
150
  const issues = model.findingsFor(box.path);
160
151
  node.dataset.path = pathKey(box.path);
@@ -164,21 +155,14 @@ export function createLayoutCanvas(container, options = {}) {
164
155
  else { node.dataset.name = box.entry.name; delete node.dataset.direction; }
165
156
  node.dataset.heading = !split && isHeading(box.entry) ? "true" : "false";
166
157
  node.dataset.finding = issues.length ? issues[0].severity : model.findingsWithin(box.path).length ? "within" : "";
167
- node.classList.toggle("is-selected", isSelected(box.path));
168
158
  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);
159
+ paintSelectable(node, { box, selected: isSelected(box.path), pressable: !split, label: describeNode(box), tabStop: pathKey(box.path) === tabStop });
174
160
  // 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
161
  // 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
162
  const label = node.firstElementChild;
177
163
  label.dataset.text = split ? (box.entry.direction === "row" ? "row" : "column") : box.entry.name;
178
164
  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(); }
165
+ paintBadge(dom, node, issues.map((issue) => issue.message));
182
166
  let wire = node.querySelector(":scope > .opf-lx-wire");
183
167
  const wanted = model.preview && !split;
184
168
  const wireKey = wanted ? JSON.stringify([isHeading(box.entry), box.entry.accepts, box.entry.flow, box.entry.max]) : "";
@@ -189,43 +173,29 @@ export function createLayoutCanvas(container, options = {}) {
189
173
  wire.dataset.key = wireKey;
190
174
  node.append(wire);
191
175
  }
192
- order.push(node);
193
176
  }
194
177
  // Dividers: the gap between two siblings.
195
178
  for (const info of geo.dividers) {
196
179
  const key = `d:${pathKey(info.listPath)}:${info.index}`;
197
- const node = ensure(key, () => makeDivider(key), seen);
180
+ const node = pass.ensure(key, () => makeDivider(key));
198
181
  node._info = info;
199
- node.dataset.orientation = info.direction === "row" ? "vertical" : "horizontal";
200
182
  node.dataset.depth = String(info.depth);
201
- node.dataset.fixed = info.draggable ? "false" : "true";
202
183
  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
184
  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);
185
+ paintDivider(node, {
186
+ box: info, orientation: info.direction === "row" ? "vertical" : "horizontal", draggable: info.draggable,
187
+ tabbable: model.tool === "select" || model.tool === "hand",
188
+ label: info.draggable ? `Resize ${model.describe(first)} and ${model.describe(second)}` : undefined,
189
+ valueNow: info.draggable ? Math.round(info.a / (info.a + info.b) * 100) : undefined,
190
+ valueText: info.draggable ? ratioLabel(info.a, info.b) : undefined,
191
+ title: "Drag to resize. Double-click to make both sides equal.",
192
+ });
223
193
  }
224
194
  // Insert slots, only for the Insert tool.
225
195
  if (model.tool === "insert" && !model.readOnly) {
226
196
  geo.slots.forEach((slot) => {
227
197
  const key = `s:${pathKey(slot.listPath)}:${slot.index}`;
228
- const node = ensure(key, makeSlot, seen);
198
+ const node = pass.ensure(key, makeSlot);
229
199
  node._info = slot;
230
200
  node.dataset.orientation = slot.direction === "row" ? "vertical" : "horizontal";
231
201
  node.dataset.depth = String(slot.depth);
@@ -239,16 +209,9 @@ export function createLayoutCanvas(container, options = {}) {
239
209
  node.style.top = slot.direction === "row" ? pct(thick.y) : `calc(${pct(thick.y)} - var(--opf-lx-slot) / 2)`;
240
210
  node.style.width = slot.direction === "row" ? "var(--opf-lx-slot)" : pct(thick.w);
241
211
  node.style.height = slot.direction === "row" ? pct(thick.h) : "var(--opf-lx-slot)";
242
- order.push(node);
243
212
  });
244
213
  }
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
- }
214
+ pass.done();
252
215
  // The implicit title band and the layout-level findings.
253
216
  implicit.hidden = !geo.implicitTitle;
254
217
  if (geo.implicitTitle) setBox(implicit, geo.implicitTitle);
@@ -267,11 +230,9 @@ export function createLayoutCanvas(container, options = {}) {
267
230
  schedulePreview();
268
231
  if (keepFocus) syncFocus();
269
232
  }
270
- const trim = (value) => String(Math.round(value * 1000) / 1000);
271
233
 
272
234
  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");
235
+ marks.hover(layer, hover && !dragger.active && !divider ? nodeElement(hover) : null);
275
236
  }
276
237
  function paintStatus() {
277
238
  const notice = model.notice;
@@ -280,7 +241,7 @@ export function createLayoutCanvas(container, options = {}) {
280
241
  status.dataset.live = notice && notice.kind !== "error" ? "true" : "false";
281
242
  alert.textContent = notice?.kind === "error" ? notice.message : "";
282
243
  alert.hidden = !alert.textContent;
283
- zoomLabel.textContent = `${Math.round(zoom * 100)}%`;
244
+ zoomLabel.textContent = `${Math.round(viewportKit.zoom * 100)}%`;
284
245
  }
285
246
  /** Put DOM focus on the selected box (or the frame, when nothing is selected), after the structure changed under it. */
286
247
  function syncFocus() {
@@ -303,24 +264,7 @@ export function createLayoutCanvas(container, options = {}) {
303
264
  }
304
265
  return deepest;
305
266
  }
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);
267
+ const panMode = () => viewportKit.panMode;
324
268
 
325
269
  content.addEventListener("pointerdown", (event) => {
326
270
  if (event.target.closest?.(".opf-lx-divider, .opf-lx-slot, .opf-lx-menu, .opf-lx-rename") || panMode()) return;
@@ -345,7 +289,7 @@ export function createLayoutCanvas(container, options = {}) {
345
289
  focusPrimary();
346
290
  if (model.readOnly) return;
347
291
  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 };
292
+ dragger.press(event, { paths });
349
293
  content.setPointerCapture?.(event.pointerId);
350
294
  });
351
295
  // The margin of the slide, outside the content box: a click there selects the layout.
@@ -356,8 +300,7 @@ export function createLayoutCanvas(container, options = {}) {
356
300
  frame.focus({ preventScroll: true });
357
301
  });
358
302
  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; }
303
+ if (dragger.move(event)) return;
361
304
  if (event.pointerType === "touch") return;
362
305
  // Hover: the node a click would select, and the guide of the split tools.
363
306
  const point = unit(event);
@@ -369,11 +312,8 @@ export function createLayoutCanvas(container, options = {}) {
369
312
  paintCut();
370
313
  });
371
314
  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; });
315
+ content.addEventListener("pointerup", (event) => { dragger.release(event); });
316
+ content.addEventListener("pointercancel", () => { dragger.cancel(); });
377
317
  content.addEventListener("dblclick", (event) => {
378
318
  if (model.tool !== "select" || event.target.closest?.(".opf-lx-divider, .opf-lx-slot, .opf-lx-rename") || model.readOnly) return;
379
319
  const region = [...chainAt(unit(event))].reverse().find((box) => !isSplit(box.entry));
@@ -382,11 +322,7 @@ export function createLayoutCanvas(container, options = {}) {
382
322
  function paintCut() {
383
323
  const tool = model.tool;
384
324
  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";
325
+ if (box) marks.showGuide(box, tool === "split-row" ? "row" : "column"); else marks.hideGuide();
390
326
  }
391
327
  function focusPrimary() {
392
328
  const primary = model.primary;
@@ -394,20 +330,15 @@ export function createLayoutCanvas(container, options = {}) {
394
330
  (node ?? frame).focus({ preventScroll: true });
395
331
  }
396
332
 
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();
333
+ // --- drag a region to reorder it or move it into a split (the kit's drag controller) ------------------------------------------
334
+ function startDrag(press) {
335
+ if (model.readOnly) return null;
336
+ const paths = press.payload.paths.filter((path) => nodeAt(model.record, path));
337
+ if (!paths.length) return null;
338
+ const ghost = dragGhost(dom, root, paths.length > 1 ? `${paths.length} regions` : model.describe(paths[0]));
339
+ return { paths, valid: paths, ghost, drop: null };
409
340
  }
410
- function dropAt(point) {
341
+ function dropAt(drag, point) {
411
342
  const skip = (box) => drag.paths.some((path) => isPrefix(path, box.path));
412
343
  const chain = chainAt(point, skip);
413
344
  const hit = chain[chain.length - 1];
@@ -427,55 +358,35 @@ export function createLayoutCanvas(container, options = {}) {
427
358
  index = hit.path[hit.path.length - 1] + (after ? 1 : 0);
428
359
  }
429
360
  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
- }
361
+ const { reason, noop } = adapter.trialMove(drag.paths, listPath, index);
439
362
  return { listPath, index, slot, into, reason, noop };
440
363
  }
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));
364
+ function moveDrag(drag, event) {
365
+ drag.ghost.moveTo(event);
366
+ const found = dropAt(drag, unit(event));
445
367
  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;
368
+ marks.into(layer, null);
369
+ if (!found?.slot) { marks.hideDrop(); return; }
452
370
  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");
371
+ marks.showDrop(slot, { orientation: slot.direction === "row" ? "vertical" : "horizontal", state: found.reason ? "refused" : found.noop ? "same" : "ok", reason: found.reason });
372
+ if (found.into && !found.reason) marks.into(layer, nodeElement(found.into));
456
373
  }
457
- function finishDrag() {
458
- const outcome = drag?.drop;
459
- const paths = drag?.paths;
460
- endDrag();
374
+ function finishDrag(drag) {
375
+ const outcome = drag.drop;
376
+ const paths = drag.paths;
461
377
  if (!outcome || !paths) return;
462
378
  if (outcome.reason) { model.notify(outcome.reason, "error"); return; }
463
379
  if (outcome.noop) return;
464
380
  model.move(paths, outcome.listPath, outcome.index);
465
381
  focusPrimary();
466
382
  }
467
- function endDrag() {
468
- doc.removeEventListener("keydown", dragKey, true);
469
- drag?.ghost.remove();
470
- drag = null;
471
- pending = null;
472
- drop.hidden = true;
383
+ function endDrag(drag) {
384
+ drag.ghost.remove();
385
+ marks.hideDrop();
473
386
  delete root.dataset.dragging;
474
- for (const node of layer.querySelectorAll("[data-into]")) node.removeAttribute("data-into");
387
+ marks.into(layer, null);
475
388
  render();
476
389
  }
477
- function cancelDrag() { if (drag) endDrag(); }
478
- function dragKey(event) { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); cancelDrag(); model.notify("Move cancelled."); } }
479
390
 
480
391
  // --- dividers ------------------------------------------------------------------------------------------------------------------
481
392
  function startResize(event, node) {
@@ -483,52 +394,44 @@ export function createLayoutCanvas(container, options = {}) {
483
394
  if (!info?.draggable || model.readOnly || panMode() || (event.pointerType === "mouse" && event.button !== 0)) return;
484
395
  event.preventDefault();
485
396
  event.stopPropagation();
486
- if (!model.beginGesture("Resized")) return;
397
+ // One gesture through the adapter: each move previews a draft record, the release commits one step, Escape or a cancel restores.
398
+ if (!adapter.begin("Resized")) return;
487
399
  const startRecord = model.record;
488
400
  divider = { key: node.dataset.key, info, startRecord, node, id: event.pointerId };
489
401
  node.setPointerCapture?.(event.pointerId);
490
402
  node.focus({ preventScroll: true });
491
403
  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);
404
+ trackPointer(node, {
405
+ doc,
406
+ onMove(move) {
407
+ const p = unit(move);
408
+ const along = info.direction === "row" ? p.x : p.y;
409
+ const fraction = (along - info.pairStart) / info.pairLength;
410
+ const sizes = resizeSizes(startRecord, info.listPath, info.index, fraction, { free: move.shiftKey });
411
+ if (!sizes) return;
412
+ const next = setPairSizes(startRecord, info.listPath, info.index, sizes.a, sizes.b).record;
413
+ adapter.preview(next, `Resize ${sizes.label}`);
414
+ marks.showRatio(sizes.label, { x: info.x + info.w / 2, y: info.y + info.h / 2 });
415
+ divider.label = sizes.label;
416
+ },
417
+ onEnd(commit) {
418
+ marks.hideRatio();
419
+ const label = divider?.label;
420
+ divider = null;
421
+ if (commit) adapter.commit(label ? `Resized to ${label}` : "Resized"); else adapter.cancel();
422
+ node.dataset.active = "false";
423
+ },
424
+ });
524
425
  }
525
426
  function dividerKey(event, node) {
526
427
  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); }
428
+ if (!info?.draggable) return;
429
+ const action = dividerKeyAction(event, info.direction === "row" ? "vertical" : "horizontal");
430
+ if (!action) return;
431
+ event.preventDefault();
432
+ event.stopPropagation();
433
+ if (action.type === "nudge") model.nudge(info.listPath, info.index, action.step, action.big);
434
+ else model.equalize(info.listPath, info.index);
532
435
  }
533
436
 
534
437
  // --- context menu ----------------------------------------------------------------------------------------------------------------
@@ -600,24 +503,11 @@ export function createLayoutCanvas(container, options = {}) {
600
503
  });
601
504
  }
602
505
 
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);
506
+ // --- zoom (the kit viewport: Ctrl or Cmd with the wheel steps it; Fit is 100%, the frame fitted to the canvas) -----------------------
507
+ const setZoom = (value) => { viewportKit.setZoom(value); };
508
+ zoomOut.addEventListener("click", () => viewportKit.step(-1));
509
+ zoomIn.addEventListener("click", () => viewportKit.step(1));
510
+ zoomFit.addEventListener("click", () => viewportKit.fit());
621
511
 
622
512
  // --- preview with the host's renderer ---------------------------------------------------------------------------------------------
623
513
  function schedulePreview() {
@@ -667,26 +557,25 @@ export function createLayoutCanvas(container, options = {}) {
667
557
  content.addEventListener("mousedown", (event) => { if (!event.target.closest?.(".opf-lx-rename")) event.preventDefault(); });
668
558
  content.addEventListener("focusin", (event) => {
669
559
  const node = event.target.closest?.(".opf-lx-node");
670
- if (node && node.dataset.path !== undefined && !pending && !drag) {
560
+ if (node && node.dataset.path !== undefined && !dragger.pending && !dragger.active) {
671
561
  const path = node.dataset.path.split(".").map(Number);
672
562
  if (!isSelected(path)) model.select([path]);
673
563
  }
674
564
  });
675
565
  root.addEventListener("keydown", (event) => {
676
- if (event.target === renameInput || event.opfLayoutHandled) return;
566
+ if (event.target === renameInput || isHandled(event)) return;
677
567
  if (event.key === " " && !isTypingTarget(event.target) && (event.target === frame || event.target.closest?.(".opf-lx-node, .opf-lx-viewport")) && !event.repeat) {
678
568
  event.preventDefault();
679
- spaceHeld = true;
680
- root.dataset.space = "true";
569
+ viewportKit.holdSpace(true);
681
570
  return;
682
571
  }
683
572
  if (handleGlobalShortcut(event, model)) return;
684
573
  if (isTypingTarget(event.target)) return;
685
574
  if (event.key === "Escape") {
686
575
  event.preventDefault();
687
- event.opfLayoutHandled = true;
576
+ markHandled(event);
688
577
  if (menu) { menu.close(); return; }
689
- if (drag) { cancelDrag(); return; }
578
+ if (dragger.active) { dragger.cancel(); return; }
690
579
  if (model.tool !== "select") { model.setTool("select"); return; }
691
580
  model.selectParent();
692
581
  focusPrimary();
@@ -717,8 +606,8 @@ export function createLayoutCanvas(container, options = {}) {
717
606
  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
607
  }
719
608
  });
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; });
609
+ root.addEventListener("keyup", (event) => { if (event.key === " ") viewportKit.holdSpace(false); });
610
+ root.addEventListener("focusout", () => { viewportKit.holdSpace(false); });
722
611
 
723
612
  const unsubscribe = model.subscribe((event) => {
724
613
  if (event.type === "notice") { paintStatus(); return; }
@@ -732,13 +621,14 @@ export function createLayoutCanvas(container, options = {}) {
732
621
  model,
733
622
  refresh: render,
734
623
  setZoom,
735
- getZoom: () => zoom,
624
+ getZoom: () => viewportKit.zoom,
736
625
  focus() { focusPrimary(); },
737
626
  destroy() {
738
627
  unsubscribe();
739
628
  win.clearTimeout(previewTimer);
740
629
  previewToken++;
741
- cancelDrag();
630
+ dragger.cancel();
631
+ viewportKit.destroy();
742
632
  menu?.close({ restore: false });
743
633
  headingMenu?.close({ restore: false });
744
634
  root.remove();