@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.
- package/README.md +60 -6
- package/dist/canvas-variables.d.ts +15 -0
- package/dist/canvas-variables.js +52 -0
- package/dist/canvas.js +33 -5
- package/dist/design-controls.js +3 -2
- package/dist/design-options.js +1 -1
- package/dist/index.d.ts +37 -1
- package/dist/index.js +50 -3
- package/dist/json-editor.d.ts +33 -0
- package/dist/json-editor.js +83 -2
- package/dist/layout-adapter.js +96 -0
- package/dist/layout-canvas.js +122 -232
- package/dist/layout-editor-ui.js +46 -185
- package/dist/layout-inspector.js +36 -99
- package/dist/layout-layers.js +75 -92
- package/dist/layout-model.js +62 -103
- package/dist/react.d.ts +38 -0
- package/dist/react.js +127 -0
- package/dist/slide-retention.js +13 -0
- package/dist/source-color-scheme.js +60 -0
- package/dist/source-pane.d.ts +85 -0
- package/dist/source-pane.js +151 -9
- package/dist/studio-kit/commands.d.ts +38 -0
- package/dist/studio-kit/commands.js +148 -0
- package/dist/studio-kit/dom.d.ts +36 -0
- package/dist/studio-kit/dom.js +162 -0
- package/dist/studio-kit/drag.d.ts +26 -0
- package/dist/studio-kit/drag.js +127 -0
- package/dist/studio-kit/gesture.d.ts +29 -0
- package/dist/studio-kit/gesture.js +87 -0
- package/dist/studio-kit/index.d.ts +29 -0
- package/dist/studio-kit/index.js +12 -0
- package/dist/studio-kit/inspector.d.ts +22 -0
- package/dist/studio-kit/inspector.js +119 -0
- package/dist/studio-kit/overlay.d.ts +29 -0
- package/dist/studio-kit/overlay.js +182 -0
- package/dist/studio-kit/selection.d.ts +30 -0
- package/dist/studio-kit/selection.js +106 -0
- package/dist/studio-kit/tree.d.ts +9 -0
- package/dist/studio-kit/tree.js +82 -0
- package/dist/studio-kit/viewport.d.ts +29 -0
- package/dist/studio-kit/viewport.js +153 -0
- package/dist/styles.css +325 -7
- package/dist/workspace-layouts.d.ts +31 -0
- package/dist/workspace-layouts.js +84 -0
- package/dist/workspace-measure.js +35 -0
- package/dist/workspace-shell.js +96 -0
- package/dist/workspace-source.js +83 -0
- package/dist/workspace-theme.js +142 -0
- package/dist/workspace.d.ts +413 -0
- package/dist/workspace.js +1274 -0
- 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
|
+
}
|
package/dist/source-pane.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/source-pane.js
CHANGED
|
@@ -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;
|