@openpresentation/opf-editor 0.27.0 → 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 (40) hide show
  1. package/README.md +4 -4
  2. package/dist/design-controls.js +3 -2
  3. package/dist/design-options.js +1 -1
  4. package/dist/json-editor.d.ts +33 -0
  5. package/dist/json-editor.js +83 -2
  6. package/dist/layout-adapter.js +96 -0
  7. package/dist/layout-canvas.js +122 -232
  8. package/dist/layout-editor-ui.js +46 -185
  9. package/dist/layout-inspector.js +36 -99
  10. package/dist/layout-layers.js +75 -92
  11. package/dist/layout-model.js +62 -103
  12. package/dist/react.d.ts +4 -0
  13. package/dist/react.js +9 -2
  14. package/dist/source-color-scheme.js +60 -0
  15. package/dist/source-pane.d.ts +85 -0
  16. package/dist/source-pane.js +151 -9
  17. package/dist/studio-kit/commands.d.ts +38 -0
  18. package/dist/studio-kit/commands.js +148 -0
  19. package/dist/studio-kit/dom.d.ts +36 -0
  20. package/dist/studio-kit/dom.js +162 -0
  21. package/dist/studio-kit/drag.d.ts +26 -0
  22. package/dist/studio-kit/drag.js +127 -0
  23. package/dist/studio-kit/gesture.d.ts +29 -0
  24. package/dist/studio-kit/gesture.js +87 -0
  25. package/dist/studio-kit/index.d.ts +29 -0
  26. package/dist/studio-kit/index.js +12 -0
  27. package/dist/studio-kit/inspector.d.ts +22 -0
  28. package/dist/studio-kit/inspector.js +119 -0
  29. package/dist/studio-kit/overlay.d.ts +29 -0
  30. package/dist/studio-kit/overlay.js +182 -0
  31. package/dist/studio-kit/selection.d.ts +30 -0
  32. package/dist/studio-kit/selection.js +106 -0
  33. package/dist/studio-kit/tree.d.ts +9 -0
  34. package/dist/studio-kit/tree.js +82 -0
  35. package/dist/studio-kit/viewport.d.ts +29 -0
  36. package/dist/studio-kit/viewport.js +153 -0
  37. package/dist/styles.css +28 -7
  38. package/dist/workspace.d.ts +26 -1
  39. package/dist/workspace.js +13 -0
  40. package/package.json +10 -10
@@ -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
@@ -33,6 +33,7 @@ export declare function createOPFReactComponents(React: {
33
33
 
34
34
  // ---- CE-06: the embeddable editor ---------------------------------------------------------------------------------------------------------
35
35
  import type { CSSProperties, ForwardRefExoticComponent, MutableRefObject, RefAttributes } from "react";
36
+ import type { SourceCursor } from "./source-pane.js";
36
37
  import type { OpfEditor as OpfEditorHandle, OpfEditorOptions, OpfEditorSlots } from "./workspace.js";
37
38
 
38
39
  /** A slot is an element, or a React ref to one (resolved when the editor mounts). */
@@ -63,4 +64,7 @@ export declare function useOpfDocument(editor: OpfEditorHandle | null | undefine
63
64
  /** The content last selected on the canvas, or `null`. */
64
65
  export declare function useOpfSelection(editor: OpfEditorHandle | null | undefined): { path: string; value: unknown } | null;
65
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
+
66
70
  export type { OpfEditorHandle };
package/dist/react.js CHANGED
@@ -116,7 +116,7 @@ function resolveSlots(slots) {
116
116
  }
117
117
 
118
118
  // The props that are not editor options.
119
- const COMPONENT_PROPS = new Set(["className", "style", "id", "onEditor", "children", "slots", "onChange", "onSelection", "onError", "onReady"]);
119
+ const COMPONENT_PROPS = new Set(["className", "style", "id", "onEditor", "children", "slots", "onChange", "onSelection", "onCursor", "onError", "onReady"]);
120
120
 
121
121
  function splitProps(props) {
122
122
  const options = {};
@@ -128,7 +128,7 @@ function splitProps(props) {
128
128
  /**
129
129
  * Create and destroy one editor in the element `container.current` points at. The options are read once, when the editor mounts (change the
130
130
  * `key` of the component to mount another); `theme`, `readOnly`, `view`, `density` and `exportMode` follow later renders. The callbacks
131
- * (`onChange`, `onSelection`, `onError`, `onReady`) always call the latest function passed. Returns the editor once it is mounted, else `null`.
131
+ * (`onChange`, `onSelection`, `onCursor`, `onError`, `onReady`) always call the latest function passed. Returns the editor once it is mounted, else `null`.
132
132
  */
133
133
  function useMountedEditor(container, props, onEditor) {
134
134
  const [editor, setEditor] = useState(null);
@@ -150,6 +150,7 @@ function useMountedEditor(container, props, onEditor) {
150
150
  slots: resolveSlots(latest.current.slots),
151
151
  onChange: (detail) => current().onChange?.(detail),
152
152
  onSelection: (detail) => current().onSelection?.(detail),
153
+ onCursor: (detail) => current().onCursor?.(detail),
153
154
  onError: (error, detail) => current().onError?.(error, detail),
154
155
  onReady: (detail) => current().onReady?.(detail),
155
156
  });
@@ -216,3 +217,9 @@ export function useOpfSelection(editor) {
216
217
  const subscribe = useCallback(subscribeTo(editor, "selection"), [editor]);
217
218
  return useSyncExternalStore(subscribe, () => (editor ? editor.selection : null), () => null);
218
219
  }
220
+
221
+ /** The path under the caret of the source pane (the latest `cursor` event: `{ pointer, idPath, from, to, ... }`), or `null` before the first. */
222
+ export function useOpfCursor(editor) {
223
+ const subscribe = useCallback(subscribeTo(editor, "cursor"), [editor]);
224
+ return useSyncExternalStore(subscribe, () => (editor ? editor.sourceCursor : null), () => null);
225
+ }
@@ -0,0 +1,60 @@
1
+ import { Compartment } from "@codemirror/state";
2
+ import { EditorView, ViewPlugin } from "@codemirror/view";
3
+
4
+ // Selection contrast: CodeMirror styles an editor as light or dark through the `EditorView.darkTheme` facet, and its own base theme paints a selection
5
+ // by that flag (`#d7d4f0` for a light editor, drawn behind the text). The editor's colors come from the `--opf-ui-*` tokens, so the flag has to follow
6
+ // the color scheme those tokens resolve to, or a dark surface keeps light text on a light selection. `colorSchemeExtension(parent)` is that facet,
7
+ // kept in step with the page: it reads the `color-scheme` the surface resolves to (the tokens set it with the scheme), and looks again when a class,
8
+ // `data-theme` or `style` attribute changes on the editor or any ancestor, when `prefers-color-scheme` changes (a surface left to `light dark`) and
9
+ // when the editor takes focus.
10
+
11
+ /** True when `element` is drawn in a dark color scheme: its resolved `color-scheme` is `dark`, or lists both and the system prefers dark. */
12
+ export function prefersDark(element) {
13
+ const win = element?.ownerDocument?.defaultView;
14
+ if (!win || !element.isConnected) return false;
15
+ const scheme = win.getComputedStyle(element).colorScheme.split(/\s+/);
16
+ if (scheme.includes("dark")) return scheme.includes("light") ? Boolean(win.matchMedia?.("(prefers-color-scheme: dark)").matches) : true;
17
+ return false;
18
+ }
19
+
20
+ const DARK = EditorView.darkTheme.of(true);
21
+
22
+ /** The extension for a view that will be mounted in `parent`: the dark flag now, and the plugin that keeps it in step. */
23
+ export function colorSchemeExtension(parent) {
24
+ const scheme = new Compartment();
25
+ const watcher = ViewPlugin.fromClass(class {
26
+ constructor(view) {
27
+ this.view = view;
28
+ this.dark = prefersDark(parent.isConnected ? parent : view.dom);
29
+ this.dead = false;
30
+ this.queued = false;
31
+ const win = view.dom.ownerDocument.defaultView;
32
+ this.observer = typeof win?.MutationObserver === "function" ? new win.MutationObserver(() => this.queue()) : null;
33
+ for (let node = view.dom; node; node = node.parentElement) this.observer?.observe(node, { attributes: true, attributeFilter: ["class", "data-theme", "style"] });
34
+ this.media = win?.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
35
+ this.onMedia = () => this.queue();
36
+ this.media?.addEventListener?.("change", this.onMedia);
37
+ // The surface may not be in the document yet (the host mounts it first, attaches it after): look again once this update is over.
38
+ this.queue();
39
+ }
40
+ update(update) { if (update.focusChanged) this.queue(); }
41
+ queue() {
42
+ if (this.queued || this.dead) return;
43
+ this.queued = true;
44
+ queueMicrotask(() => { this.queued = false; this.sync(); });
45
+ }
46
+ sync() {
47
+ if (this.dead) return;
48
+ const dark = prefersDark(this.view.dom);
49
+ if (dark === this.dark) return;
50
+ this.dark = dark;
51
+ this.view.dispatch({ effects: scheme.reconfigure(dark ? DARK : []) });
52
+ }
53
+ destroy() {
54
+ this.dead = true;
55
+ this.observer?.disconnect();
56
+ this.media?.removeEventListener?.("change", this.onMedia);
57
+ }
58
+ });
59
+ return [scheme.of(prefersDark(parent) ? DARK : []), watcher];
60
+ }