@openpresentation/opf-editor 0.26.10 → 0.28.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 (52) hide show
  1. package/README.md +60 -6
  2. package/dist/canvas-variables.d.ts +15 -0
  3. package/dist/canvas-variables.js +52 -0
  4. package/dist/canvas.js +33 -5
  5. package/dist/design-controls.js +3 -2
  6. package/dist/design-options.js +1 -1
  7. package/dist/index.d.ts +37 -1
  8. package/dist/index.js +50 -3
  9. package/dist/json-editor.d.ts +33 -0
  10. package/dist/json-editor.js +83 -2
  11. package/dist/layout-adapter.js +96 -0
  12. package/dist/layout-canvas.js +122 -232
  13. package/dist/layout-editor-ui.js +46 -185
  14. package/dist/layout-inspector.js +36 -99
  15. package/dist/layout-layers.js +75 -92
  16. package/dist/layout-model.js +62 -103
  17. package/dist/react.d.ts +38 -0
  18. package/dist/react.js +127 -0
  19. package/dist/slide-retention.js +13 -0
  20. package/dist/source-color-scheme.js +60 -0
  21. package/dist/source-pane.d.ts +85 -0
  22. package/dist/source-pane.js +151 -9
  23. package/dist/studio-kit/commands.d.ts +38 -0
  24. package/dist/studio-kit/commands.js +148 -0
  25. package/dist/studio-kit/dom.d.ts +36 -0
  26. package/dist/studio-kit/dom.js +162 -0
  27. package/dist/studio-kit/drag.d.ts +26 -0
  28. package/dist/studio-kit/drag.js +127 -0
  29. package/dist/studio-kit/gesture.d.ts +29 -0
  30. package/dist/studio-kit/gesture.js +87 -0
  31. package/dist/studio-kit/index.d.ts +29 -0
  32. package/dist/studio-kit/index.js +12 -0
  33. package/dist/studio-kit/inspector.d.ts +22 -0
  34. package/dist/studio-kit/inspector.js +119 -0
  35. package/dist/studio-kit/overlay.d.ts +29 -0
  36. package/dist/studio-kit/overlay.js +182 -0
  37. package/dist/studio-kit/selection.d.ts +30 -0
  38. package/dist/studio-kit/selection.js +106 -0
  39. package/dist/studio-kit/tree.d.ts +9 -0
  40. package/dist/studio-kit/tree.js +82 -0
  41. package/dist/studio-kit/viewport.d.ts +29 -0
  42. package/dist/studio-kit/viewport.js +153 -0
  43. package/dist/styles.css +325 -7
  44. package/dist/workspace-layouts.d.ts +31 -0
  45. package/dist/workspace-layouts.js +84 -0
  46. package/dist/workspace-measure.js +35 -0
  47. package/dist/workspace-shell.js +96 -0
  48. package/dist/workspace-source.js +83 -0
  49. package/dist/workspace-theme.js +142 -0
  50. package/dist/workspace.d.ts +413 -0
  51. package/dist/workspace.js +1274 -0
  52. package/package.json +29 -11
@@ -1,10 +1,12 @@
1
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
2
  // drag to reorder or to move between splits, and see which nodes have findings; the findings are listed under the tree.
3
+ // CE-18: the tree items, the tree keyboard, the drag controller and the drop place are the studio kit's (src/studio-kit/).
3
4
  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;
5
+ import { trialMove } from "./layout-adapter.js";
6
+ import { isHeading, isPrefix, isSplit, nodeAt, pathKey, samePath } from "./layout-tree.js";
7
+ import { SIZE_LABEL, createDom, handleGlobalShortcut, handleSelectionShortcut, isHandled, isTypingTarget, nextId, nodeMenuItems, openMenu } from "./layout-editor-ui.js";
8
+ import { hitRow, rovingTabStop, rowDropMode, showTreeDrop, treeItem, treeKeyAction } from "./studio-kit/tree.js";
9
+ import { createDragController } from "./studio-kit/drag.js";
8
10
  const parsePath = (text) => (text === "" || text === undefined ? [] : text.split(".").map(Number));
9
11
 
10
12
  /**
@@ -36,8 +38,6 @@ export function createLayoutTree(container, options = {}) {
36
38
  container.append(root);
37
39
 
38
40
  const collapsed = new Set();
39
- let drag = null;
40
- let pending = null;
41
41
  let menu = null;
42
42
  let renameInput = null;
43
43
 
@@ -46,10 +46,11 @@ export function createLayoutTree(container, options = {}) {
46
46
 
47
47
  function build(path, entry, level, direction, position) {
48
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)); }
49
+ const li = treeItem(dom, {
50
+ level, selected: isSelected(path), expanded: rootItem || split ? !collapsed.has(pathKey(path)) : undefined,
51
+ setsize: rootItem ? undefined : position.size, posinset: rootItem ? undefined : position.index + 1,
52
+ attributes: { "data-path": pathKey(path), "data-kind": rootItem ? "layout" : split ? "split" : "region" },
53
+ });
53
54
  const issues = model.findingsFor(path);
54
55
  const within = model.findingsWithin(path);
55
56
  const row = el("div", "opf-lx-row", { "data-selected": String(isSelected(path)), "data-finding": issues.length ? "error" : within.length ? "within" : "" });
@@ -96,7 +97,7 @@ export function createLayoutTree(container, options = {}) {
96
97
  list.replaceChildren(build([], model.record, 1, "column", { index: 0, size: 1 }));
97
98
  const primary = model.primary ?? [];
98
99
  const stop = itemOf(primary) ?? itemOf([]);
99
- for (const item of list.querySelectorAll(".opf-lx-item")) item.tabIndex = item === stop ? 0 : -1;
100
+ rovingTabStop(list.querySelectorAll(".opf-lx-item"), stop);
100
101
  if (focusedKey !== undefined) { const next = itemOf(primary) ?? itemOf(parsePath(focusedKey)) ?? stop; next?.focus({ preventScroll: true }); }
101
102
  paintFindings();
102
103
  syncRename();
@@ -174,7 +175,7 @@ export function createLayoutTree(container, options = {}) {
174
175
  });
175
176
  list.addEventListener("focusin", (event) => {
176
177
  const item = event.target.closest?.(".opf-lx-item");
177
- if (!item || event.target !== item || drag || pending) return;
178
+ if (!item || event.target !== item || dragger.active || dragger.pending) return;
178
179
  const path = parsePath(item.dataset.path);
179
180
  if (!path.length) { if (model.selection.length) model.select([]); } else if (!isSelected(path)) model.select([path]);
180
181
  });
@@ -197,76 +198,64 @@ export function createLayoutTree(container, options = {}) {
197
198
  const path = rowPath(event);
198
199
  if (!path?.length) return;
199
200
  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
+ dragger.press(event, { paths });
201
202
  });
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);
203
+ list.addEventListener("pointermove", (event) => { dragger.move(event); });
204
+ list.addEventListener("pointerup", (event) => { dragger.release(event); });
205
+ list.addEventListener("pointercancel", () => { dragger.cancel(); });
206
+ list.addEventListener("touchmove", (event) => { if (dragger.active && event.cancelable) event.preventDefault(); }, { passive: false });
207
+ // The kit's drag controller: a press past 4px drags the rows; each move finds the drop place; the release moves once; Escape cancels.
208
+ const dragger = createDragController({
209
+ doc,
210
+ onStart(press) {
211
+ list.setPointerCapture?.(press.id);
212
+ return { paths: press.payload.paths, drop: null };
213
+ },
214
+ onStarted(drag) {
215
+ root.dataset.dragging = "true";
216
+ for (const path of drag.paths) itemOf(path)?.classList.add("is-dragging");
217
+ },
218
+ onMove(drag, event) {
219
+ const found = dropAt(drag, event);
220
+ drag.drop = found;
221
+ for (const node of list.querySelectorAll("[data-into]")) node.removeAttribute("data-into");
222
+ showTreeDrop(dropMark, scroller, found && { mode: found.mode, state: found.reason ? "refused" : found.noop ? "same" : "ok", reason: found.reason, box: found.box });
223
+ if (found?.mode === "into") found.item.setAttribute("data-into", "true");
224
+ },
225
+ onEnd() {
226
+ dropMark.hidden = true;
227
+ delete root.dataset.dragging;
228
+ for (const node of list.querySelectorAll(".is-dragging, [data-into]")) { node.classList.remove("is-dragging"); node.removeAttribute("data-into"); }
229
+ },
230
+ onDrop(drag) {
231
+ const outcome = drag.drop, paths = drag.paths;
232
+ if (!outcome || !paths) return;
233
+ if (outcome.reason) { model.notify(outcome.reason, "error"); return; }
234
+ if (outcome.noop) return;
235
+ model.move(paths, outcome.listPath, outcome.index);
236
+ },
237
+ onEscape: () => model.notify("Move cancelled."),
205
238
  });
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) {
239
+ function dropAt(drag, event) {
217
240
  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; });
241
+ const hit = hitRow(rows, event.clientY);
219
242
  if (!hit || drag.paths.some((path) => isPrefix(path, hit.path))) return null;
220
243
  const box = hit.row.getBoundingClientRect();
221
244
  const fraction = (event.clientY - box.top) / (box.height || 1);
222
245
  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; }
246
+ const mode = rowDropMode(fraction, { root: !hit.path.length, container: hit.path.length > 0 && isSplit(entry) });
247
+ let listPath, index;
248
+ if (!hit.path.length) { listPath = []; index = model.record.regions.length; }
249
+ else if (mode === "into") { listPath = hit.path; index = (entry.regions ?? []).length; }
250
+ else { listPath = hit.path.slice(0, -1); index = hit.path[hit.path.length - 1] + (mode === "before" ? 0 : 1); }
251
+ const { reason, noop } = trialMove(model.record, drag.paths, listPath, index);
229
252
  return { listPath, index, mode, reason, noop, box, item: hit.item };
230
253
  }
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
254
 
266
255
  // --- keyboard (the WAI-ARIA tree pattern) -------------------------------------------------------------------------------------------------
267
256
  const visibleItems = () => [...list.querySelectorAll(".opf-lx-item")];
268
257
  list.addEventListener("keydown", (event) => {
269
- if (event.opfLayoutHandled || event.target.closest?.(".opf-lx-rename")) return;
258
+ if (isHandled(event) || event.target.closest?.(".opf-lx-rename")) return;
270
259
  if (handleGlobalShortcut(event, model)) return;
271
260
  if (isTypingTarget(event.target)) return;
272
261
  const item = event.target.closest?.(".opf-lx-item");
@@ -275,31 +264,25 @@ export function createLayoutTree(container, options = {}) {
275
264
  if (event.key === "Escape") {
276
265
  event.preventDefault();
277
266
  if (menu) { menu.close(); return; }
278
- if (drag) { endDrag(); return; }
267
+ if (dragger.active) { dragger.cancel(); return; }
279
268
  if (path.length) { const parent = path.slice(0, -1); model.select(parent.length ? [parent] : []); itemOf(parent)?.focus({ preventScroll: true }); }
280
269
  return;
281
270
  }
282
271
  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();
272
+ const action = treeKeyAction(event, item, visibleItems(), {
273
+ expandable: item.hasAttribute("aria-expanded"),
274
+ expanded: item.getAttribute("aria-expanded") === "true",
275
+ collapsible: path.length > 0,
276
+ parent: path.length ? itemOf(path.slice(0, -1)) : null,
277
+ firstChild: item.querySelector(":scope > ul > .opf-lx-item"),
278
+ menu: path.length > 0,
279
+ });
280
+ if (!action) return;
281
+ event.preventDefault();
282
+ if (action.type === "focus") action.target?.focus();
283
+ else if (action.type === "expand" || action.type === "collapse") { toggle(path); itemOf(path)?.focus(); }
284
+ else if (action.type === "activate") { if (path.length) model.select([path]); else model.select([]); }
285
+ else if (action.type === "menu") {
303
286
  const box = item.firstElementChild.getBoundingClientRect();
304
287
  openRowMenu({ x: box.left + 24, y: box.bottom }, item);
305
288
  }
@@ -308,7 +291,7 @@ export function createLayoutTree(container, options = {}) {
308
291
  const unsubscribe = model.subscribe((event) => {
309
292
  if (event.type === "notice" || event.type === "tool") return;
310
293
  if (event.type === "view") { root.dataset.density = model.density; return; }
311
- if (drag && event.type === "record") return;
294
+ if (dragger.active && event.type === "record") return;
312
295
  render();
313
296
  });
314
297
  render();
@@ -322,7 +305,7 @@ export function createLayoutTree(container, options = {}) {
322
305
  beginRename(path) { model.requestRename(path, "layers"); },
323
306
  destroy() {
324
307
  unsubscribe();
325
- endDrag();
308
+ dragger.cancel();
326
309
  menu?.close({ restore: false });
327
310
  root.remove();
328
311
  if (ownModel) model.destroy();
@@ -1,18 +1,21 @@
1
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
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
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.
4
+ // `layout-tree.js`, validates the result and records one history step. The selection, the gesture and the snapshot history are the
5
+ // studio kit's (CE-18), over the layout's index paths (src/layout-adapter.js). No DOM, no network.
5
6
  import { validateCatalogRecord } from "@openpresentation/opf";
6
7
  import { layoutTemplateIssues } from "@openpresentation/opf/composition";
7
8
  import {
8
- LayoutOperationError, clone, deleteNodes, duplicateNodes, equalizePair, insertRegion, isSplit, listAt, moveIntoSibling, moveNodes, nodeAt, nodes, nudgePair,
9
+ LayoutOperationError, clone, deleteNodes, duplicateNodes, equalizePair, insertRegion, isSplit, moveIntoSibling, moveNodes, nodeAt, nodes, nudgePair,
9
10
  parentDirection, removeDesignHint, reorderNode, samePath, setLayoutProperty, setProperty, splitRegion, toggleHeading, unwrapNode, wrapNodes,
10
11
  } from "./layout-tree.js";
12
+ import { createSelection } from "./studio-kit/selection.js";
13
+ import { layoutSelectionAdapter } from "./layout-adapter.js";
14
+ import { createGesture, createHistory } from "./studio-kit/gesture.js";
11
15
 
12
16
  /** The `$schema` of a layout record; core's catalog validation expects it. */
13
17
  export const LAYOUT_SCHEMA = "https://openpresentation.org/schema/opf-layout/v2";
14
18
  export const LAYOUT_TOOLS = Object.freeze(["select", "split-row", "split-column", "insert", "heading", "hand"]);
15
- const COALESCE_MS = 1500;
16
19
  const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
17
20
 
18
21
  /** A blank layout: a title and one content region. */
@@ -69,7 +72,6 @@ export function createLayoutModel(options = {}) {
69
72
  const listeners = new Set();
70
73
  const state = {
71
74
  record: normalize(options.record ?? blankLayoutRecord()),
72
- selection: [],
73
75
  tool: "select",
74
76
  preview: false,
75
77
  density: options.density === "touch" ? "touch" : "comfortable",
@@ -78,37 +80,49 @@ export function createLayoutModel(options = {}) {
78
80
  notice: null,
79
81
  renaming: null,
80
82
  };
81
- const history = { entries: [], index: 0 };
82
- const limit = options.historyLimit ?? 200;
83
- let gesture = null;
83
+ const history = createHistory({ limit: options.historyLimit ?? 200 });
84
84
  let destroyed = false;
85
85
  let onChange = options.onChange;
86
86
  state.findings = validateLayoutRecord(state.record);
87
- history.entries.push({ record: clone(state.record), selection: [] });
87
+ history.reset({ record: clone(state.record), selection: [] });
88
88
 
89
89
  const emit = (event) => { if (!destroyed) for (const listener of [...listeners]) listener({ ...event, model }); };
90
90
  const notice = (message, kind = "info") => { state.notice = message ? { message, kind, at: Date.now() } : null; emit({ type: "notice" }); };
91
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]) });
92
+ const selection = createSelection(layoutSelectionAdapter(() => state.record), { onChange: ({ source }) => emit({ type: "selection", source }) });
93
+ const snapshot = () => ({ record: clone(state.record), selection: selection.snapshot() });
94
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
- }
95
+ const push = (snap, coalesce) => history.push(snap, coalesce);
96
+ // A drag is one step: drafts show the record (no history, no onChange); the end pushes one snapshot; a cancel puts the start back.
97
+ const gesture = createGesture({
98
+ snapshot: () => ({ record: state.record, selection: selection.items }),
99
+ preview(record, message) {
100
+ state.record = record;
101
+ validate();
102
+ state.notice = message ? { message, kind: "info", at: Date.now(), live: true } : state.notice;
103
+ emit({ type: "record", source: "gesture" });
104
+ },
105
+ commit(start, { message }) {
106
+ if (JSON.stringify(start.record) === JSON.stringify(state.record)) { emit({ type: "record", source: "gesture-end" }); return false; }
107
+ selection.prune();
108
+ push(snapshot());
109
+ state.notice = { message, kind: "info", at: Date.now() };
110
+ emit({ type: "record", source: "gesture-end", message: state.notice.message });
111
+ fire();
112
+ return true;
113
+ },
114
+ restore(start) {
115
+ state.record = start.record;
116
+ selection.replace(start.selection);
117
+ validate();
118
+ emit({ type: "record", source: "gesture-cancel" });
119
+ },
120
+ });
107
121
 
108
122
  /** Run `operation(record)` -> `{ record, selection }`; a refusal is a notice, and nothing changes. */
109
123
  function run(message, operation, { coalesce } = {}) {
110
124
  if (state.readOnly) { notice("This layout is read only.", "error"); return false; }
111
- if (gesture) return false;
125
+ if (gesture.active) return false;
112
126
  let outcome;
113
127
  try { outcome = operation(state.record); } catch (error) {
114
128
  if (error instanceof LayoutOperationError) { notice(error.message, "error"); return false; }
@@ -116,8 +130,8 @@ export function createLayoutModel(options = {}) {
116
130
  }
117
131
  if (JSON.stringify(outcome.record) === JSON.stringify(state.record)) { if (outcome.selection) select(outcome.selection); return false; }
118
132
  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);
133
+ if (outcome.selection) selection.replace(outcome.selection.filter((path) => nodeAt(state.record, path)));
134
+ else selection.prune();
121
135
  validate();
122
136
  push(snapshot(), coalesce);
123
137
  state.notice = message ? { message: typeof message === "function" ? message(outcome) : message, kind: "info", at: Date.now() } : null;
@@ -126,18 +140,12 @@ export function createLayoutModel(options = {}) {
126
140
  return true;
127
141
  }
128
142
 
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
- }
143
+ const select = (paths, options) => selection.set(paths, options);
136
144
 
137
145
  const model = {
138
146
  get record() { return state.record; },
139
- get selection() { return state.selection; },
140
- get primary() { return state.selection[state.selection.length - 1] ?? null; },
147
+ get selection() { return selection.items; },
148
+ get primary() { return selection.primary; },
141
149
  get tool() { return state.tool; },
142
150
  get preview() { return state.preview; },
143
151
  get density() { return state.density; },
@@ -145,7 +153,7 @@ export function createLayoutModel(options = {}) {
145
153
  get findings() { return state.findings; },
146
154
  get notice() { return state.notice; },
147
155
  get renaming() { return state.renaming; },
148
- get gesturing() { return gesture !== null; },
156
+ get gesturing() { return gesture.active; },
149
157
 
150
158
  subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
151
159
  setOnChange(callback) { onChange = callback; },
@@ -153,45 +161,22 @@ export function createLayoutModel(options = {}) {
153
161
  /** Replace the record (a layout the host loaded). Resets the selection and, by default, the history; does not call `onChange`. */
154
162
  setRecord(record, { keepHistory = false } = {}) {
155
163
  state.record = normalize(record);
156
- state.selection = [];
157
- gesture = null;
164
+ selection.replace([]);
165
+ gesture.reset();
158
166
  state.renaming = null;
159
167
  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; }
168
+ if (keepHistory) history.append(snapshot());
169
+ else history.reset(snapshot());
162
170
  emit({ type: "load" });
163
171
  },
164
172
 
165
173
  select,
166
174
  /** 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
- },
175
+ selectAdd: (path) => selection.add(path),
176
+ selectParent: () => selection.selectParent(),
177
+ selectChild: () => selection.selectChild(),
186
178
  /** 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
- },
179
+ selectSibling: (delta) => selection.selectSibling(delta),
195
180
  setTool(tool) {
196
181
  if (!LAYOUT_TOOLS.includes(tool) || state.tool === tool) return false;
197
182
  state.tool = tool;
@@ -224,42 +209,17 @@ export function createLayoutModel(options = {}) {
224
209
 
225
210
  // --- gestures: a drag is one step ---
226
211
  beginGesture(label) {
227
- if (state.readOnly || gesture) return false;
228
- gesture = { label, record: state.record, selection: state.selection };
229
- return true;
212
+ if (state.readOnly || gesture.active) return false;
213
+ return gesture.begin(label);
230
214
  },
231
215
  /** 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
- },
216
+ updateGesture(record, message) { gesture.update(record, message); },
217
+ endGesture: (message) => gesture.end(message),
218
+ cancelGesture() { gesture.cancel(); },
259
219
 
260
220
  // --- history ---
261
- canUndo: () => history.index > 0 && !gesture,
262
- canRedo: () => history.index < history.entries.length - 1 && !gesture,
221
+ canUndo: () => history.canUndo() && !gesture.active,
222
+ canRedo: () => history.canRedo() && !gesture.active,
263
223
  undo() { return jump(-1); },
264
224
  redo() { return jump(1); },
265
225
 
@@ -277,12 +237,11 @@ export function createLayoutModel(options = {}) {
277
237
  };
278
238
 
279
239
  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];
240
+ if (gesture.active) return false;
241
+ const entry = history.go(step);
242
+ if (!entry) return false;
284
243
  state.record = clone(entry.record);
285
- state.selection = existing(entry.selection);
244
+ selection.replace(selection.existing(entry.selection));
286
245
  validate();
287
246
  state.notice = { message: step < 0 ? "Undid the last change" : "Redid the change", kind: "info", at: Date.now() };
288
247
  emit({ type: "record", source: "history", message: state.notice.message });
package/dist/react.d.ts CHANGED
@@ -30,3 +30,41 @@ export declare function createOPFReactComponents(React: {
30
30
  OPFTextInput(props: OPFTextInputProps): unknown;
31
31
  OPFCatalogSelect(props: OPFCatalogSelectProps): unknown;
32
32
  };
33
+
34
+ // ---- CE-06: the embeddable editor ---------------------------------------------------------------------------------------------------------
35
+ import type { CSSProperties, ForwardRefExoticComponent, MutableRefObject, RefAttributes } from "react";
36
+ import type { SourceCursor } from "./source-pane.js";
37
+ import type { OpfEditor as OpfEditorHandle, OpfEditorOptions, OpfEditorSlots } from "./workspace.js";
38
+
39
+ /** A slot is an element, or a React ref to one (resolved when the editor mounts). */
40
+ export type OpfEditorReactSlots = { [Name in keyof OpfEditorSlots]?: OpfEditorSlots[Name] | { current: OpfEditorSlots[Name] | null } };
41
+
42
+ export interface OpfEditorProps extends Omit<OpfEditorOptions, "slots"> {
43
+ slots?: OpfEditorReactSlots;
44
+ className?: string;
45
+ style?: CSSProperties;
46
+ id?: string;
47
+ /** Called with the editor right after it is created, and with `null` when it is destroyed. For the hooks below. */
48
+ onEditor?: (editor: OpfEditorHandle | null) => void;
49
+ }
50
+
51
+ /**
52
+ * The editor in a `<div>`. Every other prop is an option of `createOpfEditor`, read once when it mounts (give the component another `key` to mount a
53
+ * new one); `theme`, `readOnly`, `view`, `density` and `exportMode` follow later renders, and the callbacks are always the latest. `ref` is the
54
+ * editor handle once mounted. Correct under StrictMode; renders an empty container on the server.
55
+ */
56
+ export declare const OpfEditor: ForwardRefExoticComponent<OpfEditorProps & RefAttributes<OpfEditorHandle | null>>;
57
+
58
+ /** The editor as a hook: `const { ref, editor } = useOpfEditor(options)`, then `<div ref={ref} />`. `editor` is `null` on the server and until mounted. */
59
+ export declare function useOpfEditor(options?: OpfEditorProps): { ref: MutableRefObject<HTMLDivElement | null>; editor: OpfEditorHandle | null };
60
+
61
+ /** The document of an editor, replaced on every change; `undefined` while `editor` is `null`. Frozen: do not modify it. */
62
+ export declare function useOpfDocument(editor: OpfEditorHandle | null | undefined): unknown;
63
+
64
+ /** The content last selected on the canvas, or `null`. */
65
+ export declare function useOpfSelection(editor: OpfEditorHandle | null | undefined): { path: string; value: unknown } | null;
66
+
67
+ /** The path under the caret of the source pane, the latest `cursor` event (`pointer`, `idPath`, `from`, `to`, ...), or `null` before the first. */
68
+ export declare function useOpfCursor(editor: OpfEditorHandle | null | undefined): SourceCursor | null;
69
+
70
+ export type { OpfEditorHandle };