@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
package/dist/react.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createElement, forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, useSyncExternalStore } from "react";
1
2
  import { commitCatalogControl } from "./catalog-control.js";
2
3
  import {
3
4
  getCatalogOptions,
@@ -96,3 +97,129 @@ export function createOPFReactComponents(React) {
96
97
  OPFCatalogSelect
97
98
  };
98
99
  }
100
+
101
+ // ---- CE-06: the embeddable editor for React 18 and 19 ----------------------------------------------------------------------------------
102
+ // `<OpfEditor>` mounts `createOpfEditor` (src/workspace.js) in an effect and destroys it in the cleanup, so it is correct under StrictMode's
103
+ // mount, unmount, mount: the editor module is loaded on demand (an import that resolves after both effects), the first effect's request is
104
+ // cancelled by its cleanup, and only the second creates an editor. The server renders an empty container; nothing of the editor
105
+ // (and none of its code) runs there. React is an optional peer: only this entry point needs it.
106
+
107
+ const NO_STOP = () => {};
108
+ const isRef = (value) => value !== null && typeof value === "object" && "current" in value;
109
+
110
+ /** `slots` whose values are React refs (`useRef`) resolve to their elements when the editor mounts, after the commit that attached them. */
111
+ function resolveSlots(slots) {
112
+ if (!slots) return undefined;
113
+ const resolved = {};
114
+ for (const [name, value] of Object.entries(slots)) resolved[name] = isRef(value) ? value.current : value;
115
+ return resolved;
116
+ }
117
+
118
+ // The props that are not editor options.
119
+ const COMPONENT_PROPS = new Set(["className", "style", "id", "onEditor", "children", "slots", "onChange", "onSelection", "onCursor", "onError", "onReady"]);
120
+
121
+ function splitProps(props) {
122
+ const options = {};
123
+ const rest = {};
124
+ for (const [name, value] of Object.entries(props)) (COMPONENT_PROPS.has(name) ? rest : options)[name] = value;
125
+ return { options, rest };
126
+ }
127
+
128
+ /**
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
+ * `key` of the component to mount another); `theme`, `readOnly`, `view`, `density` and `exportMode` follow later renders. The callbacks
131
+ * (`onChange`, `onSelection`, `onCursor`, `onError`, `onReady`) always call the latest function passed. Returns the editor once it is mounted, else `null`.
132
+ */
133
+ function useMountedEditor(container, props, onEditor) {
134
+ const [editor, setEditor] = useState(null);
135
+ const latest = useRef(props);
136
+ latest.current = props;
137
+ const report = useRef(onEditor);
138
+ report.current = onEditor;
139
+ useEffect(() => {
140
+ const element = container.current;
141
+ if (!element) return undefined;
142
+ let cancelled = false;
143
+ let created = null;
144
+ import("./workspace.js").then(({ createOpfEditor }) => {
145
+ if (cancelled) return;
146
+ const { options } = splitProps(latest.current);
147
+ const current = () => latest.current;
148
+ created = createOpfEditor(element, {
149
+ ...options,
150
+ slots: resolveSlots(latest.current.slots),
151
+ onChange: (detail) => current().onChange?.(detail),
152
+ onSelection: (detail) => current().onSelection?.(detail),
153
+ onCursor: (detail) => current().onCursor?.(detail),
154
+ onError: (error, detail) => current().onError?.(error, detail),
155
+ onReady: (detail) => current().onReady?.(detail),
156
+ });
157
+ setEditor(created);
158
+ report.current?.(created);
159
+ }, (error) => {
160
+ if (!cancelled) latest.current.onError?.(error instanceof Error ? error : new Error(String(error)), {});
161
+ });
162
+ return () => {
163
+ cancelled = true;
164
+ if (!created) return;
165
+ created.destroy();
166
+ created = null;
167
+ setEditor(null);
168
+ report.current?.(null);
169
+ };
170
+ // The editor mounts once per element: later options are applied by the effects below, or by a new `key`.
171
+ // eslint-disable-next-line react-hooks/exhaustive-deps
172
+ }, [container]);
173
+ const { theme, readOnly, view, density, exportMode } = props;
174
+ // A theme written inline in JSX is a new object on every render; it is applied again only when its content changed.
175
+ const themeKey = theme === undefined ? undefined : JSON.stringify(theme);
176
+ // eslint-disable-next-line react-hooks/exhaustive-deps
177
+ useEffect(() => { if (editor && theme !== undefined) editor.setTheme(theme); }, [editor, themeKey]);
178
+ useEffect(() => { if (editor && readOnly !== undefined) editor.setReadOnly(readOnly); }, [editor, readOnly]);
179
+ useEffect(() => { if (editor && view !== undefined && editor.view !== view) editor.setView(view); }, [editor, view]);
180
+ useEffect(() => { if (editor && density !== undefined) editor.setDensity(density); }, [editor, density]);
181
+ useEffect(() => { if (editor && exportMode !== undefined) editor.setExportMode(exportMode); }, [editor, exportMode]);
182
+ return editor;
183
+ }
184
+
185
+ /**
186
+ * The editor in a `<div>`. Every prop that is not `className`, `style`, `id`, `slots` or a callback is an option of `createOpfEditor`;
187
+ * `ref` is the editor handle once mounted, and `onEditor(editor | null)` tells the parent when it is created and destroyed (for the hooks below).
188
+ */
189
+ export const OpfEditor = forwardRef(function OpfEditor(props, ref) {
190
+ const container = useRef(null);
191
+ const editor = useMountedEditor(container, props, props.onEditor);
192
+ useImperativeHandle(ref, () => editor, [editor]);
193
+ const { rest } = splitProps(props);
194
+ return createElement("div", { ref: container, id: rest.id, className: rest.className, style: rest.style, "data-opf-react": "" });
195
+ });
196
+
197
+ /**
198
+ * The editor as a hook, for a host that renders its own element: `const { ref, editor } = useOpfEditor(options)` and `<div ref={ref} />`.
199
+ * `editor` is `null` on the server and until the client has mounted it. Options are read once, as for `<OpfEditor>`.
200
+ */
201
+ export function useOpfEditor(options = {}) {
202
+ const ref = useRef(null);
203
+ const editor = useMountedEditor(ref, options);
204
+ return { ref, editor };
205
+ }
206
+
207
+ const subscribeTo = (editor, event) => (notify) => (editor ? editor.on(event, notify) : NO_STOP);
208
+
209
+ /** The document of an editor, replaced on every change (it is frozen, so it works as a store snapshot). `undefined` until there is an editor. */
210
+ export function useOpfDocument(editor) {
211
+ const subscribe = useCallback(subscribeTo(editor, "change"), [editor]);
212
+ return useSyncExternalStore(subscribe, () => (editor ? editor.getDocument() : undefined), () => undefined);
213
+ }
214
+
215
+ /** The content last selected on the canvas, `{ path, value }`, or `null`. */
216
+ export function useOpfSelection(editor) {
217
+ const subscribe = useCallback(subscribeTo(editor, "selection"), [editor]);
218
+ return useSyncExternalStore(subscribe, () => (editor ? editor.selection : null), () => null);
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,13 @@
1
+ // CE-06 (promoted from openpresentation.org's playground runtime): which slide to show after the document changed outside the canvas
2
+ // (the source pane, an opened file, a restored copy). Stable ids survive a reorder; a deck without ids, or an id that is not unique, keeps
3
+ // the position and clamps it to the deck.
4
+
5
+ /** @param {readonly {id?: string}[]} before @param {readonly {id?: string}[]} after @param {number} index */
6
+ export function retainedSlideIndex(before, after, index) {
7
+ const id = before[index]?.id;
8
+ if (id && before.filter((slide) => slide.id === id).length === 1) {
9
+ const matches = after.flatMap((slide, position) => (slide.id === id ? [position] : []));
10
+ if (matches.length === 1) return matches[0];
11
+ }
12
+ return Math.max(0, Math.min(index, after.length - 1));
13
+ }
@@ -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
+ }
@@ -74,7 +74,74 @@ export interface OutlineSymbol {
74
74
  line: number;
75
75
  }
76
76
 
77
+ /**
78
+ * Where the caret is in the text (CE-17), in any of the three formats. Offsets are source offsets: UTF-16 positions in the text as `getText()` returns it.
79
+ */
80
+ export interface SourceCursor {
81
+ /** The JSON Pointer of the field under the caret (`/slides/0/title`; `""` outside the document's content). On a property name or value it is that property; between members it is the container. */
82
+ pointer: string;
83
+ /** The same place addressed by id (`/slides/@intro/title`), read from the session's document; the pointer itself where an element has no id, or while the text does not read as a document. */
84
+ idPath: string;
85
+ /** The start and end of the main selection (equal for a plain caret). */
86
+ from: number;
87
+ to: number;
88
+ /** The end of the selection the caret is at; `pointer` is read here. */
89
+ head: number;
90
+ /** What the caret is on: a property name (`key`), a value, whitespace or punctuation inside a container (`between`), or outside the document (`none`). */
91
+ part: "key" | "value" | "between" | "none";
92
+ format: SourceFormat;
93
+ }
94
+
95
+ /** One mark or line class a host draws in the text (CE-17), by path or by range. */
96
+ export interface SourceDecoration {
97
+ /** A JSON Pointer (or dotted OPF path). The decoration follows the field when slides move, and is not drawn while the text has no such field. */
98
+ path?: string;
99
+ /** Which part of the field a `path` marks: the value (default), its `key`, the whole `entry` (key through value), the `open` bracket or the `colon`. */
100
+ part?: "value" | "key" | "entry" | "open" | "colon";
101
+ /** Source offsets, instead of a path. They follow the text as it is edited, as text positions, and are not placed again when the format changes. */
102
+ range?: { from: number; to?: number };
103
+ /** The class `opf-source-decoration-<kind>`; the built-in looks are `highlight` (default), `error`, `warning`, `info`, `added` and `changed`. Any other kind is styled by the host. */
104
+ kind?: string;
105
+ /** The browser tooltip of a mark. */
106
+ label?: string;
107
+ /** Put the class on every line the range touches (a `.cm-line`) instead of marking the text. */
108
+ line?: boolean;
109
+ }
110
+
111
+ /** A host decoration as drawn now (`getDecorations`). */
112
+ export interface SourceDecorationInfo {
113
+ group: string;
114
+ kind: string;
115
+ label?: string;
116
+ path?: string;
117
+ /** The current source offsets; a line decoration spans from the first line's start to the last line's end. */
118
+ from: number;
119
+ to: number;
120
+ line: boolean;
121
+ }
122
+
123
+ export interface RevealOptions {
124
+ /** Also focus the code editor (default false: the page's focus stays where it is). */
125
+ focus?: boolean;
126
+ /** Also select the range (default false: the caret stays; this also emits a `cursor` event). */
127
+ select?: boolean;
128
+ /** Where the start of the range lands: `center` (default), `start` (the top) or `nearest` (the least scrolling). */
129
+ align?: "center" | "start" | "nearest";
130
+ /** Mark the first line for a moment. */
131
+ flash?: boolean;
132
+ /** Which part of the field a path means (default `entry`: the key through the end of its value). */
133
+ part?: SourceDecoration["part"];
134
+ }
135
+
77
136
  export interface SourcePaneOptions {
137
+ /**
138
+ * The caret or selection moved (also while the pane has no focus), or the text under it changed: the path under the caret. Called after `cursorDelay` of
139
+ * quiet and not at all for a position it already reported; while a typed edit waits to be applied it waits too, so `idPath` is read from the document
140
+ * the text reads as. `getCursor()` reads the position at once.
141
+ */
142
+ onCursor?(cursor: SourceCursor): void;
143
+ /** Milliseconds of quiet before `onCursor` is called (default 50; 0 is the next tick). */
144
+ cursorDelay?: number;
78
145
  /** Add an Outline button to the bar and a panel listing the slides and other symbols of the document (`"open"` shows the panel at the start). The symbol picker (Cmd/Ctrl+Shift+O) is always there. */
79
146
  outline?: boolean | "open";
80
147
  /** Called with `getShortcuts()` when the shortcuts dialog is about to open; returning `true` means the host shows its own and the editor's dialog stays closed. */
@@ -160,6 +227,24 @@ export interface SourcePane {
160
227
  getFormat(): SourceFormat;
161
228
  /** Move the cursor to the line of a JSON Pointer (or dotted OPF path) in the current format (core's `locate` finds it in JSON, YAML and Markdown). Returns the line. */
162
229
  focusPath(path: string): number;
230
+ /** The path under the caret now (see `SourceCursor`), read at once and also while the pane has no focus. */
231
+ getCursor(): SourceCursor;
232
+ /**
233
+ * Scroll a JSON Pointer (or dotted path) or a range `{ from, to }` (source offsets) into view without moving the caret or the focus, which
234
+ * `focusPath` does. Folded text around the start is opened. Returns the range revealed, its one-based line, and whether the path is exactly in the
235
+ * text (`false`: the nearest container was revealed); `null` when the path cannot be placed at all.
236
+ */
237
+ revealPath(target: string | { from: number; to?: number }, options?: RevealOptions): { from: number; to: number; line: number; exact: boolean } | null;
238
+ /**
239
+ * Draw marks or line classes of your own, by path or range, kept apart from the editor's own decorations (squiggles, field highlights, the outline's
240
+ * marker). `group` (default `default`) names a set; a call replaces the whole set, so validation issues and the paths an agent touched can be
241
+ * two groups. `[]` or `null` clears the set. A decoration with neither a path nor a range throws a `TypeError`.
242
+ */
243
+ setDecorations(decorations: readonly SourceDecoration[] | null, options?: { group?: string }): void;
244
+ /** Clear the decorations of `group`, or all of the host's without an argument. */
245
+ clearDecorations(group?: string): void;
246
+ /** The host decorations drawn now, at their current offsets, in position order; of `group`, or all. */
247
+ getDecorations(group?: string): SourceDecorationInfo[];
163
248
  /** The text as shown, with any pending edit applied first. */
164
249
  getText(): string;
165
250
  /**
@@ -2,7 +2,7 @@ import { scanYamlComments, toYaml } from "@openpresentation/opf/yaml";
2
2
  import { toMarkdown } from "@openpresentation/opf/markdown";
3
3
  import { mountJsonCodeEditor } from "./json-editor.js";
4
4
  import { MAX_EXACT_SOURCE_LENGTH, createSourceMemory, updateJsonSource } from "./exact-source.js";
5
- import { lineForPath, offsetOfLine } from "./source-lines.js";
5
+ import { lineForPath, lineOfOffset, offsetOfLine, pathSegments } from "./source-lines.js";
6
6
  import { errorText, normalizeFinding, rejectionFindings as findingsOfError } from "./source-findings.js";
7
7
  import { formatSource } from "./source-format.js";
8
8
  import { buildProblems, summarize } from "./source-problems.js";
@@ -11,7 +11,8 @@ import { createSourceChecker } from "./source-checker.js";
11
11
  import { detailsFor } from "./source-details.js";
12
12
  import { schemaAtPath, schemaVariants } from "./schema.js";
13
13
  import { locateText } from "./source-ranges.js";
14
- import { scanMarkdown, starterValue } from "@openpresentation/opf/source";
14
+ import { pathAt, scanMarkdown, starterValue } from "@openpresentation/opf/source";
15
+ import { toIdPointer } from "@openpresentation/opf/ids";
15
16
  import { listCatalogRecords } from "./catalogs.js";
16
17
  import { documentSymbols, symbolAt } from "./source-symbols.js";
17
18
 
@@ -135,6 +136,11 @@ export function createSourcePane(container, options = {}) {
135
136
  const checker = options.worker ? createSourceChecker({ worker: options.worker, onError: (error) => options.onError?.(error) }) : null;
136
137
  let checkToken = 0, syntaxToken = 0, checkBusy = false, syntaxBusy = false;
137
138
  const stats = { worker: 0, inline: 0 };
139
+ // CE-17: the cursor event's pause and what it last said, and the host's decoration groups (below, with the cursor and the decorations).
140
+ const cursorDelay = Math.max(0, options.cursorDelay ?? 50);
141
+ let cursorTimer = null, cursorKey = "", cursorWaiting = false;
142
+ const hostGroups = new Map();
143
+ let decorationTimer = null, flashTimer = null;
138
144
  let problemRows = [], lastDraft = null, lastSummary = "No problems", summaryTimer = null, syntaxTimer = null, recheckTimer = null;
139
145
  // The exact text of the documents `replaceDocument` replaced and opened, per format, so Undo and Redo show it again byte for byte
140
146
  // (a YAML comment, the author's JSON spelling). Keyed by format and document; the oldest entries go first.
@@ -260,7 +266,8 @@ export function createSourcePane(container, options = {}) {
260
266
  onChange: () => schedule(),
261
267
  onFormat: () => formatDocument(),
262
268
  onFocusProblems: () => focusProblems(),
263
- onSelectionChange: () => scheduleOutline(),
269
+ onSelectionChange: () => { scheduleOutline(); scheduleCursor(); },
270
+ onDocChange: () => scheduleDecorations(),
264
271
  onShowShortcuts: options.onShowShortcuts,
265
272
  // The pane supplies the problems itself (every finding, with ranges and fixes).
266
273
  syntaxProblems: false,
@@ -667,6 +674,7 @@ export function createSourcePane(container, options = {}) {
667
674
  dirty = false;
668
675
  control.setLanguage(target.language);
669
676
  show(written.text);
677
+ refreshDecorations();
670
678
  if (format === "md") refreshNotes();
671
679
  // A draft the document never accepted is checked and applied again, so its findings come back with it.
672
680
  if (written.draft) { baseline = null; flush(); }
@@ -689,6 +697,7 @@ export function createSourcePane(container, options = {}) {
689
697
  syntaxFindings = [];
690
698
  noteFindings = [];
691
699
  control.setLanguage(target.language);
700
+ refreshDecorations();
692
701
  updateBar();
693
702
  baseline = null;
694
703
  flush();
@@ -1137,6 +1146,115 @@ export function createSourcePane(container, options = {}) {
1137
1146
  return true;
1138
1147
  }
1139
1148
 
1149
+ // ---- Cursor (CE-17) -----------------------------------------------------------------------------------------------
1150
+ // The document path under the caret in the text as shown, for any format (core's `pathAt` reads text that does not parse). `idPath` is the same place
1151
+ // addressed by id, read from the session's document: while the text is a draft that has not been applied, the event waits for the apply (the
1152
+ // pointer into a deck the session has not got yet would name another slide), and while the text does not read as a document `idPath` is the pointer.
1153
+ function readCursor() {
1154
+ const text = control.api.getValue();
1155
+ const [from, to] = control.api.getSelection();
1156
+ const head = control.api.getHead();
1157
+ const found = attempt(() => pathAt(text, format, head), undefined);
1158
+ const at = pointer(found?.path ?? []);
1159
+ return { pointer: at, idPath: attempt(() => toIdPointer(deck(), at), at), from, to, head, part: found?.part ?? "none", format };
1160
+ }
1161
+ const settled = () => timer === null && syntaxTimer === null && !checkBusy && !syntaxBusy;
1162
+ function scheduleCursor() {
1163
+ if (destroyed || !options.onCursor) return;
1164
+ clearTimeout(cursorTimer);
1165
+ cursorTimer = setTimeout(emitCursor, cursorDelay);
1166
+ }
1167
+ function emitCursor() {
1168
+ cursorTimer = null;
1169
+ if (destroyed) return;
1170
+ if (!settled()) {
1171
+ if (!cursorWaiting) { cursorWaiting = true; idle().then(() => { cursorWaiting = false; scheduleCursor(); }); }
1172
+ return;
1173
+ }
1174
+ const detail = readCursor();
1175
+ const key = JSON.stringify([detail.format, detail.from, detail.to, detail.head, detail.pointer, detail.idPath, detail.part]);
1176
+ if (key === cursorKey) return;
1177
+ cursorKey = key;
1178
+ try { options.onCursor?.(detail); } catch (error) { options.onError?.(error); }
1179
+ }
1180
+
1181
+ // ---- Host decorations and reveal (CE-17) --------------------------------------------------------------------------------
1182
+ // A host's marks and line classes live in the code editor's own sets (`host:range:<group>` and `host:path:<group>`), apart from the editor's
1183
+ // squiggles and field highlights. A range follows the text as it is edited; a path is found again after each edit (and at once when the format
1184
+ // changes), so it stays on its field when slides move.
1185
+ const kindClass = (kind) => `opf-source-decoration opf-source-decoration-${String(kind || "highlight").toLowerCase().replace(/[^a-z0-9_-]+/g, "-")}`;
1186
+ const hostItem = (group, spec, from, to) => ({ from, to, line: Boolean(spec.line), className: kindClass(spec.kind), title: spec.label, data: { group, kind: spec.kind || "highlight", ...(spec.path !== undefined ? { path: spec.path } : {}) } });
1187
+ function drawPaths(group) {
1188
+ const entry = hostGroups.get(group);
1189
+ if (!entry) return;
1190
+ const text = control.api.getValue();
1191
+ const items = [];
1192
+ for (const spec of entry.paths) {
1193
+ const found = attempt(() => locateText(format, text, pathSegments(spec.path), spec.part ?? "value"), null);
1194
+ if (found?.exact) items.push(hostItem(group, spec, found.from, found.to));
1195
+ }
1196
+ control.setDecorations(`host:path:${group}`, items);
1197
+ }
1198
+ function refreshDecorations() {
1199
+ clearTimeout(decorationTimer);
1200
+ decorationTimer = null;
1201
+ if (!destroyed) for (const [group, entry] of hostGroups) if (entry.paths.length) drawPaths(group);
1202
+ }
1203
+ function scheduleDecorations() {
1204
+ if (destroyed || ![...hostGroups.values()].some((entry) => entry.paths.length)) return;
1205
+ clearTimeout(decorationTimer);
1206
+ decorationTimer = setTimeout(refreshDecorations, 120);
1207
+ }
1208
+ function clearGroup(group) {
1209
+ hostGroups.delete(group);
1210
+ control.clearDecorations(`host:range:${group}`);
1211
+ control.clearDecorations(`host:path:${group}`);
1212
+ }
1213
+ function setHostDecorations(list, { group = "default" } = {}) {
1214
+ if (destroyed) return;
1215
+ const specs = list === null || list === undefined ? [] : list;
1216
+ if (!Array.isArray(specs)) throw new TypeError("setDecorations needs a list of decorations (or null to clear).");
1217
+ const ranges = [], paths = [];
1218
+ for (const spec of specs) {
1219
+ if (typeof spec?.path === "string") paths.push(spec);
1220
+ else if (Number.isFinite(spec?.range?.from) && Number.isFinite(spec?.range?.to ?? spec.range.from)) ranges.push(spec);
1221
+ else throw new TypeError("A decoration needs a path (a JSON Pointer) or a range ({ from, to } in source offsets).");
1222
+ }
1223
+ if (!specs.length) { clearGroup(group); return; }
1224
+ hostGroups.set(group, { paths });
1225
+ control.setDecorations(`host:range:${group}`, ranges.map((spec) => hostItem(group, spec, spec.range.from, spec.range.to ?? spec.range.from)));
1226
+ drawPaths(group);
1227
+ }
1228
+ function revealTarget(target, { focus = false, select = false, align = "center", flash = false, part = "entry" } = {}) {
1229
+ let range;
1230
+ if (typeof target === "string") {
1231
+ const found = attempt(() => locateText(format, control.api.getValue(), pathSegments(target), part), null);
1232
+ if (!found) return null;
1233
+ range = found;
1234
+ } else if (Number.isFinite(target?.from)) {
1235
+ const size = control.api.getValue().length;
1236
+ const from = Math.min(size, Math.max(0, target.from));
1237
+ range = { from, to: Math.min(size, Math.max(from, Number.isFinite(target.to) ? target.to : from)), exact: true };
1238
+ } else throw new TypeError("revealPath needs a JSON Pointer or a range ({ from, to } in source offsets).");
1239
+ control.reveal(range.from, range.to, { align, select, focus });
1240
+ if (flash) {
1241
+ control.setDecorations("flash", [{ from: range.from, to: range.from, line: true, className: "opf-source-flash" }]);
1242
+ clearTimeout(flashTimer);
1243
+ flashTimer = setTimeout(() => { flashTimer = null; if (!destroyed) control.clearDecorations("flash"); }, 1200);
1244
+ }
1245
+ return { from: range.from, to: range.to, line: lineOfOffset(control.api.getValue(), range.from), exact: range.exact };
1246
+ }
1247
+ function hostDecorationList(group) {
1248
+ return control.getDecorations().filter((item) => item.key.startsWith("host:") && (group === undefined || item.data.group === group))
1249
+ .map((item) => ({ group: item.data.group, kind: item.data.kind, ...(item.title ? { label: item.title } : {}), ...(item.data.path !== undefined ? { path: item.data.path } : {}), from: item.from, to: item.to, line: item.line }));
1250
+ }
1251
+ function idle() {
1252
+ return new Promise((resolve) => {
1253
+ const poll = () => { if (destroyed || settled()) resolve(); else setTimeout(poll, 4); };
1254
+ poll();
1255
+ });
1256
+ }
1257
+
1140
1258
  // ---- Public API ---------------------------------------------------------------------------------------------------
1141
1259
  function focusPath(path, finding) {
1142
1260
  flush();
@@ -1220,15 +1338,36 @@ export function createSourcePane(container, options = {}) {
1220
1338
  getOutline: () => documentSymbols(format, control.api.getValue()),
1221
1339
  /** Show or hide the outline panel (needs the `outline` option); with no argument it toggles. Returns whether the panel is open. */
1222
1340
  toggleOutline(open) { if (!outlinePanel) return false; setOutlineOpen(open ?? outlinePanel.hidden); return !outlinePanel.hidden; },
1341
+ /**
1342
+ * CE-17: the path under the caret: `{ pointer, idPath, from, to, head, part, format }` (offsets are source offsets), also when the pane has no focus.
1343
+ * Read now, without waiting for the pending edit or the pause `onCursor` uses.
1344
+ */
1345
+ getCursor: () => readCursor(),
1346
+ /**
1347
+ * CE-17: scroll a JSON Pointer (or dotted path) or a range `{ from, to }` into view without moving the caret or the focus; folded text is opened.
1348
+ * `focus` and `select` do move them. `align`: "center" (default), "start" or "nearest"; `flash` marks the first line for a moment; `part`: which part of
1349
+ * the field a path means (default `entry`: the key through its value). Returns the range, its line and whether the path is exactly there, or `null`.
1350
+ */
1351
+ revealPath: revealTarget,
1352
+ /**
1353
+ * CE-17: draw marks (or line classes) a host owns, by path or range, apart from the editor's own decorations. `group` (default `default`) names a set, and a
1354
+ * call replaces it. A range follows the text as it is edited; a path is placed again after each edit, and not drawn while the text has no such field.
1355
+ * `[]` or `null` clears the set.
1356
+ */
1357
+ setDecorations: setHostDecorations,
1358
+ /** CE-17: clear the decorations of `group`, or every host decoration without an argument. */
1359
+ clearDecorations(group) {
1360
+ if (destroyed) return;
1361
+ if (group !== undefined) { clearGroup(group); return; }
1362
+ for (const name of [...hostGroups.keys()]) clearGroup(name);
1363
+ control.clearDecorations();
1364
+ },
1365
+ /** CE-17: the host decorations drawn now at their current offsets: `{ group, kind, label?, path?, from, to, line }`. */
1366
+ getDecorations: (group) => hostDecorationList(group),
1223
1367
  /** The code editor, for hosts that focus or measure it. */
1224
1368
  editor: control,
1225
1369
  /** Resolves once no edit is waiting for its delay and no check is running (a worker's included): the text on screen has been read and applied. */
1226
- whenIdle() {
1227
- return new Promise((resolve) => {
1228
- const poll = () => { if (destroyed || (timer === null && syntaxTimer === null && !checkBusy && !syntaxBusy)) resolve(); else setTimeout(poll, 4); };
1229
- poll();
1230
- });
1231
- },
1370
+ whenIdle: idle,
1232
1371
  /** How the applied edits were checked: `worker` and `inline` counts, and whether the worker is still usable (`available`). */
1233
1372
  checkerStats: () => ({ worker: stats.worker, inline: stats.inline, available: Boolean(checker?.available) }),
1234
1373
  destroy() {
@@ -1243,6 +1382,9 @@ export function createSourcePane(container, options = {}) {
1243
1382
  clearTimeout(recheckTimer);
1244
1383
  clearTimeout(outlineTimer);
1245
1384
  clearTimeout(outlineMarkTimer);
1385
+ clearTimeout(cursorTimer);
1386
+ clearTimeout(decorationTimer);
1387
+ clearTimeout(flashTimer);
1246
1388
  unsubscribe();
1247
1389
  document.removeEventListener("pointerdown", outside, true);
1248
1390
  control.destroy();
@@ -0,0 +1,38 @@
1
+ /** CE-18 studio kit (internal): the command registry and keymap. */
2
+ export interface KitCommand<Ctx = unknown> {
3
+ id: string;
4
+ label: string;
5
+ group?: string;
6
+ /** The scope(s) `handle` runs it in (default "global"). */
7
+ scope?: string | string[];
8
+ /** CodeMirror-style key specs: "Mod-z", "Shift-Mod-z", "Ctrl-y", "[", "Delete", "F2". */
9
+ keys?: string[];
10
+ /** What a menu shows instead of the formatted first key. */
11
+ hint?: string;
12
+ /** false: a held key does not repeat the command. */
13
+ repeat?: boolean;
14
+ /** true runs it; false or a reason string disables it. */
15
+ when?(ctx: Ctx): boolean | string;
16
+ run(ctx: Ctx, event?: KeyboardEvent): unknown;
17
+ }
18
+ export interface KitCommandInfo { id: string; label: string; group?: string; keys: string[]; shortcut: string; enabled: boolean; reason: string }
19
+ export interface KitKey { key: string; shift: boolean; alt: boolean; mod: boolean; ctrl: boolean; meta: boolean }
20
+ export interface KitCommandRegistry<Ctx = unknown> {
21
+ register(command: KitCommand<Ctx>): () => void;
22
+ get(id: string): KitCommand<Ctx> | undefined;
23
+ has(id: string): boolean;
24
+ setKeymap(map: Record<string, string | string[] | null>): void;
25
+ list(ctx: Ctx, options?: { win?: Window }): KitCommandInfo[];
26
+ enabled(id: string, ctx: Ctx): boolean;
27
+ run(id: string, ctx: Ctx, event?: KeyboardEvent): boolean;
28
+ match(event: KeyboardEvent, ctx: Ctx, options?: { scope?: string }): KitCommand<Ctx> | null;
29
+ handle(event: KeyboardEvent, ctx: Ctx, options?: { scope?: string }): boolean;
30
+ menu(ids: string[], ctx: Ctx, options?: { win?: Window }): { id: string; label: string; hint?: string; disabled: boolean; title?: string; onSelect(): void }[];
31
+ }
32
+ export declare function createCommandRegistry<Ctx = unknown>(options?: { commands?: KitCommand<Ctx>[] }): KitCommandRegistry<Ctx>;
33
+ export declare function parseKey(spec: string): KitKey;
34
+ export declare function matchKey(event: Pick<KeyboardEvent, "key" | "code" | "shiftKey" | "altKey" | "ctrlKey" | "metaKey">, spec: string | KitKey): boolean;
35
+ export declare function formatKey(spec: string | KitKey, win?: Window): string;
36
+ export declare function isHandled(event: Event): boolean;
37
+ export declare function markHandled(event: Event): void;
38
+ export declare function keyGuard(event: KeyboardEvent): boolean;