@openpresentation/opf-editor 0.26.0-rc.0 → 0.26.1

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 CHANGED
@@ -76,9 +76,9 @@ control.destroy();
76
76
 
77
77
  The host owns the source draft, OPF validation, slide preview, persistence and session history. `onChange` includes invalid JSON while typing; keep the last valid preview until the draft validates. `update` applies external source without emitting `onChange` or creating a local undo step. It preserves unrelated edits through CodeMirror's history mapping; it is not a collaboration/conflict-resolution protocol. Importing either entrypoint does not require a DOM; mounting requires an existing browser container.
78
78
 
79
- The control provides indentation, paired quotes/brackets, folding shortcuts, search, undo/redo, JSON syntax diagnostics and explicit formatting (`Cmd/Ctrl+Shift+F`). Enter at the end of a nonempty string array item supplies indentation, quotes and commas. Other Enter positions use ordinary JSON indentation. Source offsets are UTF-16 in the original authored string. Existing CRLF, CR and LF spellings survive ordinary edits and undo; new typed lines use the first existing separator. Paste retains the text delivered by the browser clipboard event. Explicit formatting deliberately standardizes whitespace, preserving JSON values and numeric lexemes.
79
+ The control provides indentation, paired quotes/brackets, folding shortcuts, search, undo/redo, JSON syntax diagnostics, multiple cursors and explicit formatting (`Shift+Alt+F` or `Cmd/Ctrl+Shift+F`, also while the JSON does not parse). Its keys follow VS Code (see Keyboard below). Enter at the end of a nonempty string array item supplies indentation, quotes and commas. Other Enter positions use ordinary JSON indentation. Source offsets are UTF-16 in the original authored string. Existing CRLF, CR and LF spellings survive ordinary edits and undo; new typed lines use the first existing separator. Paste retains the text delivered by the browser clipboard event. Explicit formatting deliberately standardizes whitespace, preserving JSON values and numeric lexemes.
80
80
 
81
- Click a categorical property/value or press `Ctrl+Space`, `Cmd+Space` or `Alt+Down` to open its choices. Records the document embeds come first, then those of the catalogs the host passes as `catalogs` (`Catalog[]`, the session's registered list); there are no built-in records. The actual schema supplies enums and booleans. Prose and extension fields do not gain menus just because their keys resemble schema fields. External catalog URLs are not fetched. `setCatalogs(list)` replaces the host's catalogs and closes stale choices. Supply `onOptions(left?, top?)` to retain an existing menu UI.
81
+ Press `Alt+Shift+O` (and, with `completions` off, `Ctrl+Space`) to open the choices of the categorical property or value at the caret; clicking a value only places the caret. Records the document embeds come first, then those of the catalogs the host passes as `catalogs` (`Catalog[]`, the session's registered list); there are no built-in records. The actual schema supplies enums and booleans. Prose and extension fields do not gain menus just because their keys resemble schema fields. External catalog URLs are not fetched. `setCatalogs(list)` replaces the host's catalogs and closes stale choices. Supply `onOptions(left?, top?)` to retain an existing menu UI.
82
82
 
83
83
  In OPF 0.19, layout choices change only the template reference and preserve authored content. Empty regions remain available for explicit insertion; choosing a template does not invent blank payloads. The JSON field menu groups templates by intent and describes their supported regions. Hosts apply `replaceFieldOption` or `fieldOptionEdit(context, value)` as one undoable edit and check that `context.source` is still current.
84
84
 
@@ -116,13 +116,15 @@ Methods: `replaceDocument(document, { source?, format?, name?, meta? })` (below)
116
116
  - **Keyboard Undo and Redo in the pane (one model).** Cmd/Ctrl+Z and Cmd/Ctrl+Shift+Z (Ctrl+Y) work in two layers, in this order. First CodeMirror's history, which holds only the text typed in the pane since its last whole-document change (a `replaceDocument`, or a session Undo, Redo or restore, each of which clears it because the typed edits no longer fit the text). When that has nothing left, the key steps the session over a whole-document replace, exactly as its Undo and Redo do, so the text comes back byte for byte: Undo steps back over the nearest `replaceDocument` together with the pane's own edits typed on it (the typing was already undone, step by step, by CodeMirror's history); Redo steps forward over the replace. The key never reaches over an edit made elsewhere (the canvas, a panel): Undo stops at it, and the host's Undo button (or `editor.undo()`) undoes that edit, after which the key reaches the load again. A read-only pane takes neither key. `mountJsonCodeEditor` exposes the same seam as `historyFallback: { undo(), redo() }` (answer `true` when you did something) plus `resetHistory()` and `undoDepth()` on the control.
117
117
  - **JSON is canonical** and keeps the author's bytes: a YAML or Markdown edit rewrites only the changed JSON tokens (`exact-source`), and Undo restores the exact spelling.
118
118
  - **YAML and Markdown** are core's browser-safe converters, `@openpresentation/opf/yaml` and `/markdown`. Switching format writes the session's document in the new format. An edit parses back to the document and reaches the session as one `replace` patch, after a short delay (`applyDelay`, default 250 ms) so one burst of typing is one undo step. A syntax error, or an OPF rule the session refuses, is listed with its line and never reaches the document; Undo, Redo and outside edits replace a draft that did not parse.
119
- - **Nothing is lost silently.** Markdown writes what its dialect has no syntax for (a `design`, region keys, nested groups) as YAML in `opf-slide` and `opf-block` fences; each such part is a warning finding with its JSON Pointer and reason. Comments in YAML cannot survive a rewrite from the document: Format and outside edits drop them and say how many. A document that is not valid OPF cannot be written as YAML or Markdown; the pane stays in JSON and says so.
120
- - **Completions.** In JSON the pane offers property names and values as you type (`completions: false` keeps the field menu only): the keys the schema allows at the cursor's object, and the enum, boolean and catalog values of the field, from the same data as the field menu (`getJsonCompletions` in `/json-options`, which reads drafts that do not parse yet). Enter accepts, Escape closes, and Ctrl+Space or Alt+ArrowDown still open the field menu. YAML and Markdown keep the menu only. `mountJsonCodeEditor` takes the same `completions` option, off by default there.
119
+ - **Nothing is lost silently.** Markdown writes what its dialect has no syntax for (a `design`, region keys, nested groups) as YAML in `opf-slide` and `opf-block` fences; each such part is a warning finding with its JSON Pointer and reason. Comments in YAML cannot survive a rewrite from the document: outside edits drop them and say how many. Format (below) never rewrites from the document, so it keeps them. A document that is not valid OPF cannot be written as YAML or Markdown; the pane stays in JSON and says so.
120
+ - **Completions.** In JSON the pane offers property names and values as you type (`completions: false` keeps the field menu only): the keys the schema allows at the cursor's object, and the enum, boolean and catalog values of the field, from the same data as the field menu (`getJsonCompletions` in `/json-options`, which reads drafts that do not parse yet). Tab and Enter accept the selected item (at once, right after a keystroke too), Escape closes the list, and Ctrl+Space (Cmd+I on macOS) opens it, also right after a finished string. With several cursors the list stays closed. YAML and Markdown have no completions yet. `mountJsonCodeEditor` takes the same `completions` option, off by default there.
121
+ - **Keyboard.** The keys are VS Code's. `Tab` accepts a suggestion when the list is open; otherwise it inserts spaces up to the next tab stop (replacing a selection inside one line; a selection over several lines, or a whole line, is indented line by line) and `Shift+Tab` outdents the line. The indentation unit is the document's (JSON: read once when a document is opened, two spaces if nothing is indented, or a tab; YAML and Markdown: two spaces) and typing never changes it. `Alt+Up` and `Alt+Down` move the line, `Shift+Alt+Up` and `Shift+Alt+Down` copy it, `Cmd/Ctrl+Shift+K` deletes it. `Cmd/Ctrl+D` adds the next occurrence, `Cmd/Ctrl+Shift+L` all of them, `Cmd/Ctrl+Alt+Up` and `Down` a cursor above or below, `Alt+click` a cursor, `Shift+Alt+drag` a column; `Escape` goes back to one cursor. The field menu (a searchable dialog of the choices of one field) is on `Alt+Shift+O`; `Tab` and `Shift+Tab` stay inside it and `Escape` returns to the editor. A paste inserts the clipboard text as it is, however many lines it has.
122
+ - **Format document.** `Shift+Alt+F` (and `Cmd/Ctrl+Shift+F`) and the Format button do the same in JSON, YAML and Markdown, and change whitespace only, as one undo step with the cursor kept on its token. JSON is laid out token by token with the document's indentation, also while it does not parse; YAML keeps its comments, blank lines, quote styles, number spellings and key order (the pane says so and does nothing when the YAML has errors); Markdown gets at most one blank line between blocks (code fences untouched), its front matter formatted as YAML, and one newline at the end. A format that would change the document is refused. The pane does not rewrite the text from the document (`toYaml`, `toMarkdown`) any more, so it no longer drops comments.
121
123
  - **Accessibility.** The editor's `aria-invalid` is `true` while the findings include an error and `false` otherwise. The format button (`aria-label="Source format"`) opens a `role="menu"` of `menuitemradio` items: Arrow keys, Home, End, Enter, Escape (focus returns to the button) and Tab. The file name is a button that becomes a text input; Enter or Escape commits. Controls show a 2px `--opf-ui-ring` focus outline.
122
124
  - **Styles.** `src/styles/source-pane.css` uses only `--opf-ui-*` tokens under the `opf-source-` prefix. It ships in the editor's `styles.css` (`@openpresentation/opf-editor/styles.css`), which also defines the tokens.
123
- - **Dependency.** `@codemirror/lang-yaml` (MIT) highlights YAML; Markdown uses a small stream tokenizer built on `@codemirror/language`, so no Markdown, HTML, CSS or JavaScript grammar is added.
125
+ - **Dependency.** `yaml` (ISC; core already depends on it) formats YAML while keeping its comments. `@codemirror/lang-yaml` (MIT) highlights YAML; Markdown uses a small stream tokenizer built on `@codemirror/language`, so no Markdown, HTML, CSS or JavaScript grammar is added.
124
126
 
125
- Icons are inline paths from [Lucide](https://lucide.dev) (ISC license). The harness is `examples/source-pane.html` (built by `pnpm run build:playground`); `pnpm run test:source-pane-browser` drives it.
127
+ Icons are inline paths from [Lucide](https://lucide.dev) (ISC license). The harness is `examples/source-pane.html` (built by `pnpm run build:playground`); `pnpm run test:source-pane-browser` drives it, and `pnpm run test:source-keyboard-browser` runs the keyboard cases (real key events; `OPF_BROWSER_ENGINE=webkit` and `OPF_KEYBOARD_PLATFORM=win32|linux|darwin` pick the browser and the key map).
126
128
 
127
129
  ## Layout editor (RR-99)
128
130
 
@@ -7,10 +7,18 @@ export interface JsonEditorApi {
7
7
  hasFocus(): boolean;
8
8
  focus(): void;
9
9
  setSelection(from: number, to?: number): void;
10
- /** Replace one source range atomically; boundaries inside CRLF are rejected. */
11
- replace(from: number, to: number, text: string): void;
10
+ /**
11
+ * Replace one source range atomically; boundaries inside CRLF are rejected. The cursor moves to the end of the inserted text unless
12
+ * `keepSelection` is set. `userEvent` names the edit (default `"input.replace"`; a paste uses `"input.paste"`): it must not be
13
+ * `"input.type"` or `"input.complete"`, which re-indent the inserted lines.
14
+ */
15
+ replace(from: number, to: number, text: string, options?: { keepSelection?: boolean; userEvent?: string }): void;
16
+ /** Replace the whole text with `text` as one undoable edit; only what differs changes, and a whitespace-only difference (a format) is one change per run of whitespace, so the cursor stays on its token. */
17
+ replaceDiff(text: string): void;
18
+ /** The indentation unit now in effect: a tab character or some spaces (JSON: read once when a document is opened; YAML and Markdown: two spaces). */
19
+ indentUnit(): string;
12
20
  coordinates(): { left: number; top: number };
13
- /** Explicit, undoable whitespace formatting; returns false for invalid JSON. */
21
+ /** Explicit, undoable whitespace formatting of JSON, also while it does not parse; returns false in YAML and Markdown (the source pane formats those). */
14
22
  format(): boolean;
15
23
  }
16
24
  export interface JsonCodeEditorOptions {
@@ -23,7 +31,11 @@ export interface JsonCodeEditorOptions {
23
31
  /** Highlighting and behavior: "json" (default) adds the parse linter, field highlights and the option menu. */
24
32
  language?: "json" | "yaml" | "markdown";
25
33
  readOnly?: boolean;
26
- /** Live completion popup in JSON: property names and enum, boolean and catalog values at the cursor's JSON path, from the same data as the field menu (default false here; the source pane turns it on). Ctrl+Space still opens the field menu. */
34
+ /**
35
+ * Live completion list in JSON: property names and enum, boolean and catalog values at the cursor's JSON path, from the same data as the field
36
+ * menu (default false here; the source pane turns it on). Tab and Enter accept the selected item, Escape closes the list, and Ctrl+Space (Cmd+I on
37
+ * macOS) opens it. The field menu is on Alt+Shift+O; with `completions` off it is also on Ctrl+Space.
38
+ */
27
39
  completions?: boolean;
28
40
  catalogs?: JsonCatalogContext;
29
41
  /** Overrides the built-in menu, allowing an existing host UI to keep its appearance. */
@@ -34,6 +46,11 @@ export interface JsonCodeEditorOptions {
34
46
  * whole-document replace.
35
47
  */
36
48
  historyFallback?: { undo?(): boolean; redo?(): boolean };
49
+ /**
50
+ * Format document (Shift+Alt+F and Cmd/Ctrl+Shift+F). The source pane passes one that formats JSON, YAML and Markdown; without it the keys
51
+ * format JSON only.
52
+ */
53
+ onFormat?(): void;
37
54
  onFocus?(): void;
38
55
  onHoverField?(path: string | null): void;
39
56
  onError?(error: unknown): void;
@@ -1,16 +1,17 @@
1
- import { Annotation, Compartment, EditorSelection, EditorState, Prec, StateEffect, StateField, Transaction } from "@codemirror/state";
2
- import { Decoration, EditorView, drawSelection, keymap, lineNumbers } from "@codemirror/view";
3
- import { defaultKeymap, history, historyKeymap, indentWithTab, isolateHistory, redo, undo, undoDepth } from "@codemirror/commands";
1
+ import { Annotation, ChangeSet, Compartment, EditorSelection, EditorState, Prec, StateEffect, StateField, Transaction, countColumn } from "@codemirror/state";
2
+ import { Decoration, EditorView, crosshairCursor, drawSelection, keymap, lineNumbers, rectangularSelection } from "@codemirror/view";
3
+ import { defaultKeymap, history, historyKeymap, indentLess, indentMore, insertNewlineAndIndent, isolateHistory, redo, undo, undoDepth } from "@codemirror/commands";
4
4
  import { HighlightStyle, StreamLanguage, bracketMatching, codeFolding, foldKeymap, indentOnInput, indentUnit, syntaxHighlighting } from "@codemirror/language";
5
5
  import { json, jsonParseLinter } from "@codemirror/lang-json";
6
6
  import { yaml } from "@codemirror/lang-yaml";
7
- import { autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap, startCompletion } from "@codemirror/autocomplete";
7
+ import { acceptCompletion, autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap, completionStatus, moveCompletionSelection, startCompletion } from "@codemirror/autocomplete";
8
8
  import { highlightSelectionMatches, search, searchKeymap } from "@codemirror/search";
9
9
  import { linter } from "@codemirror/lint";
10
10
  import { tags } from "@lezer/highlight";
11
- import { applyEdits, findNodeAtOffset, format, getNodePath, parseTree } from "jsonc-parser";
11
+ import { findNodeAtOffset, getNodePath, parseTree } from "jsonc-parser";
12
12
  import {jsonSource,jsonSourceHistory,setJsonSource} from './json-source.js';
13
- import {textInputMap,preferredLineEnding} from './text-input.js';
13
+ import {textInputMap} from './text-input.js';
14
+ import {detectIndent,formatJsonSource,whitespaceEdits} from './json-format.js';
14
15
  const external = Annotation.define();
15
16
  const hoverField = StateEffect.define();
16
17
  const activeField = StateField.define({ create: () => null, update: (value, tr) => tr.effects.reduce((value2, effect) => effect.is(hoverField) ? effect.value : value2, value) });
@@ -70,24 +71,83 @@ function languageExtension(name, completions) {
70
71
  if (name === "markdown") return markdownLanguage;
71
72
  return [json(), linter(jsonParseLinter(), { delay: 500 }), fieldDecorations, completions ?? []];
72
73
  }
73
- // The completion popup keeps CodeMirror's keys (arrows, Enter, Escape) but not Ctrl-Space, which opens the field menu.
74
- const completionKeys = Prec.highest(keymap.of(completionKeymap.filter((binding) => binding.run !== startCompletion)));
74
+ // Tab or Enter pressed right after a keystroke can arrive before the completion query that keystroke started has answered (with the data
75
+ // loaded it answers within a task or two). Accepting then waits for the answer instead of inserting whitespace or a line over a list that is
76
+ // about to open; when no list comes, `fallback` does what the key does without a list.
77
+ function acceptOrFallBack(isReady, fallback) {
78
+ return (view) => {
79
+ if (acceptCompletion(view)) return true;
80
+ if (completionStatus(view.state) !== "pending" || !isReady()) return false;
81
+ let tries = 0;
82
+ const settle = () => {
83
+ if (!view.dom.isConnected) return;
84
+ if (completionStatus(view.state) === "pending" && ++tries < 50) { setTimeout(settle, 2); return; }
85
+ if (!acceptCompletion(view)) fallback(view);
86
+ };
87
+ setTimeout(settle, 0);
88
+ return true;
89
+ };
90
+ }
91
+ // Completion keys, as in VS Code: Tab and Enter accept the selected suggestion, Escape closes the list, Ctrl+Space (and Cmd+I on macOS) opens it
92
+ // wherever the schema has something to offer, and the arrows, the Page keys and Ctrl+N and Ctrl+P (macOS) move the selection. Without a list,
93
+ // none of them is taken: Tab then inserts whitespace and Enter inserts a line.
94
+ function completionKeys(isReady) {
95
+ return Prec.highest(keymap.of([
96
+ ...completionKeymap.filter((binding) => binding.key !== "Enter"),
97
+ { key: "Enter", run: acceptOrFallBack(isReady, (view) => nextStringItem(view) || insertNewlineAndIndent(view)) },
98
+ { key: "Tab", run: acceptOrFallBack(isReady, tabCommand) },
99
+ { mac: "Mod-i", run: startCompletion },
100
+ { mac: "Ctrl-n", run: moveCompletionSelection(true) },
101
+ { mac: "Ctrl-p", run: moveCompletionSelection(false) },
102
+ ]));
103
+ }
75
104
  // Accept a completion by rewriting its whole token (quotes included), so a string key, a bare word and a changed value type all land
76
- // as valid JSON. A key adds ": " when none follows and then offers the values of that key.
105
+ // as valid JSON. A key adds ": " when none follows and then offers the values of that key. The quotes are read from the text when the
106
+ // completion is applied, because the list is reused while the author keeps typing the same token.
77
107
  function completionApply(found, option) {
78
108
  return (view, completion, from, to) => {
79
- const start = found.quoted ? from - 1 : from;
80
- const end = found.closed ? to + 1 : to;
109
+ const { state } = view;
110
+ const start = found.quoted && state.sliceDoc(from - 1, from) === '"' ? from - 1 : from;
111
+ const end = found.quoted && state.sliceDoc(to, to + 1) === '"' ? to + 1 : to;
81
112
  let insert = JSON.stringify(option.value);
82
- const chain = found.kind === "key" && !/^\s*:/.test(view.state.sliceDoc(end, Math.min(view.state.doc.length, end + 200)));
113
+ const chain = found.kind === "key" && !/^\s*:/.test(state.sliceDoc(end, Math.min(state.doc.length, end + 200)));
83
114
  if (chain) insert += ": ";
84
115
  view.dispatch({ changes: { from: start, to: end, insert }, selection: { anchor: start + insert.length }, userEvent: "input.complete", scrollIntoView: true });
85
116
  if (chain) setTimeout(() => startCompletion(view), 0);
86
117
  };
87
118
  }
88
- function indentation(source) {
89
- const spaces = source.match(/^[\t ]+(?=\S)/gm) ?? [];
90
- return spaces.some((value) => value.includes(" ")) ? " " : " ".repeat(spaces.length ? Math.min(8, ...spaces.map((value) => value.length)) : 2);
119
+ // A quoted token keeps its list for any text between the quotes; a bare word for word characters only.
120
+ const QUOTED_TEXT = /^[^"\\\n\r]*$/;
121
+ const WORD_TEXT = /^[A-Za-z0-9_.-]*$/;
122
+ const DEFAULT_INDENT = " ";
123
+ const tabWidth = (unit) => (unit === "\t" ? 4 : unit.length);
124
+ // Tab without a completion list (VS Code's `tab`): whitespace goes in at the caret, up to the next tab stop, and replaces a selection inside
125
+ // one line; a selection over several lines, or one line in full, is indented line by line. Every selection range does the same.
126
+ function tabCommand(view) {
127
+ const { state } = view;
128
+ if (state.readOnly) return false;
129
+ const unit = state.facet(indentUnit), size = state.tabSize;
130
+ const lineWise = state.selection.ranges.some((range) => {
131
+ if (range.empty) return false;
132
+ const first = state.doc.lineAt(range.from), last = state.doc.lineAt(range.to);
133
+ return first.number !== last.number || (range.from === first.from && range.to === first.to && first.length > 0);
134
+ });
135
+ if (lineWise) {
136
+ // Indent the lines; a selection that began at the start of its line still does, so it keeps covering whole lines.
137
+ const wholeStart = state.selection.ranges.map((range) => !range.empty && range.from === state.doc.lineAt(range.from).from);
138
+ if (!indentMore(view)) return false;
139
+ const after = view.state.selection;
140
+ view.dispatch({ selection: EditorSelection.create(after.ranges.map((range, index) => (wholeStart[index] ? EditorSelection.range(view.state.doc.lineAt(range.from).from, range.to) : range)), after.mainIndex) });
141
+ return true;
142
+ }
143
+ const change = state.changeByRange((range) => {
144
+ const line = state.doc.lineAt(range.from);
145
+ const column = countColumn(state.sliceDoc(line.from, range.from), size);
146
+ const insert = unit === "\t" ? "\t" : " ".repeat(unit.length - (column % unit.length));
147
+ return { changes: { from: range.from, to: range.to, insert }, range: EditorSelection.cursor(range.from + insert.length) };
148
+ });
149
+ view.dispatch(state.update(change, { scrollIntoView: true, userEvent: "input" }));
150
+ return true;
91
151
  }
92
152
  function sourceDecorations(state) {
93
153
  const ranges = [];
@@ -139,7 +199,7 @@ function nextStringItem(view) {
139
199
  const line = state.doc.lineAt(node.offset);
140
200
  const existingIndent = /^\s*/.exec(line.text)[0];
141
201
  const parentLine = state.doc.lineAt(node.parent.offset);
142
- const indent = line.number === parentLine.number ? /^\s*/.exec(parentLine.text)[0] + indentation(source) : existingIndent;
202
+ const indent = line.number === parentLine.number ? /^\s*/.exec(parentLine.text)[0] + state.facet(indentUnit) : existingIndent;
143
203
  const inserted = `,
144
204
  ${indent}""${hasNext ? "," : ""}`;
145
205
  view.dispatch({
@@ -171,6 +231,11 @@ function mountJsonCodeEditor(parent, options) {
171
231
  const editableExtension = (readOnly) => [EditorState.readOnly.of(Boolean(readOnly)), EditorView.editable.of(!readOnly)];
172
232
  let languageName = options.language ?? "json";
173
233
  let disposed=false, menu=null, catalogs=options.catalogs??[], generation=0;
234
+ // The indentation unit belongs to the document, not to the text as it is edited: JSON reads it once when a document is opened (here,
235
+ // and when the host replaces the text), YAML and Markdown always use two spaces. Typing never changes it.
236
+ let jsonIndent = detectIndent(options.code) ?? DEFAULT_INDENT;
237
+ const unitOf = (name) => (name === "json" ? jsonIndent : DEFAULT_INDENT);
238
+ const indentExtension = (name) => [indentUnit.of(unitOf(name)), EditorState.tabSize.of(tabWidth(unitOf(name)))];
174
239
  function closeOptions(){generation++;menu?.destroy();menu=null;}
175
240
  async function openOptions(left,top){
176
241
  closeOptions();
@@ -183,43 +248,60 @@ function mountJsonCodeEditor(parent, options) {
183
248
  menu=mountJsonFieldMenu(parent,api,catalogs,left,top,()=>{menu=null;});
184
249
  }catch(error){options.onError?.(error);}
185
250
  }
186
- // Live completions (JSON only, `completions: true`): property names and values at the cursor's JSON path, from the same data as the
187
- // field menu. The data loads with the field menu's chunk on first use.
188
- async function completionSource(context) {
189
- if (disposed || languageName !== "json") return null;
190
- let module;
191
- try { module = await import('./json-options.js'); } catch (error) { options.onError?.(error); return null; }
192
- if (disposed || context.aborted) return null;
193
- const found = module.getJsonCompletions(context.state.doc.toString(), context.pos, catalogs, { explicit: context.explicit });
251
+ // Live completions (JSON only, `completions: true`): property names and values at the cursor's JSON path, from the same data as the field
252
+ // menu. The data loads with the field menu's chunk, at the first focus or the first query; once loaded, a query answers at once (no
253
+ // promise), and the list stays valid while the author types within the same token (`validFor`), so typing only refilters it.
254
+ let optionsModule = null, optionsLoading = null;
255
+ const loadOptions = () => (optionsLoading ??= import('./json-options.js').then((module) => (optionsModule = module), (error) => { optionsLoading = null; throw error; }));
256
+ function completionResult(context) {
257
+ const found = optionsModule.getJsonCompletions(context.state.doc.toString(), context.pos, catalogs, { explicit: context.explicit });
194
258
  if (!found) return null;
195
- return { from: found.from, to: found.to, filter: true, options: found.options.map((option) => ({
259
+ return { from: found.from, to: found.to, filter: true, validFor: found.quoted ? QUOTED_TEXT : WORD_TEXT, options: found.options.map((option) => ({
196
260
  label: option.label, detail: option.detail, info: option.info, boost: option.boost, type: found.kind === "key" ? "property" : "enum", apply: completionApply(found, option),
197
261
  })) };
198
262
  }
199
- // Undo and Redo run CodeMirror's history first. When it has nothing left, the host's fallback may step further (the source pane
200
- // routes a whole-document replace to the session); a fallback answers true when it did something.
263
+ function completionSource(context) {
264
+ // One list for one cursor: with several, Tab and Enter keep their plain meaning at every cursor instead of completing at the main one only.
265
+ if (disposed || languageName !== "json" || context.state.selection.ranges.length > 1) return null;
266
+ if (optionsModule) return completionResult(context);
267
+ return loadOptions().then(() => (disposed || context.aborted ? null : completionResult(context)), (error) => { options.onError?.(error); return null; });
268
+ }
269
+ // Undo and Redo run CodeMirror's history first. When it has nothing left, the host's fallback may step further (the source pane routes a whole-document replace to the session); a fallback answers true when it did something.
201
270
  const undoCommand = (target) => undo(target) || Boolean(options.historyFallback?.undo?.());
202
271
  const redoCommand = (target) => redo(target) || Boolean(options.historyFallback?.redo?.());
203
272
  const historyBindings = historyKeymap.map((binding) => binding.run === undo ? { ...binding, run: undoCommand } : binding.run === redo ? { ...binding, run: redoCommand } : binding);
204
- const completionExtension = options.completions ? [autocompletion({ override: [completionSource], defaultKeymap: false, icons: false, maxRenderedOptions: 60 }), completionKeys] : null;
205
- let currentIndent = indentation(options.code);
273
+ // Accept with no minimum delay: the list is already valid for the typed text, so Tab or Enter right after a keystroke accepts (VS Code does).
274
+ const completionExtension = options.completions ? [autocompletion({ override: [completionSource], defaultKeymap: false, icons: false, maxRenderedOptions: 60, interactionDelay: 0, activateOnTypingDelay: 0 }), completionKeys(() => optionsModule !== null)] : null;
206
275
  const contentAttributes = (invalid = options.invalid, describedBy = options.describedBy) => EditorView.contentAttributes.of({
207
276
  "aria-label": options.label,
208
277
  "aria-invalid": String(Boolean(invalid)),
209
278
  "aria-describedby": describedBy ?? "",
210
- "aria-keyshortcuts": "Control+Space Meta+Space Alt+ArrowDown",
279
+ "aria-keyshortcuts": options.completions ? "Control+Space" : "Control+Space Alt+Shift+O",
211
280
  spellcheck: "false",
212
281
  autocapitalize: "off",
213
282
  autocorrect: "off"
214
283
  });
284
+ // Format the document: the host's command (the source pane formats every language) or, standalone, the JSON formatter.
285
+ const runFormat = () => { if (options.onFormat) options.onFormat(); else api.format(); return true; };
286
+ // These chords are read from the event, not from CodeMirror's keymap: Alt changes the character a key produces on macOS, so the physical key counts.
287
+ const letter = (event, name) => event.code === `Key${name.toUpperCase()}` || event.key.toLowerCase() === name;
288
+ // Cmd/Ctrl+Shift+F (kept from earlier versions) and VS Code's Shift+Alt+F.
289
+ const isFormatKey = (event) => event.shiftKey && !((event.metaKey || event.ctrlKey) && event.altKey) && (event.metaKey || event.ctrlKey || event.altKey) && letter(event, "f");
290
+ // The field menu: Alt+Shift+O, a chord VS Code does not use.
291
+ const isOptionsKey = (event) => event.altKey && event.shiftKey && !event.metaKey && !event.ctrlKey && letter(event, "o");
215
292
  const view = new EditorView({ parent, state: EditorState.create({ doc: options.code, extensions: [
216
293
  language.of(languageExtension(languageName, completionExtension)),
217
294
  editable.of(editableExtension(options.readOnly)),
218
295
  jsonSource.init(()=>options.code),jsonSourceHistory,
219
- indent.of(indentUnit.of(currentIndent)),
296
+ indent.of(indentExtension(languageName)),
220
297
  indentOnInput(),
221
298
  historyField.of(history()),
222
299
  drawSelection(),
300
+ // Multiple cursors, as in VS Code: Cmd/Ctrl+D, Cmd/Ctrl+Shift+L, Cmd/Ctrl+Alt+Up and Down, Alt+click (and Cmd/Ctrl+click), and Shift+Alt+drag for a column.
301
+ EditorState.allowMultipleSelections.of(true),
302
+ EditorView.clickAddsSelectionRange.of((event) => event.altKey || (/Mac|iPhone|iPad/.test(navigator.platform) ? event.metaKey : event.ctrlKey)),
303
+ rectangularSelection({ eventFilter: (event) => event.altKey && event.shiftKey && event.button === 0 }),
304
+ crosshairCursor(),
223
305
  bracketMatching(),
224
306
  closeBrackets(),
225
307
  codeFolding(),
@@ -241,9 +323,14 @@ function mountJsonCodeEditor(parent, options) {
241
323
  redoCommand(view);
242
324
  return true;
243
325
  }
244
- if ((event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey && event.key.toLowerCase() === "f") {
326
+ if (isFormatKey(event)) {
327
+ event.preventDefault();
328
+ runFormat();
329
+ return true;
330
+ }
331
+ if (languageName === "json" && isOptionsKey(event)) {
245
332
  event.preventDefault();
246
- api.format();
333
+ void openOptions();
247
334
  return true;
248
335
  }
249
336
  return false;
@@ -252,19 +339,10 @@ function mountJsonCodeEditor(parent, options) {
252
339
  { key: "Enter", run: nextStringItem },
253
340
  { key: '"', run: (view2) => skipDelimiter(view2, '"') },
254
341
  { key: ",", run: (view2) => skipDelimiter(view2, ",") },
255
- { key: "Mod-Space", run: () => {
256
- void openOptions();
257
- return true;
258
- } },
259
- { key: "Ctrl-Space", run: () => {
260
- void openOptions();
261
- return true;
262
- } },
263
- { key: "Alt-ArrowDown", run: () => {
264
- void openOptions();
265
- return true;
266
- } },
267
- indentWithTab,
342
+ // The field menu (a searchable dialog of the choices of one field) is on Alt+Shift+O, a chord VS Code does not use. With completions off
343
+ // it is also on Ctrl+Space, because the list is not there to take that key.
344
+ ...(options.completions ? [] : [{ key: "Ctrl-Space", run: () => languageName === "json" && (void openOptions(), true) }, { key: "Mod-Space", run: () => languageName === "json" && (void openOptions(), true) }]),
345
+ { key: "Tab", run: tabCommand, shift: indentLess },
268
346
  ...closeBracketsKeymap,
269
347
  ...defaultKeymap,
270
348
  ...historyBindings,
@@ -272,16 +350,14 @@ function mountJsonCodeEditor(parent, options) {
272
350
  ...foldKeymap
273
351
  ]),
274
352
  EditorView.domEventHandlers({
275
- click(event) {
276
- if (view.state.selection.main.empty) void openOptions(event.clientX, event.clientY + 12);
277
- },
278
353
  paste(event){
279
354
  if(!event.clipboardData||view.state.selection.ranges.length!==1)return false;
280
355
  const text=event.clipboardData.getData('text/plain');
281
356
  if(!text)return false;
282
- event.preventDefault();api.replace(...api.getSelection(),text);return true;
357
+ event.preventDefault();api.replace(...api.getSelection(),text,{userEvent:'input.paste'});return true;
283
358
  },
284
359
  focus() {
360
+ if (options.completions) void loadOptions().catch(() => {});
285
361
  options.onFocus?.();
286
362
  },
287
363
  mousemove(event) {
@@ -293,6 +369,9 @@ function mountJsonCodeEditor(parent, options) {
293
369
  }),
294
370
  EditorView.updateListener.of((update) => {
295
371
  if(update.docChanged||update.selectionSet)closeOptions();
372
+ // Tests and hosts wait on this: a completion query is still running.
373
+ const busy = completionStatus(update.state) === "pending", dom = update.view.dom;
374
+ if (busy !== (dom.dataset.opfBusy === "1")) { if (busy) dom.dataset.opfBusy = "1"; else delete dom.dataset.opfBusy; }
296
375
  if (update.docChanged && !update.transactions.every((tr) => tr.annotation(external))) options.onChange(update.state.field(jsonSource));
297
376
  })
298
377
  ] }) });
@@ -302,28 +381,53 @@ function mountJsonCodeEditor(parent, options) {
302
381
  hasFocus: () => view.hasFocus,
303
382
  focus: () => view.focus(),
304
383
  setSelection: (from, to=from) => {const map=textInputMap(api.getValue());view.dispatch({ selection: EditorSelection.single(map.toInput(from),map.toInput(to)), scrollIntoView: true });},
305
- replace: (from, to, text) => {
384
+ // `keepSelection` leaves the cursor where the edit maps it (a format keeps it on the same token) instead of moving it to the end of the new text.
385
+ // `userEvent` names the edit; the default is not "input.complete" or "input.type", which would run indentOnInput over the inserted lines and make
386
+ // the text differ from the source the edit declares (a pasted block ending in "}" was dropped that way).
387
+ replace: (from, to, text, { keepSelection = false, userEvent = "input.replace" } = {}) => {
306
388
  const source=api.getValue(),map=textInputMap(source),a=map.toInput(from),b=map.toInput(to);
307
389
  if(to<from||map.toSource(a)!==from||map.toSource(b)!==to)throw new RangeError('JSON edits must use source boundaries outside a CRLF pair.');
308
390
  const insert=textInputMap(text).text,next=source.slice(0,from)+text+source.slice(to);
309
- view.dispatch({changes:{from:a,to:b,insert},selection:{anchor:a+insert.length},effects:setJsonSource.of(next),
310
- annotations:[Transaction.userEvent.of('input.complete'),isolateHistory.of('full')],scrollIntoView:true});
391
+ view.dispatch({changes:{from:a,to:b,insert},...(keepSelection?{}:{selection:{anchor:a+insert.length}}),effects:setJsonSource.of(next),
392
+ annotations:[Transaction.userEvent.of(userEvent),isolateHistory.of('full')],scrollIntoView:true});
311
393
  },
394
+ /** The indentation unit now in effect: a tab character or some spaces. */
395
+ indentUnit: () => view.state.facet(indentUnit),
312
396
  coordinates: () => {
313
397
  const rect = view.coordsAtPos(view.state.selection.main.head) ?? parent.getBoundingClientRect();
314
398
  return { left: rect.left, top: rect.bottom + 8 };
315
399
  },
316
400
  format: () => {
401
+ if (languageName !== "json") return false;
402
+ // jsonc-parser formats tokens, so it also lays out a draft that does not parse yet.
403
+ api.replaceDiff(formatJsonSource(api.getValue(), view.state.facet(indentUnit)));
404
+ return true;
405
+ },
406
+ /**
407
+ * Replace the whole text with `next` as one undoable edit. When `next` differs only in whitespace (a format), each run of whitespace that
408
+ * changed is its own change, so the cursor stays on its token; otherwise only the part that differs is replaced.
409
+ */
410
+ replaceDiff: (next) => {
317
411
  const source = api.getValue();
318
- try {
319
- JSON.parse(source);
320
- } catch {
321
- return false;
412
+ if (source === next) return;
413
+ const edits = whitespaceEdits(source, next);
414
+ if (edits) {
415
+ const map = textInputMap(source);
416
+ try {
417
+ const changes = ChangeSet.of(edits.map((edit) => ({ from: map.toInput(edit.from), to: map.toInput(edit.to), insert: textInputMap(edit.insert).text })), view.state.doc.length);
418
+ view.dispatch({ changes, selection: view.state.selection.map(changes, 1), effects: setJsonSource.of(next), annotations: [Transaction.userEvent.of("input.format"), isolateHistory.of("full")], scrollIntoView: true });
419
+ return;
420
+ } catch (error) {
421
+ // An edit that splits a CRLF pair: replace the differing middle instead.
422
+ if (!(error instanceof RangeError)) throw error;
423
+ }
322
424
  }
323
- const unit = indentation(source);
324
- const next = applyEdits(source, format(source, void 0, { insertSpaces: unit !== " ", tabSize: unit.length, eol: preferredLineEnding(source) }));
325
- if (source !== next) api.replace(0, source.length, next);
326
- return true;
425
+ let from = 0, end = source.length, nextEnd = next.length;
426
+ while (from < end && from < nextEnd && source[from] === next[from]) from++;
427
+ while (end > from && nextEnd > from && source[end - 1] === next[nextEnd - 1]) { end--; nextEnd--; }
428
+ if (source[from - 1] === "\r" && source[from] === "\n") from--;
429
+ if (source[end - 1] === "\r" && source[end] === "\n") { end++; nextEnd++; }
430
+ api.replace(from, end, next.slice(from, nextEnd), { keepSelection: true, userEvent: "input.format" });
327
431
  }
328
432
  };
329
433
  return {
@@ -334,7 +438,7 @@ function mountJsonCodeEditor(parent, options) {
334
438
  setLanguage(name){
335
439
  if(name===languageName)return;
336
440
  languageName=name;closeOptions();
337
- view.dispatch({ effects: language.reconfigure(languageExtension(name, completionExtension)) });
441
+ view.dispatch({ effects: [language.reconfigure(languageExtension(name, completionExtension)), indent.reconfigure(indentExtension(name))] });
338
442
  },
339
443
  /** Forget the typed edits CodeMirror can undo (the text they were typed against is gone). Returns nothing; the document is unchanged. */
340
444
  resetHistory(){
@@ -350,10 +454,14 @@ function mountJsonCodeEditor(parent, options) {
350
454
  if(code!==api.getValue())closeOptions();
351
455
  const current = view.state.doc.toString(), normalized=textInputMap(code).text;
352
456
  const effects = [attributes.reconfigure(contentAttributes(invalid, describedBy)), hoverField.of(field),setJsonSource.of(code)];
353
- const nextIndent = indentation(code);
354
- if (nextIndent !== currentIndent) {
355
- currentIndent = nextIndent;
356
- effects.push(indent.reconfigure(indentUnit.of(nextIndent)));
457
+ // A different text is a different document (or a rewrite of it by the host): read its indentation again. The same text, which the host
458
+ // passes after every applied edit to refresh the attributes, keeps the unit.
459
+ if (current !== normalized) {
460
+ const detected = detectIndent(code);
461
+ if (detected && detected !== jsonIndent) {
462
+ jsonIndent = detected;
463
+ if (languageName === "json") effects.push(indent.reconfigure(indentExtension("json")));
464
+ }
357
465
  }
358
466
  if (current === normalized) {
359
467
  view.dispatch({ effects });
@@ -0,0 +1,54 @@
1
+ import { applyEdits, format } from "jsonc-parser";
2
+ import { preferredLineEnding } from "./text-input.js";
3
+
4
+ /**
5
+ * The indentation unit of a JSON text: a tab when an indented line starts with one, else the smallest indentation found (at most 8
6
+ * spaces); null when no line is indented.
7
+ */
8
+ export function detectIndent(source) {
9
+ const spaces = source.match(/^[\t ]+(?=\S)/gm) ?? [];
10
+ if (!spaces.length) return null;
11
+ return spaces.some((value) => value.includes("\t")) ? "\t" : " ".repeat(Math.min(8, ...spaces.map((value) => value.length)));
12
+ }
13
+
14
+ /**
15
+ * The whitespace edits that lay JSON text out with `unit` (a tab or spaces) as the indentation (`{ offset, length, content }`, offsets in
16
+ * `source`): whitespace only, so values and numeric lexemes stay as written, the first line ending is kept and a final newline stays if there
17
+ * is one. jsonc-parser formats tokens, so a draft that does not parse is laid out too.
18
+ */
19
+ export function jsonFormatEdits(source, unit = " ") {
20
+ return format(source, undefined, {
21
+ insertSpaces: unit !== "\t",
22
+ tabSize: unit === "\t" ? 4 : unit.length,
23
+ eol: preferredLineEnding(source),
24
+ insertFinalNewline: /[\r\n]\s*$/.test(source),
25
+ });
26
+ }
27
+
28
+ /** `source` laid out by `jsonFormatEdits`. */
29
+ export const formatJsonSource = (source, unit = " ") => applyEdits(source, jsonFormatEdits(source, unit));
30
+
31
+ const isSpace = (char) => char === " " || char === "\t" || char === "\n" || char === "\r";
32
+ /**
33
+ * The edits (`{ from, to, insert }`, offsets in `source`) that turn `source` into `formatted` when the two differ in whitespace only, one per run
34
+ * of whitespace that changed, so a cursor between two tokens stays between them. Null when something other than whitespace differs.
35
+ */
36
+ export function whitespaceEdits(source, formatted) {
37
+ const edits = [];
38
+ let i = 0, j = 0;
39
+ while (i < source.length || j < formatted.length) {
40
+ if (isSpace(source[i]) || isSpace(formatted[j])) {
41
+ let endSource = i, endFormatted = j;
42
+ while (isSpace(source[endSource])) endSource++;
43
+ while (isSpace(formatted[endFormatted])) endFormatted++;
44
+ const before = source.slice(i, endSource), after = formatted.slice(j, endFormatted);
45
+ if (before !== after) edits.push({ from: i, to: endSource, insert: after });
46
+ i = endSource;
47
+ j = endFormatted;
48
+ } else if (source[i] === formatted[j] && i < source.length) {
49
+ i++;
50
+ j++;
51
+ } else return null;
52
+ }
53
+ return edits;
54
+ }
package/dist/json-menu.js CHANGED
@@ -7,7 +7,7 @@ export function mountJsonFieldMenu(parent,api,catalogs,left,top,onClose){
7
7
  const context=getJsonFieldContext(source,selection[0],catalogs);
8
8
  if(!context)return null;
9
9
  const coordinates=api.coordinates();left??=coordinates.left;top??=coordinates.top;
10
- const root=doc.createElement('div');root.className='json-field-menu';root.setAttribute('role','dialog');root.setAttribute('aria-label',`${context.label} options`);
10
+ const root=doc.createElement('div');root.className='json-field-menu';root.setAttribute('role','dialog');root.setAttribute('aria-modal','true');root.setAttribute('aria-label',`${context.label} options`);
11
11
  root.style.cssText='position:fixed;z-index:10000;width:380px;max-width:calc(100vw - 16px);max-height:calc(100vh - 16px);overflow:auto;box-sizing:border-box;padding:12px;background:Canvas;color:CanvasText;border:1px solid GrayText;border-radius:8px;font:13px system-ui;box-shadow:0 8px 28px #0003';
12
12
  const add=(tag,text,className,container=root)=>{const node=doc.createElement(tag);if(text!==undefined)node.textContent=text;if(className)node.className=className;container.append(node);return node;};
13
13
  const heading=add('div',undefined,'json-field-heading');add('strong',context.label,undefined,heading);
@@ -59,6 +59,13 @@ export function mountJsonFieldMenu(parent,api,catalogs,left,top,onClose){
59
59
  search.addEventListener('input',()=>{selected=0;render();});
60
60
  root.addEventListener('keydown',event=>{
61
61
  if(event.key==='Escape'){event.preventDefault();event.stopPropagation();close(true);}
62
+ // Tab walks the dialog's two controls and wraps; the options are reached with the arrow keys from the search field. Focus never leaves
63
+ // the dialog by Tab, so it cannot be left open behind the page.
64
+ else if(event.key==='Tab'){
65
+ event.preventDefault();event.stopPropagation();
66
+ const stops=[closeButton,search],at=stops.indexOf(doc.activeElement);
67
+ stops[(at+(event.shiftKey?-1:1)+stops.length)%stops.length].focus();
68
+ }
62
69
  else if(event.target===search&&['ArrowDown','ArrowUp'].includes(event.key)){event.preventDefault();selected=Math.max(0,Math.min(choices.length-1,selected+(event.key==='ArrowDown'?1:-1)));highlight();}
63
70
  else if(event.target===search&&event.key==='Enter'&&choices[selected]){event.preventDefault();choose(choices[selected]);}
64
71
  });
@@ -138,11 +138,13 @@ const WORD = /[A-Za-z0-9_.-]/;
138
138
  // The token being typed: inside a string (a quote opened earlier on this line) or a bare word. JSON strings never span lines.
139
139
  function tokenAt(text, position, explicit) {
140
140
  const lineStart = Math.max(text.lastIndexOf("\n", position - 1), text.lastIndexOf("\r", position - 1)) + 1;
141
- let open = -1;
141
+ let open = -1, last = null;
142
142
  for (let index = lineStart; index < position; index++) {
143
- if (open >= 0) { if (text[index] === "\\") index++; else if (text[index] === '"') open = -1; }
143
+ if (open >= 0) { if (text[index] === "\\") index++; else if (text[index] === '"') { last = { start: open, end: index }; open = -1; } }
144
144
  else if (text[index] === '"') open = index;
145
145
  }
146
+ // Asked for explicitly (Ctrl+Space) right after a closing quote: the string just ended is the token, as in VS Code.
147
+ if (open < 0 && explicit && last?.end === position - 1) return { quoted: true, closed: true, start: last.start, end: position, from: last.start + 1, to: position - 1 };
146
148
  if (open >= 0) {
147
149
  let close = -1;
148
150
  for (let index = position; index < text.length && text[index] !== "\n" && text[index] !== "\r"; index++) {
package/dist/presenter.js CHANGED
@@ -19,6 +19,29 @@ const CLICK_GUARD_MS = 450;
19
19
  const NEXT_KEYS = new Set(["ArrowRight", " ", "PageDown", "Enter"]);
20
20
  const PREVIOUS_KEYS = new Set(["ArrowLeft", "PageUp", "Backspace"]);
21
21
 
22
+ // Full screen belongs to the document, not to one presenter: the browser ends whatever is full screen when an exit it was asked for runs, and tells
23
+ // every listener. A presenter that closed early asks for its exit late (when its pending full screen request resolves), possibly after a newer
24
+ // presenter took full screen. So the exits the presenters asked for are counted per document, and any presenter reads a `fullscreenchange` that
25
+ // answers one of them as ours, not as the user leaving full screen.
26
+ const exitRequests = new WeakMap(); // Document -> Set of exits asked for and not yet reported
27
+ const attributedEvents = new WeakMap(); // fullscreenchange Event -> whether it answered an exit a presenter asked for
28
+ function noteExit(doc, win) {
29
+ let requests = exitRequests.get(doc);
30
+ if (!requests) exitRequests.set(doc, requests = new Set());
31
+ const request = {};
32
+ requests.add(request);
33
+ // The browser reports an exit with the event; when it does not (the exit was refused, or two exits became one event), stop counting it soon.
34
+ return { forget: () => requests.delete(request), forgetSoon: () => win.setTimeout(() => requests.delete(request), 500) };
35
+ }
36
+ function answersAnExit(doc, event) {
37
+ if (attributedEvents.has(event)) return attributedEvents.get(event);
38
+ const requests = exitRequests.get(doc);
39
+ const ours = Boolean(requests?.size) && !doc.fullscreenElement;
40
+ if (ours) requests.delete(requests.values().next().value);
41
+ attributedEvents.set(event, ours);
42
+ return ours;
43
+ }
44
+
22
45
  const DEFAULT_LABELS = {
23
46
  /** The accessible name of the presenter dialog. */
24
47
  dialog: "Presentation",
@@ -69,7 +92,7 @@ export function createPresenter(container, options = {}) {
69
92
  // Drawn slides by deck index, for the deck object they were drawn from: a new snapshot (an edit) or a new start() draws again.
70
93
  let drawn = new Map(), drawnFor;
71
94
 
72
- let active = false, destroyed = false, current = -1, opener = null, enteredFullscreen = false, leaving = false, returnTo = null;
95
+ let active = false, destroyed = false, current = -1, opener = null, enteredFullscreen = false, returnTo = null;
73
96
  let counterTimer, preloadTimer, renderToken = 0, swipe = null, clickGuardUntil = 0;
74
97
 
75
98
  // ---- the DOM ----------------------------------------------------------------------------------------------------------------------------
@@ -208,11 +231,12 @@ export function createPresenter(container, options = {}) {
208
231
  () => "overlay",
209
232
  );
210
233
  }
211
- // Ask the browser to leave full screen; onFullscreenChange then sees `leaving` and does not read the exit as the user's. Focus goes back to the
212
- // element that opened the presentation once the exit is done, because the page behind a full screen element takes no focus until then.
234
+ // Ask the browser to leave full screen; onFullscreenChange then sees that the event answers an exit asked for (by this presenter or an earlier
235
+ // one) and does not read it as the user's. Focus goes back to the element that opened the presentation once the exit is done, because the page
236
+ // behind a full screen element takes no focus until then.
213
237
  function leaveFullscreen() {
214
- leaving = true;
215
- Promise.resolve(doc.exitFullscreen?.()).then(restoreFocus, () => { leaving = false; });
238
+ const exit = noteExit(doc, win);
239
+ Promise.resolve(doc.exitFullscreen?.()).then(() => { exit.forgetSoon(); restoreFocus(); }, exit.forget);
216
240
  }
217
241
  // Only when nobody took focus since: the host may move it in `onExit`.
218
242
  function restoreFocus() {
@@ -220,11 +244,11 @@ export function createPresenter(container, options = {}) {
220
244
  if (returnTo?.isConnected && typeof returnTo.focus === "function" && (!now || now === doc.body || now === root)) returnTo.focus({ preventScroll: true });
221
245
  }
222
246
  // The browser takes full screen away with Esc (that key never reaches the page) or the host exits it: the presentation ends with it.
223
- function onFullscreenChange() {
247
+ function onFullscreenChange(event) {
224
248
  if (doc.fullscreenElement === root) return;
225
- if (leaving) {
226
- // The exit that stop() asked for finished. If the host started again meanwhile, full screen is gone and the overlay carries on.
227
- leaving = false;
249
+ if (answersAnExit(doc, event)) {
250
+ // An exit that a presenter asked for finished: ours from stop(), or an earlier presenter's that ran after this one took full screen. A
251
+ // presentation still running carries on as the overlay.
228
252
  if (active && enteredFullscreen) { enteredFullscreen = false; root.dataset.mode = "overlay"; }
229
253
  return;
230
254
  }
@@ -0,0 +1,92 @@
1
+ // Format document for the source pane (Shift+Alt+F, Cmd/Ctrl+Shift+F and the Format button): whitespace only, in every format, and nothing the
2
+ // author wrote is dropped. JSON is laid out token by token (so a draft that does not parse is laid out too), YAML goes through the `yaml`
3
+ // library's document round trip (comments, anchors, key order and scalar styles stay), and Markdown gets one blank line between blocks with its
4
+ // front matter formatted as YAML. Plain functions, no DOM: `formatSource(format, text, { unit })` returns `{ text }` or `{ error }`.
5
+ import { parseDocument } from "yaml";
6
+ import { formatJsonSource } from "./json-format.js";
7
+
8
+ const lineEnding = (source) => /\r\n|\r|\n/.exec(source)?.[0] ?? "\n";
9
+
10
+ /** YAML text laid out with a 2-space indent, long lines kept whole; its comments, blank lines, scalar styles and key order stay as written. */
11
+ export function formatYamlSource(source) {
12
+ if (source.trim() === "") return { text: source };
13
+ const bom = source.startsWith("\uFEFF") ? "\uFEFF" : "";
14
+ const eol = lineEnding(source);
15
+ const document = parseDocument(bom ? source.slice(1) : source, { prettyErrors: false, customTags: keepNumberSpelling });
16
+ if (document.errors.length) return { error: "The YAML has errors, so it cannot be formatted. Fix them first." };
17
+ // Only comments: there is no document to write, and writing one would add a `null`.
18
+ if (document.contents === null || document.contents === undefined) return { text: source };
19
+ let text = document.toString({ indent: 2, lineWidth: 0, flowCollectionPadding: false });
20
+ // The library ends every document with a newline; keep the author's choice.
21
+ if (!/[\r\n]\s*$/.test(source) && text.endsWith("\n")) text = text.slice(0, -1);
22
+ if (eol !== "\n") text = text.replaceAll("\n", eol);
23
+ return { text: bom + text };
24
+ }
25
+
26
+ // The library writes a number from its value (1e3 becomes 1e+3, 0x1F 0x1f, .5 0.5, +1 1); the author's spelling is kept instead.
27
+ const NUMBER_TAGS = new Set(["tag:yaml.org,2002:int", "tag:yaml.org,2002:float"]);
28
+ const keepNumberSpelling = (tags) => tags.map((tag) => (
29
+ typeof tag === "object" && tag && typeof tag.stringify === "function" && NUMBER_TAGS.has(tag.tag)
30
+ ? { ...tag, stringify: (item, context, onComment, onChompKeep) => (typeof item.source === "string" && item.source !== "" ? item.source : tag.stringify(item, context, onComment, onChompKeep)) }
31
+ : tag
32
+ ));
33
+
34
+ const FENCE = /^ {0,3}(`{3,}|~{3,})/;
35
+ const INDENTED = /^(?: {4}|\t)/;
36
+
37
+ /**
38
+ * Markdown laid out with at most one blank line between blocks (code fences and runs inside an indented block are left as written), the front
39
+ * matter formatted as YAML, one line ending (the first one found) and one newline at the end.
40
+ */
41
+ export function formatMarkdownSource(source) {
42
+ const eol = lineEnding(source);
43
+ const lines = source.split(/\r\n|\r|\n/);
44
+ let body = 0;
45
+ const out = [];
46
+ if (lines[0] === "---") {
47
+ const close = lines.findIndex((line, index) => index > 0 && (line === "---" || line === "..."));
48
+ if (close > 0) {
49
+ const front = formatYamlSource(lines.slice(1, close).join("\n"));
50
+ out.push("---", ...(front.error || front.text === "" ? lines.slice(1, close) : front.text.replace(/\n$/, "").split("\n")), lines[close]);
51
+ body = close + 1;
52
+ }
53
+ }
54
+ let fence = null, blanks = 0, lastIndented = false;
55
+ for (let index = body; index < lines.length; index++) {
56
+ const line = lines[index];
57
+ if (fence) {
58
+ out.push(line);
59
+ const marker = FENCE.exec(line)?.[1];
60
+ if (marker && marker[0] === fence[0] && marker.length >= fence.length && line.trim() === marker) fence = null;
61
+ continue;
62
+ }
63
+ if (line.trim() === "") { blanks++; continue; }
64
+ if (blanks) {
65
+ // A blank run inside an indented block (code) is content; elsewhere it is one blank line.
66
+ const keep = lastIndented && INDENTED.test(line) ? blanks : 1;
67
+ for (let count = 0; count < keep; count++) out.push("");
68
+ }
69
+ blanks = 0;
70
+ out.push(line);
71
+ lastIndented = INDENTED.test(line);
72
+ const opened = FENCE.exec(line)?.[1];
73
+ if (opened) fence = opened;
74
+ }
75
+ // A document that ends inside a fence keeps its last lines as written; otherwise it ends with one newline.
76
+ while (!fence && out.length && out[out.length - 1].trim() === "") out.pop();
77
+ return { text: out.length ? out.join(eol) + (fence ? "" : eol) : "" };
78
+ }
79
+
80
+ /**
81
+ * Format `text` as `format` ("json", "yaml" or "md"). `unit` is the editor's indentation unit for JSON (a tab or spaces). Returns `{ text }`
82
+ * (possibly the same text) or `{ error }` with a sentence for the author.
83
+ */
84
+ export function formatSource(format, text, { unit } = {}) {
85
+ try {
86
+ if (format === "yaml") return formatYamlSource(text);
87
+ if (format === "md") return formatMarkdownSource(text);
88
+ return { text: formatJsonSource(text, unit) };
89
+ } catch (error) {
90
+ return { error: `Formatting failed: ${error instanceof Error ? error.message : String(error)}` };
91
+ }
92
+ }
@@ -59,7 +59,7 @@ export interface SourcePaneOptions {
59
59
  /** Accessible name of the code editor (default `OPF source`). */
60
60
  label?: string;
61
61
  catalogs?: JsonCatalogContext;
62
- /** Live completions while typing JSON: property names and values (enums, booleans, catalog references) at the cursor's JSON path, from the same data as the field menu. YAML and Markdown keep the menu only. Default true. */
62
+ /** Live completions while typing JSON: property names and values (enums, booleans, catalog references) at the cursor's JSON path, from the same data as the field menu. Tab and Enter accept the selected item, Escape closes the list, Ctrl+Space (Cmd+I on macOS) opens it; the field menu is on Alt+Shift+O. YAML and Markdown have no completions yet. Default true. */
63
63
  completions?: boolean;
64
64
  onError?(error: unknown): void;
65
65
  }
@@ -5,6 +5,7 @@ import { mountJsonCodeEditor } from "./json-editor.js";
5
5
  import { MAX_EXACT_SOURCE_LENGTH, createSourceMemory, findDuplicateKey, updateJsonSource } from "./exact-source.js";
6
6
  import { lineForPath, lineOfOffset, offsetOfLine } from "./source-lines.js";
7
7
  import { errorText, normalizeFinding, rejectionFindings as findingsOfError } from "./source-findings.js";
8
+ import { formatSource } from "./source-format.js";
8
9
 
9
10
  // The source pane (RR-98): the OPF document as JSON, YAML or Markdown, with the file name, the format menu, Format, Copy
10
11
  // and Download, and the findings under the code. JSON is canonical and keeps its authored bytes (exact-source); YAML and
@@ -170,6 +171,8 @@ export function createSourcePane(container, options = {}) {
170
171
  return button;
171
172
  };
172
173
  const formatAction = actionButton("Format (fix indentation)", "Braces", "format");
174
+ formatAction.title = "Format (fix indentation), Shift+Alt+F";
175
+ formatAction.setAttribute("aria-keyshortcuts", "Shift+Alt+F");
173
176
  const copyAction = actionButton("Copy source", "Copy", "copy");
174
177
  const downloadAction = actionButton("Download source", "Download", "download");
175
178
  bar.append(file, actions);
@@ -236,6 +239,7 @@ export function createSourcePane(container, options = {}) {
236
239
  catalogs: options.catalogs ?? editor.catalogs,
237
240
  describedBy: `${id}-findings`,
238
241
  onChange: () => schedule(),
242
+ onFormat: () => formatDocument(),
239
243
  historyFallback: { undo: () => stepReplace("undo"), redo: () => stepReplace("redo") },
240
244
  onError: (error) => options.onError?.(error),
241
245
  });
@@ -601,30 +605,30 @@ export function createSourcePane(container, options = {}) {
601
605
  const current = formatOf(format);
602
606
  return { name, filename: `${name}.opf.${current.extension}`, text: control.api.getValue(), format: current.id, mime: current.mime };
603
607
  }
604
- formatAction.addEventListener("click", () => {
608
+ // Format document: the button and the keys (Shift+Alt+F, Cmd/Ctrl+Shift+F) do the same in every format. Whitespace only: JSON is laid out
609
+ // token by token (also while it does not parse), YAML keeps its comments, and a rewrite that would change the document is refused.
610
+ function formatDocument() {
605
611
  flush();
606
- if (readOnly) return;
607
- if (format === "json") { control.api.format(); control.api.focus(); return; }
608
- const read = readText(format, control.api.getValue(), catalogs());
609
- if (!read.deck) { renderFindings(); return; }
610
- let written;
611
- try { written = write(format, read.deck); }
612
- catch (error) {
613
- rejectionFindings = [normalizeFinding({ message: `Cannot rewrite the ${formatOf(format).label}: ${errorText(error)}`, kind: "rejected" })];
614
- renderFindings();
615
- return;
616
- }
612
+ if (readOnly) return false;
617
613
  const before = control.api.getValue();
618
- if (written.text !== before) {
619
- const comments = format === "yaml" ? scanYamlComments(before).count : 0;
620
- control.api.replace(0, before.length, written.text);
621
- flush();
622
- transientNotes = comments > 0 ? [normalizeFinding({ message: `${plural(comments, "comment")} in the YAML ${comments === 1 ? "was" : "were"} dropped when the text was rewritten.`, severity: "warning" }, "format")] : [];
623
- renderedKey = "";
624
- renderFindings();
625
- }
614
+ const result = formatSource(format, before, { unit: control.api.indentUnit() });
626
615
  control.api.focus();
627
- });
616
+ if (result.error) { announce(result.error); return false; }
617
+ if (result.text === before) { announce("Already formatted"); return true; }
618
+ if (format !== "json") {
619
+ let unchanged = true;
620
+ try {
621
+ const was = readText(format, before, catalogs()).deck;
622
+ unchanged = !was || same(was, readText(format, result.text, catalogs()).deck);
623
+ } catch { unchanged = false; }
624
+ if (!unchanged) { announce("Formatting would change the document, so the text was left as it is."); return false; }
625
+ }
626
+ control.api.replaceDiff(result.text);
627
+ flush();
628
+ announce("Formatted");
629
+ return true;
630
+ }
631
+ formatAction.addEventListener("click", () => { formatDocument(); });
628
632
  copyAction.addEventListener("click", async () => {
629
633
  const info = currentFile();
630
634
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-editor",
3
- "version": "0.26.0-rc.0",
3
+ "version": "0.26.1",
4
4
  "description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -282,10 +282,11 @@
282
282
  "@codemirror/view": "6.43.14",
283
283
  "@lezer/highlight": "1.2.5",
284
284
  "jsonc-parser": "3.3.1",
285
- "@openpresentation/opf": "^0.26.0-rc.0"
285
+ "yaml": "^2.9.1",
286
+ "@openpresentation/opf": "^0.26.1"
286
287
  },
287
288
  "peerDependencies": {
288
- "@openpresentation/opf-render": "^0.26.0-rc.0"
289
+ "@openpresentation/opf-render": "^0.26.1"
289
290
  },
290
291
  "peerDependenciesMeta": {
291
292
  "@openpresentation/opf-render": {
@@ -344,9 +345,9 @@
344
345
  "pdf-lib": "1.17.1",
345
346
  "pdfjs-dist": "6.4.299",
346
347
  "playwright": "1.64.0",
347
- "@openpresentation/gallery": "0.26.0-rc.0",
348
- "@openpresentation/opf-pptx": "0.26.0-rc.0",
349
- "@openpresentation/opf-render": "0.26.0-rc.0"
348
+ "@openpresentation/opf-render": "0.26.1",
349
+ "@openpresentation/opf-pptx": "0.26.1",
350
+ "@openpresentation/gallery": "0.26.1"
350
351
  },
351
352
  "scripts": {
352
353
  "build": "node scripts/build.mjs",
@@ -383,6 +384,7 @@
383
384
  "test:review-panel-browser": "node test/review-panel-browser.mjs",
384
385
  "test:review-hook-browser": "node test/review-hook-browser.mjs",
385
386
  "test:source-pane-browser": "node test/source-pane-browser.mjs",
387
+ "test:source-keyboard-browser": "node test/source-keyboard-browser.mjs",
386
388
  "test:layout-editor-browser": "node test/layout-editor-browser.mjs",
387
389
  "test:export-license-browser": "node test/export-license-browser.mjs",
388
390
  "test:chart-options-browser": "node test/chart-options-browser.mjs",
@@ -392,5 +394,5 @@
392
394
  "test:export-menu-browser": "node test/export-menu-browser.mjs",
393
395
  "test:agent-handoff-browser": "node test/agent-handoff-browser.mjs"
394
396
  },
395
- "gitHead": "fb80b1e8a7d6dd4a77946509fab9af7713657ce6"
397
+ "gitHead": "792e7c6d348011aa6f398989702fc8b8a80ec691"
396
398
  }