@openpresentation/opf-editor 0.27.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -9
- package/dist/canvas.d.ts +13 -1
- package/dist/canvas.js +39 -9
- package/dist/chart-options-panel.js +16 -16
- package/dist/codemirror-guard.js +69 -0
- package/dist/content-controls.js +127 -74
- package/dist/data-grid.js +15 -7
- package/dist/design-controls.d.ts +13 -0
- package/dist/design-controls.js +136 -125
- package/dist/design-options.js +1 -1
- package/dist/exact-source.d.ts +30 -0
- package/dist/font-gate.js +20 -1
- package/dist/json-editor.d.ts +33 -0
- package/dist/json-editor.js +91 -4
- package/dist/json-options.d.ts +2 -1
- package/dist/json-options.js +7 -6
- 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/layout-templates.d.ts +8 -3
- package/dist/layout-templates.js +30 -16
- package/dist/numbering-panel.js +2 -3
- package/dist/react.d.ts +16 -4
- package/dist/react.js +47 -6
- package/dist/slide-adapter.d.ts +49 -0
- package/dist/slide-adapter.js +289 -0
- package/dist/slide-edits.d.ts +46 -0
- package/dist/slide-edits.js +629 -0
- package/dist/slide-manager.js +2 -1
- package/dist/slide-scene.d.ts +96 -0
- package/dist/slide-scene.js +413 -0
- package/dist/source-checker.js +45 -6
- package/dist/source-color-scheme.js +60 -0
- package/dist/source-completions.js +6 -4
- package/dist/source-pane.d.ts +103 -6
- package/dist/source-pane.js +196 -9
- package/dist/source-worker.js +8 -4
- package/dist/studio-canvas.d.ts +34 -0
- package/dist/studio-canvas.js +586 -0
- package/dist/studio-commands.d.ts +15 -0
- package/dist/studio-commands.js +155 -0
- package/dist/studio-design.d.ts +16 -0
- package/dist/studio-design.js +354 -0
- package/dist/studio-kit/commands.d.ts +38 -0
- package/dist/studio-kit/commands.js +148 -0
- package/dist/studio-kit/dom.d.ts +52 -0
- package/dist/studio-kit/dom.js +198 -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/studio-layers.d.ts +12 -0
- package/dist/studio-layers.js +218 -0
- package/dist/studio.d.ts +31 -0
- package/dist/studio.js +1608 -0
- package/dist/styles.css +1573 -123
- package/dist/thumbnail-cache.d.ts +2 -0
- package/dist/thumbnail-cache.js +10 -1
- package/dist/workspace-measure.js +6 -4
- package/dist/workspace-parts.js +20 -0
- package/dist/workspace-shell.js +25 -15
- package/dist/workspace-theme.js +3 -0
- package/dist/workspace.d.ts +114 -10
- package/dist/workspace.js +223 -49
- package/package.json +30 -19
|
@@ -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
|
+
}
|
|
@@ -6,7 +6,7 @@ import { isolateHistory } from "@codemirror/commands";
|
|
|
6
6
|
import { EditorSelection } from "@codemirror/state";
|
|
7
7
|
import { describeField } from "@openpresentation/opf/schema";
|
|
8
8
|
import { listCatalogRecords, mergeCatalogs } from "./catalogs.js";
|
|
9
|
-
import {
|
|
9
|
+
import { CUSTOM_LAYOUTS_GROUP, layoutGroupName, layoutGroupOrder, layoutRegionSummary } from "./layout-templates.js";
|
|
10
10
|
import { Document, parse as parseYamlDocument, parseDocument } from "yaml";
|
|
11
11
|
import { pathAt, scanMarkdown } from "@openpresentation/opf/source";
|
|
12
12
|
import { findNodeAtLocation, getNodeValue } from "jsonc-parser";
|
|
@@ -188,12 +188,14 @@ function literalOption(label, literal, extra) {
|
|
|
188
188
|
function catalogOptions(kind, context, catalogs) {
|
|
189
189
|
let records = [];
|
|
190
190
|
try { records = listCatalogRecords(context.document, kind, { catalogs }); } catch { records = []; }
|
|
191
|
+
// Deck records first, then the catalog's groups in its own order (and its records in its order within a group), then custom layouts.
|
|
192
|
+
const position = new Map(records.map((record, index) => [record, index]));
|
|
193
|
+
const groups = kind === "layouts" ? layoutGroupOrder(records) : [];
|
|
191
194
|
const order = (record) => {
|
|
192
195
|
if (record.origin === "document") return -1;
|
|
193
196
|
if (kind !== "layouts") return 0;
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
return group < 0 ? LAYOUT_INTENT_GROUPS.length * 100 : group * 100 + LAYOUT_INTENT_GROUPS[group].ids.indexOf(id);
|
|
197
|
+
const group = layoutGroupName(record);
|
|
198
|
+
return (group === CUSTOM_LAYOUTS_GROUP ? groups.length : groups.indexOf(group)) * 100000 + position.get(record);
|
|
197
199
|
};
|
|
198
200
|
records.sort((a, b) => order(a) - order(b) || a.label.localeCompare(b.label, "en", { numeric: true }) || a.id.localeCompare(b.id, "en"));
|
|
199
201
|
const intent = kind === "layouts";
|
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. */
|
|
@@ -112,13 +179,17 @@ export interface SourcePaneOptions {
|
|
|
112
179
|
smartListEnter?: boolean;
|
|
113
180
|
/**
|
|
114
181
|
* Run the pane's text checks (reading YAML or Markdown, the syntax pass of JSON and the document validation) in a Web Worker, so typing in a deck
|
|
115
|
-
* of a thousand slides never blocks the main thread.
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
182
|
+
* of a thousand slides never blocks the main thread. `true` starts the package's own worker with `new Worker(new URL("./source-worker.js",
|
|
183
|
+
* import.meta.url), { type: "module" })`, which webpack, Turbopack and Vite (build) bundle; a function returns the Worker
|
|
184
|
+
* (`() => new Worker(new URL("@openpresentation/opf-editor/source-worker", import.meta.url), { type: "module" })`); a Worker is used as given; a URL
|
|
185
|
+
* (or a string) is the address of a worker script the host built itself (esbuild: build `@openpresentation/opf-editor/source-worker` as an entry
|
|
186
|
+
* point) and is started as a module worker. The worker file imports core, so it only runs bundled. See docs/embedding.md, "The source worker",
|
|
187
|
+
* for each bundler. Only the typing delay uses the worker: every explicit action (`getText`, switching the format, `replaceDocument`, Format, Undo)
|
|
188
|
+
* is applied on the main thread at once. When the worker cannot start, fails, stops answering, or loads but does not say it is ready within 5 s (a
|
|
189
|
+
* script the bundler emptied), the pane checks on the main thread from then on and `onError` is called once with a message that names the
|
|
190
|
+
* worker; no edit is lost. Default `false` (everything on the main thread).
|
|
120
191
|
*/
|
|
121
|
-
worker?: boolean | Worker | (() => Worker);
|
|
192
|
+
worker?: boolean | string | URL | Worker | (() => Worker);
|
|
122
193
|
/** A preview for the card F12 and Alt+F12 open on a catalog record only the app provides (see `JsonCodeEditorOptions.resolvePreview`). */
|
|
123
194
|
resolvePreview?: (kind: string, id: string) => HTMLElement | string | undefined | Promise<HTMLElement | string | undefined>;
|
|
124
195
|
onError?(error: unknown): void;
|
|
@@ -151,6 +222,14 @@ export interface SourcePane {
|
|
|
151
222
|
* (`invalid-opf-edit` with `issues`) and changes nothing. Works in a read-only pane.
|
|
152
223
|
*/
|
|
153
224
|
replaceDocument(document: unknown, options?: ReplaceDocumentOptions): EditorChange;
|
|
225
|
+
/**
|
|
226
|
+
* Load raw source text as the pane's whole text, as if pasted over everything: a file as it was read, a draft that does not parse. Nothing is
|
|
227
|
+
* converted; the bytes stay as given (line endings included; a leading byte order mark is dropped). `options.format` switches the pane to the
|
|
228
|
+
* text's format first (without writing the document in it), `options.name` renames the file. Text that reads as a document the session
|
|
229
|
+
* accepts is applied at once as one undoable edit; anything else stays as a draft with its problems and the session keeps its last valid
|
|
230
|
+
* document. Returns true when the session now holds the text's document. Throws in a read-only pane.
|
|
231
|
+
*/
|
|
232
|
+
setSourceText(text: string, options?: { format?: SourceFormat; name?: string }): boolean;
|
|
154
233
|
/** Re-read the document from the session. */
|
|
155
234
|
refresh(): void;
|
|
156
235
|
/** Replace the host findings; `null` returns to the session's own validation. */
|
|
@@ -160,6 +239,24 @@ export interface SourcePane {
|
|
|
160
239
|
getFormat(): SourceFormat;
|
|
161
240
|
/** 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
241
|
focusPath(path: string): number;
|
|
242
|
+
/** The path under the caret now (see `SourceCursor`), read at once and also while the pane has no focus. */
|
|
243
|
+
getCursor(): SourceCursor;
|
|
244
|
+
/**
|
|
245
|
+
* Scroll a JSON Pointer (or dotted path) or a range `{ from, to }` (source offsets) into view without moving the caret or the focus, which
|
|
246
|
+
* `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
|
|
247
|
+
* text (`false`: the nearest container was revealed); `null` when the path cannot be placed at all.
|
|
248
|
+
*/
|
|
249
|
+
revealPath(target: string | { from: number; to?: number }, options?: RevealOptions): { from: number; to: number; line: number; exact: boolean } | null;
|
|
250
|
+
/**
|
|
251
|
+
* 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
|
|
252
|
+
* marker). `group` (default `default`) names a set; a call replaces the whole set, so validation issues and the paths an agent touched can be
|
|
253
|
+
* two groups. `[]` or `null` clears the set. A decoration with neither a path nor a range throws a `TypeError`.
|
|
254
|
+
*/
|
|
255
|
+
setDecorations(decorations: readonly SourceDecoration[] | null, options?: { group?: string }): void;
|
|
256
|
+
/** Clear the decorations of `group`, or all of the host's without an argument. */
|
|
257
|
+
clearDecorations(group?: string): void;
|
|
258
|
+
/** The host decorations drawn now, at their current offsets, in position order; of `group`, or all. */
|
|
259
|
+
getDecorations(group?: string): SourceDecorationInfo[];
|
|
163
260
|
/** The text as shown, with any pending edit applied first. */
|
|
164
261
|
getText(): string;
|
|
165
262
|
/**
|
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();
|
|
@@ -699,6 +708,44 @@ export function createSourcePane(container, options = {}) {
|
|
|
699
708
|
options.onFormatChange?.(format, previous);
|
|
700
709
|
}
|
|
701
710
|
|
|
711
|
+
/**
|
|
712
|
+
* Put `text` in the pane as its whole source, in `next` (default the pane's format), as if the person had pasted it over everything: no
|
|
713
|
+
* conversion, the bytes as given (line endings included; a byte order mark is dropped). Text that reads as a document the session accepts is
|
|
714
|
+
* applied at once (one undoable edit; Undo in the code brings the old text back); anything else stays in the pane as a draft with its problems,
|
|
715
|
+
* and the session keeps its last valid document. Returns true when the session now holds the text's document.
|
|
716
|
+
*/
|
|
717
|
+
function setSourceText(text, { format: next, name: nextName } = {}) {
|
|
718
|
+
if (destroyed) throw new Error("The source pane was destroyed.");
|
|
719
|
+
if (typeof text !== "string") throw new TypeError("setSourceText needs the source text as a string.");
|
|
720
|
+
if (next !== undefined && !FORMATS.some((entry) => entry.id === next)) throw new TypeError(`setSourceText: format must be "json", "yaml" or "md", not ${JSON.stringify(next)}.`);
|
|
721
|
+
if (readOnly) throw new Error("The source pane is read-only.");
|
|
722
|
+
flush();
|
|
723
|
+
closeConfirm(false);
|
|
724
|
+
const previous = format;
|
|
725
|
+
for (const key of Object.keys(drafts)) delete drafts[key];
|
|
726
|
+
transientNotes = [];
|
|
727
|
+
rejectionFindings = [];
|
|
728
|
+
syntaxFindings = [];
|
|
729
|
+
if (next !== undefined && next !== format) {
|
|
730
|
+
format = next;
|
|
731
|
+
noteFindings = [];
|
|
732
|
+
control.setLanguage(formatOf(next).language);
|
|
733
|
+
refreshDecorations();
|
|
734
|
+
}
|
|
735
|
+
if (nextName !== undefined) name = sanitizeName(nextName) || "deck";
|
|
736
|
+
updateBar();
|
|
737
|
+
control.api.replace(0, control.api.getValue().length, text.replace(/^\uFEFF/, ""));
|
|
738
|
+
control.api.setSelection(0);
|
|
739
|
+
// Read and apply it now, also when it is the text already shown (in another format, say).
|
|
740
|
+
baseline = null;
|
|
741
|
+
flush();
|
|
742
|
+
renderedKey = "";
|
|
743
|
+
renderFindings();
|
|
744
|
+
syncAttributes();
|
|
745
|
+
if (format !== previous) options.onFormatChange?.(format, previous);
|
|
746
|
+
return !dirty;
|
|
747
|
+
}
|
|
748
|
+
|
|
702
749
|
// ---- Menu ---------------------------------------------------------------------------------------------------------
|
|
703
750
|
const menuOpen = () => !menu.hidden;
|
|
704
751
|
function openMenu(focus = "checked") {
|
|
@@ -786,6 +833,8 @@ export function createSourcePane(container, options = {}) {
|
|
|
786
833
|
nameButton.textContent = name;
|
|
787
834
|
nameButton.setAttribute("aria-label", `Rename ${name}`);
|
|
788
835
|
formatLabel.textContent = `.opf.${current.extension}`;
|
|
836
|
+
// The visible extension is part of the button's name (WCAG 2.5.3, label in name).
|
|
837
|
+
formatButton.setAttribute("aria-label", `Source format .opf.${current.extension}`);
|
|
789
838
|
root.dataset.format = current.id;
|
|
790
839
|
canonicalAction.hidden = current.id === "json";
|
|
791
840
|
for (const item of menuItems) item.setAttribute("aria-checked", String(item.dataset.format === current.id));
|
|
@@ -1137,6 +1186,115 @@ export function createSourcePane(container, options = {}) {
|
|
|
1137
1186
|
return true;
|
|
1138
1187
|
}
|
|
1139
1188
|
|
|
1189
|
+
// ---- Cursor (CE-17) -----------------------------------------------------------------------------------------------
|
|
1190
|
+
// 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
|
|
1191
|
+
// 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
|
|
1192
|
+
// 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.
|
|
1193
|
+
function readCursor() {
|
|
1194
|
+
const text = control.api.getValue();
|
|
1195
|
+
const [from, to] = control.api.getSelection();
|
|
1196
|
+
const head = control.api.getHead();
|
|
1197
|
+
const found = attempt(() => pathAt(text, format, head), undefined);
|
|
1198
|
+
const at = pointer(found?.path ?? []);
|
|
1199
|
+
return { pointer: at, idPath: attempt(() => toIdPointer(deck(), at), at), from, to, head, part: found?.part ?? "none", format };
|
|
1200
|
+
}
|
|
1201
|
+
const settled = () => timer === null && syntaxTimer === null && !checkBusy && !syntaxBusy;
|
|
1202
|
+
function scheduleCursor() {
|
|
1203
|
+
if (destroyed || !options.onCursor) return;
|
|
1204
|
+
clearTimeout(cursorTimer);
|
|
1205
|
+
cursorTimer = setTimeout(emitCursor, cursorDelay);
|
|
1206
|
+
}
|
|
1207
|
+
function emitCursor() {
|
|
1208
|
+
cursorTimer = null;
|
|
1209
|
+
if (destroyed) return;
|
|
1210
|
+
if (!settled()) {
|
|
1211
|
+
if (!cursorWaiting) { cursorWaiting = true; idle().then(() => { cursorWaiting = false; scheduleCursor(); }); }
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
const detail = readCursor();
|
|
1215
|
+
const key = JSON.stringify([detail.format, detail.from, detail.to, detail.head, detail.pointer, detail.idPath, detail.part]);
|
|
1216
|
+
if (key === cursorKey) return;
|
|
1217
|
+
cursorKey = key;
|
|
1218
|
+
try { options.onCursor?.(detail); } catch (error) { options.onError?.(error); }
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// ---- Host decorations and reveal (CE-17) --------------------------------------------------------------------------------
|
|
1222
|
+
// 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
|
|
1223
|
+
// 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
|
|
1224
|
+
// changes), so it stays on its field when slides move.
|
|
1225
|
+
const kindClass = (kind) => `opf-source-decoration opf-source-decoration-${String(kind || "highlight").toLowerCase().replace(/[^a-z0-9_-]+/g, "-")}`;
|
|
1226
|
+
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 } : {}) } });
|
|
1227
|
+
function drawPaths(group) {
|
|
1228
|
+
const entry = hostGroups.get(group);
|
|
1229
|
+
if (!entry) return;
|
|
1230
|
+
const text = control.api.getValue();
|
|
1231
|
+
const items = [];
|
|
1232
|
+
for (const spec of entry.paths) {
|
|
1233
|
+
const found = attempt(() => locateText(format, text, pathSegments(spec.path), spec.part ?? "value"), null);
|
|
1234
|
+
if (found?.exact) items.push(hostItem(group, spec, found.from, found.to));
|
|
1235
|
+
}
|
|
1236
|
+
control.setDecorations(`host:path:${group}`, items);
|
|
1237
|
+
}
|
|
1238
|
+
function refreshDecorations() {
|
|
1239
|
+
clearTimeout(decorationTimer);
|
|
1240
|
+
decorationTimer = null;
|
|
1241
|
+
if (!destroyed) for (const [group, entry] of hostGroups) if (entry.paths.length) drawPaths(group);
|
|
1242
|
+
}
|
|
1243
|
+
function scheduleDecorations() {
|
|
1244
|
+
if (destroyed || ![...hostGroups.values()].some((entry) => entry.paths.length)) return;
|
|
1245
|
+
clearTimeout(decorationTimer);
|
|
1246
|
+
decorationTimer = setTimeout(refreshDecorations, 120);
|
|
1247
|
+
}
|
|
1248
|
+
function clearGroup(group) {
|
|
1249
|
+
hostGroups.delete(group);
|
|
1250
|
+
control.clearDecorations(`host:range:${group}`);
|
|
1251
|
+
control.clearDecorations(`host:path:${group}`);
|
|
1252
|
+
}
|
|
1253
|
+
function setHostDecorations(list, { group = "default" } = {}) {
|
|
1254
|
+
if (destroyed) return;
|
|
1255
|
+
const specs = list === null || list === undefined ? [] : list;
|
|
1256
|
+
if (!Array.isArray(specs)) throw new TypeError("setDecorations needs a list of decorations (or null to clear).");
|
|
1257
|
+
const ranges = [], paths = [];
|
|
1258
|
+
for (const spec of specs) {
|
|
1259
|
+
if (typeof spec?.path === "string") paths.push(spec);
|
|
1260
|
+
else if (Number.isFinite(spec?.range?.from) && Number.isFinite(spec?.range?.to ?? spec.range.from)) ranges.push(spec);
|
|
1261
|
+
else throw new TypeError("A decoration needs a path (a JSON Pointer) or a range ({ from, to } in source offsets).");
|
|
1262
|
+
}
|
|
1263
|
+
if (!specs.length) { clearGroup(group); return; }
|
|
1264
|
+
hostGroups.set(group, { paths });
|
|
1265
|
+
control.setDecorations(`host:range:${group}`, ranges.map((spec) => hostItem(group, spec, spec.range.from, spec.range.to ?? spec.range.from)));
|
|
1266
|
+
drawPaths(group);
|
|
1267
|
+
}
|
|
1268
|
+
function revealTarget(target, { focus = false, select = false, align = "center", flash = false, part = "entry" } = {}) {
|
|
1269
|
+
let range;
|
|
1270
|
+
if (typeof target === "string") {
|
|
1271
|
+
const found = attempt(() => locateText(format, control.api.getValue(), pathSegments(target), part), null);
|
|
1272
|
+
if (!found) return null;
|
|
1273
|
+
range = found;
|
|
1274
|
+
} else if (Number.isFinite(target?.from)) {
|
|
1275
|
+
const size = control.api.getValue().length;
|
|
1276
|
+
const from = Math.min(size, Math.max(0, target.from));
|
|
1277
|
+
range = { from, to: Math.min(size, Math.max(from, Number.isFinite(target.to) ? target.to : from)), exact: true };
|
|
1278
|
+
} else throw new TypeError("revealPath needs a JSON Pointer or a range ({ from, to } in source offsets).");
|
|
1279
|
+
control.reveal(range.from, range.to, { align, select, focus });
|
|
1280
|
+
if (flash) {
|
|
1281
|
+
control.setDecorations("flash", [{ from: range.from, to: range.from, line: true, className: "opf-source-flash" }]);
|
|
1282
|
+
clearTimeout(flashTimer);
|
|
1283
|
+
flashTimer = setTimeout(() => { flashTimer = null; if (!destroyed) control.clearDecorations("flash"); }, 1200);
|
|
1284
|
+
}
|
|
1285
|
+
return { from: range.from, to: range.to, line: lineOfOffset(control.api.getValue(), range.from), exact: range.exact };
|
|
1286
|
+
}
|
|
1287
|
+
function hostDecorationList(group) {
|
|
1288
|
+
return control.getDecorations().filter((item) => item.key.startsWith("host:") && (group === undefined || item.data.group === group))
|
|
1289
|
+
.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 }));
|
|
1290
|
+
}
|
|
1291
|
+
function idle() {
|
|
1292
|
+
return new Promise((resolve) => {
|
|
1293
|
+
const poll = () => { if (destroyed || settled()) resolve(); else setTimeout(poll, 4); };
|
|
1294
|
+
poll();
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1140
1298
|
// ---- Public API ---------------------------------------------------------------------------------------------------
|
|
1141
1299
|
function focusPath(path, finding) {
|
|
1142
1300
|
flush();
|
|
@@ -1173,6 +1331,11 @@ export function createSourcePane(container, options = {}) {
|
|
|
1173
1331
|
renderedKey = "";
|
|
1174
1332
|
renderFindings();
|
|
1175
1333
|
},
|
|
1334
|
+
/**
|
|
1335
|
+
* Load raw source text (a file, a paste, a draft that does not parse) as the pane's whole text, in `options.format`, without converting it.
|
|
1336
|
+
* Returns true when it read as a document the session accepted, false when it stays a draft with its problems. See `setSourceText` above.
|
|
1337
|
+
*/
|
|
1338
|
+
setSourceText,
|
|
1176
1339
|
/** Switch the source format ("json", "yaml" or "md"). Returns false when the document cannot be written in it. */
|
|
1177
1340
|
setFormat: (next) => setFormatInternal(next, false),
|
|
1178
1341
|
getFormat: () => format,
|
|
@@ -1220,15 +1383,36 @@ export function createSourcePane(container, options = {}) {
|
|
|
1220
1383
|
getOutline: () => documentSymbols(format, control.api.getValue()),
|
|
1221
1384
|
/** Show or hide the outline panel (needs the `outline` option); with no argument it toggles. Returns whether the panel is open. */
|
|
1222
1385
|
toggleOutline(open) { if (!outlinePanel) return false; setOutlineOpen(open ?? outlinePanel.hidden); return !outlinePanel.hidden; },
|
|
1386
|
+
/**
|
|
1387
|
+
* 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.
|
|
1388
|
+
* Read now, without waiting for the pending edit or the pause `onCursor` uses.
|
|
1389
|
+
*/
|
|
1390
|
+
getCursor: () => readCursor(),
|
|
1391
|
+
/**
|
|
1392
|
+
* 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.
|
|
1393
|
+
* `focus` and `select` do move them. `align`: "center" (default), "start" or "nearest"; `flash` marks the first line for a moment; `part`: which part of
|
|
1394
|
+
* 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`.
|
|
1395
|
+
*/
|
|
1396
|
+
revealPath: revealTarget,
|
|
1397
|
+
/**
|
|
1398
|
+
* 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
|
|
1399
|
+
* 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.
|
|
1400
|
+
* `[]` or `null` clears the set.
|
|
1401
|
+
*/
|
|
1402
|
+
setDecorations: setHostDecorations,
|
|
1403
|
+
/** CE-17: clear the decorations of `group`, or every host decoration without an argument. */
|
|
1404
|
+
clearDecorations(group) {
|
|
1405
|
+
if (destroyed) return;
|
|
1406
|
+
if (group !== undefined) { clearGroup(group); return; }
|
|
1407
|
+
for (const name of [...hostGroups.keys()]) clearGroup(name);
|
|
1408
|
+
control.clearDecorations();
|
|
1409
|
+
},
|
|
1410
|
+
/** CE-17: the host decorations drawn now at their current offsets: `{ group, kind, label?, path?, from, to, line }`. */
|
|
1411
|
+
getDecorations: (group) => hostDecorationList(group),
|
|
1223
1412
|
/** The code editor, for hosts that focus or measure it. */
|
|
1224
1413
|
editor: control,
|
|
1225
1414
|
/** 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
|
-
},
|
|
1415
|
+
whenIdle: idle,
|
|
1232
1416
|
/** How the applied edits were checked: `worker` and `inline` counts, and whether the worker is still usable (`available`). */
|
|
1233
1417
|
checkerStats: () => ({ worker: stats.worker, inline: stats.inline, available: Boolean(checker?.available) }),
|
|
1234
1418
|
destroy() {
|
|
@@ -1243,6 +1427,9 @@ export function createSourcePane(container, options = {}) {
|
|
|
1243
1427
|
clearTimeout(recheckTimer);
|
|
1244
1428
|
clearTimeout(outlineTimer);
|
|
1245
1429
|
clearTimeout(outlineMarkTimer);
|
|
1430
|
+
clearTimeout(cursorTimer);
|
|
1431
|
+
clearTimeout(decorationTimer);
|
|
1432
|
+
clearTimeout(flashTimer);
|
|
1246
1433
|
unsubscribe();
|
|
1247
1434
|
document.removeEventListener("pointerdown", outside, true);
|
|
1248
1435
|
control.destroy();
|
package/dist/source-worker.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
// The worker entry of the source pane's text checks (`createSourcePane(el, { worker })`).
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// The worker entry of the source pane's text checks (`createSourcePane(el, { worker })`). `worker: true` starts it with
|
|
2
|
+
// `new Worker(new URL("./source-worker.js", import.meta.url), { type: "module" })`, which webpack, Turbopack, Vite (build) and Parcel bundle; a host can
|
|
3
|
+
// also write `new Worker(new URL("@openpresentation/opf-editor/source-worker", import.meta.url), { type: "module" })` itself, or build this file as an
|
|
4
|
+
// extra entry point (esbuild) and pass its URL. The file imports core, so it runs only bundled. See docs/embedding.md, "The source worker". It does the
|
|
5
|
+
// same work as the pane's own inline path (`./source-check.js`), off the main thread. It says `{ ready: true }` once it runs, so the pane can tell a
|
|
6
|
+
// worker that loaded but never started (an emptied script) from a slow check. Requests run in order, but when several checks of one kind are waiting only the newest runs
|
|
5
7
|
// (the older ones are answered with `skipped`): the pane drops stale replies anyway.
|
|
6
8
|
import { createCheckService } from "./source-check.js";
|
|
7
9
|
|
|
@@ -23,6 +25,8 @@ function drain() {
|
|
|
23
25
|
});
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
self.postMessage({ ready: true });
|
|
29
|
+
|
|
26
30
|
self.onmessage = (event) => {
|
|
27
31
|
queue.push(event.data);
|
|
28
32
|
// Messages that arrive while a check runs queue behind it; draining on the next task lets everything already waiting be seen together.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** CE-20 (internal): the Studio's canvas: the kit viewport and overlay over the workspace's canvas, driven by the slide adapter. */
|
|
2
|
+
import type { KitDom } from "./studio-kit/dom.js";
|
|
3
|
+
import type { CanvasEditor } from "./canvas.js";
|
|
4
|
+
import type { SlideAdapter } from "./slide-adapter.js";
|
|
5
|
+
import type { SceneNode } from "./slide-scene.js";
|
|
6
|
+
|
|
7
|
+
export declare function dottedPath(pointer: string): string;
|
|
8
|
+
export declare function isTextNode(node: SceneNode | null | undefined): boolean;
|
|
9
|
+
export interface StudioCanvas {
|
|
10
|
+
paint(): void;
|
|
11
|
+
paintHover(): void;
|
|
12
|
+
readonly zoom: number;
|
|
13
|
+
readonly panMode: boolean;
|
|
14
|
+
setZoom(zoom: number, options?: { anchor?: { clientX: number; clientY: number } }): number;
|
|
15
|
+
step(direction: number): number;
|
|
16
|
+
fit(mode?: "slide" | "width"): number;
|
|
17
|
+
readonly tool: "select" | "hand" | "text";
|
|
18
|
+
setTool(tool: "select" | "hand" | "text"): void;
|
|
19
|
+
focusPrimary(): void;
|
|
20
|
+
boxOf(key: string): Element | undefined;
|
|
21
|
+
describe(node: SceneNode): string;
|
|
22
|
+
beginInsertDrag(kind: string, event: PointerEvent, options?: { onEnd?(): void }): boolean;
|
|
23
|
+
readonly dragging: boolean;
|
|
24
|
+
cancelDrag(): void;
|
|
25
|
+
setEnabled(on: boolean): void;
|
|
26
|
+
destroy(): void;
|
|
27
|
+
}
|
|
28
|
+
export declare function createStudioCanvas(options: {
|
|
29
|
+
dom: KitDom;
|
|
30
|
+
elements: { root: HTMLElement; viewport: HTMLElement; stage: HTMLElement; frame: HTMLElement; overlay: HTMLElement };
|
|
31
|
+
canvas: CanvasEditor;
|
|
32
|
+
adapter: SlideAdapter;
|
|
33
|
+
studio: any;
|
|
34
|
+
}): StudioCanvas;
|