@openpresentation/opf-editor 0.26.1 → 0.26.3

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
@@ -117,14 +117,15 @@ Methods: `replaceDocument(document, { source?, format?, name?, meta? })` (below)
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
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.
120
+ - **Completions.** In JSON the pane offers what the schema and the catalogs say belongs at the caret (`completions: false` keeps the field menu only). *Keys*: the properties of the object at the caret, the required ones first, then the common ones in a curated order (a slide: `title`, `subtitle`, `layout`, `items`, `text`, `notes`, ...), then the rest; the promoted region keys of a slide (`top+middle:left+center`) show only once you type three characters of one. Keys already in the object are not offered. Accepting one writes the whole pair: a string key gets `"key": "|"`, a number `"key": |`, an array `"key": [|]`, an object `"key": {|}`, and an object with required members (`organization`: `id`, `name`) writes them one per line, each a tab stop that `Tab` walks (the last `Tab` leaves the braces). A key with a closed set of values (an enum, a catalog id, a boolean) opens the list of its values at once. A comma is added when a property follows, and not before `}`. *Values*: enum values and booleans, and catalog ids (layouts, themes, color and font schemes, narratives, audiences, purposes, tones: the document's own records first, then the registered catalogs', layouts in the order of their intents, each with its description and regions in the documentation panel); after a colon, `{}` and `[]` templates. *Snippets*: `slide`, `slide-text`, `slide-image`, `slide-chart`, `slide-quote`, `slide-metric`, `slide-table`, `slide-code`, `slide-two-column` and `section` in `slides`, `reference` in `references`, `dataset` in `datasets` and `deck` in a blank document, with tab stops (the chart type stop opens the list of chart types). *Triggers*: a quote or a word at a key or value place, `:` and the space after it, and `Ctrl+Space` (`Cmd+I` on macOS) anywhere the schema has something to offer, also right after a finished string; free text (titles, notes, alt text) and numbers never open a list on their own. *Keys in the list*: `Tab` and `Enter` accept (at once, right after a keystroke too), `Shift+Tab` and `Shift+Enter` accept too (an accepted item always replaces the whole token), `Escape` closes it, `Left`, `Right` and `Backspace` inside the word keep it open and filter by the text before the caret, `Ctrl+Space` with the list open shows or hides the documentation panel. With several cursors the accepted item goes in at each of them (as plain text). The data is core's (`describeField` and `catalogKindAt` of `@openpresentation/opf/schema`; `getJsonCompletions` of `/json-options` remains for hosts that want the plain list). YAML and Markdown have no completions yet. `mountJsonCodeEditor` takes the same `completions` option, off by default there.
121
+ - **Hover and problems.** Hovering a key shows the schema's description, its type and its allowed values; hovering a catalog id shows the record (name, description, regions, where it comes from); `Cmd/Ctrl+K Cmd/Ctrl+I` shows the same at the caret and `Escape` hides it. Every finding that has a place in the text is underlined (errors, warnings and notes, in every format that gives core a range; JSON gets one for each schema and reference finding too), with a tooltip that says what is wrong, what could come next and how many quick fixes there are. JSON syntax errors come from core's recovery analyzer (`analyzeJsonSyntax` of `@openpresentation/opf/source`): one problem per cause, at the cause (a key with no value is reported on its own line, not at the next token), up to five, 150 ms after the last key; a problem is held back while a list of keys is open next to it, so typing `"des` never flashes red. The panel rows say `Ln 3, Col 5`, the message, `Next: ...`, the rule and the quick fix buttons. `F8` and `Shift+F8` select the next and the previous problem (and say it; they wrap), `Cmd/Ctrl+Shift+M` moves focus to the panel (arrows, Home and End move between rows, Enter selects the range and returns to the code, Escape returns, `Cmd/Ctrl+.` applies the first fix of the row), `Cmd/Ctrl+.` in the code lists the fixes at the caret (the safest first; arrows, Enter, Escape). A fix is one undo step. Quick fixes in this version: the syntax repairs (insert or remove a comma, add `{ }` or remove a key with no value, close a bracket, straight quotes, ...), "Change to ..." for an enum or a reference that resolves nowhere, "Rename to ..." and "Remove property" for an unknown key, "Add `slides`" and the like for a missing required property, "Wrap in a list", "Add a slide", "Remove the first/second" for a duplicate key, and the patch and focus fixes core ships (alt text, readable color, language). `pane.getProblems()`, `nextProblem()`, `previousProblem()`, `showProblem(index)` and `triggerCompletion()` expose the same to a host.
121
122
  - **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
123
  - **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.
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.
124
+ - **Accessibility.** One polite live region (`.opf-source-live`) says how many suggestions there are when the list opens, the selected item and the first sentence of its documentation as you arrow (after a short pause), `Inserted design` when you accept, `Tab stop 2 of 3: ...` in a snippet, the problem when the caret enters a squiggle or `F8` reaches it, and `2 problems: 1 error, 1 warning` once the problems stop changing for 600 ms; hosts get every message through the `announce` option. Every suggestion has an accessible name with its kind and its place (`design, property, Design. 1 of 3`), the editor's `aria-description` says how to open the list and how to leave the editor (`Escape` then `Tab`, or `Ctrl+M`; `Ctrl+Shift+M` on macOS), and the problem rows start with the severity as text as well as a shape. 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.
124
125
  - **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.
125
126
  - **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.
126
127
 
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).
128
+ 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` and `pnpm run test:source-keyboard-json-browser` run the keyboard cases (real key events; `OPF_BROWSER_ENGINE=webkit` and `OPF_KEYBOARD_PLATFORM=win32|linux|darwin` pick the browser and the key map).
128
129
 
129
130
  ## Layout editor (RR-99)
130
131
 
@@ -1,7 +1,40 @@
1
1
  import type { JsonCatalogContext } from './json-options.js';
2
2
 
3
+ /** One text edit of a quick fix, in source offsets of the text as it is when the fix is applied. */
4
+ export interface SourceEdit { from: number; to: number; insert: string }
5
+ /**
6
+ * A quick fix of a problem: `edits` are applied as one undoable change (`caret`, or `select: [from, to]`, is a position in the text after them),
7
+ * or `run(api, problem)` does something else (the source pane applies a patch through the session).
8
+ */
9
+ export interface EditorFix {
10
+ id: string;
11
+ title: string;
12
+ /** Mechanical and unambiguous: offered first. */
13
+ safe?: boolean;
14
+ edits?: SourceEdit[];
15
+ caret?: number;
16
+ select?: [number, number];
17
+ run?(api: JsonEditorApi, problem: EditorProblem): void;
18
+ }
19
+ /** A problem drawn in the text: a squiggle over `from..to` (source offsets), its message, what could come next and its quick fixes. */
20
+ export interface EditorProblem {
21
+ from: number;
22
+ to: number;
23
+ severity: "error" | "warning" | "info";
24
+ message: string;
25
+ /** What to do, without a leading "Next:". */
26
+ next?: string;
27
+ /** The rule id, shown as the source of the problem. */
28
+ rule?: string;
29
+ fixes?: EditorFix[];
30
+ }
31
+
3
32
  /** All offsets address the original source in UTF-16, including CRLF pairs. */
4
33
  export interface JsonEditorApi {
34
+ /** Say `message` in the editor's live region (screen readers). */
35
+ say(message: string): void;
36
+ /** Apply several edits as one undoable change; `caret` or `select` is a position in the text after them. */
37
+ applyEdits(edits: SourceEdit[], options?: { caret?: number; select?: [number, number]; userEvent?: string }): void;
5
38
  getValue(): string;
6
39
  getSelection(): [number, number];
7
40
  hasFocus(): boolean;
@@ -32,9 +65,11 @@ export interface JsonCodeEditorOptions {
32
65
  language?: "json" | "yaml" | "markdown";
33
66
  readOnly?: boolean;
34
67
  /**
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.
68
+ * Live completion list in JSON (default false here; the source pane turns it on): property names from the schema, ranked with the required and the
69
+ * common ones first, and values (enums, booleans, catalog ids, `{}` and `[]` templates with their required members, snippets for slides and the like)
70
+ * at the cursor's JSON path. Typing a quote, a word, `:` or the space after it opens the list; Ctrl+Space (Cmd+I on macOS) opens it anywhere (with
71
+ * the list open it shows or hides the documentation). Tab and Enter (and Shift+Tab, Shift+Enter) accept the selected item, Escape closes the list,
72
+ * Left, Right and Backspace inside the word keep it open. The field menu is on Alt+Shift+O; with `completions` off it is also on Ctrl+Space.
38
73
  */
39
74
  completions?: boolean;
40
75
  catalogs?: JsonCatalogContext;
@@ -51,6 +86,17 @@ export interface JsonCodeEditorOptions {
51
86
  * format JSON only.
52
87
  */
53
88
  onFormat?(): void;
89
+ /** Hover documentation over keys and catalog ids in JSON, and Cmd/Ctrl+K Cmd/Ctrl+I (default true). */
90
+ hover?: boolean;
91
+ /** Called with every message the live region says (hosts mirror them into a region of their own). */
92
+ announce?(message: string): void;
93
+ /**
94
+ * JSON syntax errors are underlined by the editor itself, one per cause, 300 ms after the last key (default true). A host that supplies every
95
+ * problem through `setProblems` (the source pane) turns it off.
96
+ */
97
+ syntaxProblems?: boolean;
98
+ /** Cmd/Ctrl+Shift+M: move focus to the host's problems list. Answer true when it did. */
99
+ onFocusProblems?(): boolean;
54
100
  onFocus?(): void;
55
101
  onHoverField?(path: string | null): void;
56
102
  onError?(error: unknown): void;
@@ -60,6 +106,22 @@ export interface JsonCodeEditor {
60
106
  /** Apply external source without emitting onChange or adding a local undo step. */
61
107
  update(code: string, invalid?: boolean, field?: string | null, describedBy?: string): void;
62
108
  setCatalogs(catalogs: JsonCatalogContext): void;
109
+ /** Draw the problems in the text (squiggles with their messages and quick fixes). Replaces the ones drawn before. */
110
+ setProblems(problems: readonly EditorProblem[]): void;
111
+ /** The problems drawn now, in position order, at their current source offsets. */
112
+ getProblems(): EditorProblem[];
113
+ /** F8: select the next problem and say what it is; wraps. */
114
+ nextProblem(): boolean;
115
+ /** Shift+F8. */
116
+ previousProblem(): boolean;
117
+ /** Cmd/Ctrl+.: the quick fixes of the problems at the caret. */
118
+ triggerQuickFix(): boolean;
119
+ /** Ctrl+Space: open the suggestion list at the caret. */
120
+ triggerCompletion(): boolean;
121
+ /** Whether a suggestion list is open or being asked for. */
122
+ isCompleting(): boolean;
123
+ /** Perform a quick fix. */
124
+ applyFix(fix: EditorFix, problem?: EditorProblem): void;
63
125
  setLanguage(language: "json" | "yaml" | "markdown"): void;
64
126
  setReadOnly(readOnly: boolean): void;
65
127
  /** Forget the typed edits CodeMirror can undo, for when the text they were typed against was replaced. The text is unchanged. */
@@ -1,17 +1,19 @@
1
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";
2
+ import { Decoration, EditorView, activateHover, closeHoverTooltips, crosshairCursor, drawSelection, hasHoverTooltips, hoverTooltip, keymap, lineNumbers, rectangularSelection } from "@codemirror/view";
3
+ import { defaultKeymap, deleteCharBackward, toggleTabFocusMode, 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
- import { json, jsonParseLinter } from "@codemirror/lang-json";
5
+ import { json } from "@codemirror/lang-json";
6
6
  import { yaml } from "@codemirror/lang-yaml";
7
- import { acceptCompletion, autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap, completionStatus, moveCompletionSelection, startCompletion } from "@codemirror/autocomplete";
7
+ import { acceptCompletion, autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, hasNextSnippetField, hasPrevSnippetField, moveCompletionSelection, nextSnippetField, prevSnippetField, startCompletion } from "@codemirror/autocomplete";
8
8
  import { highlightSelectionMatches, search, searchKeymap } from "@codemirror/search";
9
- import { linter } from "@codemirror/lint";
10
9
  import { tags } from "@lezer/highlight";
11
10
  import { findNodeAtOffset, getNodePath, parseTree } from "jsonc-parser";
12
11
  import {jsonSource,jsonSourceHistory,setJsonSource} from './json-source.js';
13
12
  import {textInputMap} from './text-input.js';
14
13
  import {detectIndent,formatJsonSource,whitespaceEdits} from './json-format.js';
14
+ import {a11yExtension,createAnnouncer} from './source-a11y.js';
15
+ import { forEachDiagnostic } from "@codemirror/lint";
16
+ import {createDiagnostics,editsTransaction} from './source-diagnostics.js';
15
17
  const external = Annotation.define();
16
18
  const hoverField = StateEffect.define();
17
19
  const activeField = StateField.define({ create: () => null, update: (value, tr) => tr.effects.reduce((value2, effect) => effect.is(hoverField) ? effect.value : value2, value) });
@@ -69,7 +71,7 @@ const markdownLanguage = StreamLanguage.define({
69
71
  function languageExtension(name, completions) {
70
72
  if (name === "yaml") return yaml();
71
73
  if (name === "markdown") return markdownLanguage;
72
- return [json(), linter(jsonParseLinter(), { delay: 500 }), fieldDecorations, completions ?? []];
74
+ return [json(), fieldDecorations, completions ?? []];
73
75
  }
74
76
  // Tab or Enter pressed right after a keystroke can arrive before the completion query that keystroke started has answered (with the data
75
77
  // 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
@@ -77,7 +79,7 @@ function languageExtension(name, completions) {
77
79
  function acceptOrFallBack(isReady, fallback) {
78
80
  return (view) => {
79
81
  if (acceptCompletion(view)) return true;
80
- if (completionStatus(view.state) !== "pending" || !isReady()) return false;
82
+ if (completionStatus(view.state) !== "pending" || !isReady()) return fallback(view);
81
83
  let tries = 0;
82
84
  const settle = () => {
83
85
  if (!view.dom.isConnected) return;
@@ -88,37 +90,69 @@ function acceptOrFallBack(isReady, fallback) {
88
90
  return true;
89
91
  };
90
92
  }
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) {
93
+ // The documentation panel beside the selected item is shown by default (VS Code's `suggest.showInlineDetails`); Ctrl+Space with the list open
94
+ // hides or shows it (`toggleSuggestionDetails`). The choice is the editor's, not the list's, so it stays across lists.
95
+ function toggleDocs(view) {
96
+ const hidden = view.dom.classList.toggle("opf-docs-off");
97
+ view.dom.dataset.opfDocs = hidden ? "off" : "on";
98
+ return true;
99
+ }
100
+ const listIsOpen = (state) => completionStatus(state) === "active" && currentCompletions(state).length > 0;
101
+ // Left and Right inside the word being completed move the caret and keep the list, which then filters by the text before the caret (VS Code).
102
+ // A move that leaves the word (or the line) is the plain move, which closes the list. The move is dispatched as typing without a change, which
103
+ // is the one transaction the completion state keeps its result for.
104
+ function moveInToken(forward, tokenOf) {
105
+ return (view) => {
106
+ const { state } = view, range = state.selection.main;
107
+ if (!listIsOpen(state) || state.selection.ranges.length !== 1 || !range.empty) return false;
108
+ const target = view.moveByChar(range, forward);
109
+ if (target.head === range.head) return false;
110
+ const text = state.doc.toString(), here = tokenOf(text, range.head), there = tokenOf(text, target.head);
111
+ if (!here || !there || here.start !== there.start) return false;
112
+ view.dispatch({ selection: { anchor: target.head }, userEvent: "input.type", scrollIntoView: true });
113
+ // Ask again at the new caret, so the list filters by the text before it.
114
+ startCompletion(view);
115
+ return true;
116
+ };
117
+ }
118
+ // Backspace with the list open deletes and asks again, so the list widens as the word shrinks (the completion state alone closes the list
119
+ // when the caret goes back past the place it was opened).
120
+ function backspaceInToken(tokenOf) {
121
+ return (view) => {
122
+ if (!listIsOpen(view.state) || view.state.selection.ranges.length !== 1 || !view.state.selection.main.empty) return false;
123
+ if (!(deleteBracketPair(view) || deleteCharBackward(view))) return false;
124
+ const { state } = view;
125
+ if (tokenOf(state.doc.toString(), state.selection.main.head)) startCompletion(view);
126
+ return true;
127
+ };
128
+ }
129
+ // Completion keys, as in VS Code: Tab and Enter accept the selected suggestion (Shift+Tab and Shift+Enter accept it too: in a JSON string the
130
+ // accepted item always replaces the whole token), Escape closes the list, Ctrl+Space (and Cmd+I on macOS) opens it wherever the schema has
131
+ // something to offer and, with the list open, shows or hides its documentation, and the arrows, the Page keys and Ctrl+N and Ctrl+P (macOS)
132
+ // move the selection. Without a list, none of them is taken: Tab then inserts whitespace (or walks the stops of a snippet) and Enter inserts
133
+ // a line.
134
+ function completionKeys(isReady, tokenOf, onStep) {
135
+ // Tab and Shift+Tab walk the tab stops of a snippet; the engine says which stop it is and opens the list at a stop that is a choice.
136
+ const walk = (forward, fallback) => (view) => {
137
+ if (forward ? hasNextSnippetField(view.state) && nextSnippetField(view) : hasPrevSnippetField(view.state) && prevSnippetField(view)) { onStep(view, forward); return true; }
138
+ return fallback(view);
139
+ };
140
+ const enter = (view) => nextStringItem(view) || insertNewlineAndIndent(view);
95
141
  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) },
142
+ ...completionKeymap.filter((binding) => binding.key !== "Enter" && binding.key !== "Ctrl-Space"),
143
+ { key: "Ctrl-Space", run: (view) => (listIsOpen(view.state) ? toggleDocs(view) : startCompletion(view)) },
144
+ { key: "Enter", run: acceptOrFallBack(isReady, enter) },
145
+ { key: "Shift-Enter", run: acceptOrFallBack(isReady, insertNewlineAndIndent) },
146
+ { key: "Tab", run: acceptOrFallBack(isReady, walk(true, tabCommand)) },
147
+ { key: "Shift-Tab", run: acceptOrFallBack(isReady, walk(false, indentLess)) },
148
+ { key: "ArrowLeft", run: moveInToken(false, tokenOf) },
149
+ { key: "ArrowRight", run: moveInToken(true, tokenOf) },
150
+ { key: "Backspace", run: backspaceInToken(tokenOf) },
99
151
  { mac: "Mod-i", run: startCompletion },
100
152
  { mac: "Ctrl-n", run: moveCompletionSelection(true) },
101
153
  { mac: "Ctrl-p", run: moveCompletionSelection(false) },
102
154
  ]));
103
155
  }
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
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.
107
- function completionApply(found, option) {
108
- return (view, completion, from, 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;
112
- let insert = JSON.stringify(option.value);
113
- const chain = found.kind === "key" && !/^\s*:/.test(state.sliceDoc(end, Math.min(state.doc.length, end + 200)));
114
- if (chain) insert += ": ";
115
- view.dispatch({ changes: { from: start, to: end, insert }, selection: { anchor: start + insert.length }, userEvent: "input.complete", scrollIntoView: true });
116
- if (chain) setTimeout(() => startCompletion(view), 0);
117
- };
118
- }
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
156
  const DEFAULT_INDENT = " ";
123
157
  const tabWidth = (unit) => (unit === "\t" ? 4 : unit.length);
124
158
  // 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
@@ -248,21 +282,41 @@ function mountJsonCodeEditor(parent, options) {
248
282
  menu=mountJsonFieldMenu(parent,api,catalogs,left,top,()=>{menu=null;});
249
283
  }catch(error){options.onError?.(error);}
250
284
  }
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.
285
+ // Live completions (JSON only, `completions: true`): property names and values at the cursor's JSON path, from the schema and the catalogs, with
286
+ // templates and snippets (source-completions.js). The engine loads at the first focus or the first query; once loaded, a query answers at once
287
+ // (no promise), and the list stays valid while the author types within the same token (`validFor`), so typing only refilters it.
254
288
  let optionsModule = null, optionsLoading = null;
255
- const loadOptions = () => (optionsLoading ??= import('./json-options.js').then((module) => (optionsModule = module), (error) => { optionsLoading = null; throw error; }));
289
+ const loadOptions = () => (optionsLoading ??= import('./source-completions.js').then((module) => (optionsModule = module), (error) => { optionsLoading = null; throw error; }));
290
+ const applyOption = (target, completion, from, to) => optionsModule.applyCompletion(target, completion, from, to, say);
291
+ // Hover: the schema's words over a key, the record's over a catalog id. A problem under the pointer is the lint tooltip's (message, next, fixes).
292
+ const problemAt = (state, position) => { let hit = false; forEachDiagnostic(state, (_diagnostic, from, to) => { if (position >= from && position <= to) hit = true; }); return hit; };
293
+ const hoverExtension = options.hover === false ? [] : hoverTooltip(async (target, position) => {
294
+ if (disposed || languageName !== "json" || problemAt(target.state, position)) return null;
295
+ if (!optionsModule) { try { await loadOptions(); } catch { return null; } }
296
+ return optionsModule.hoverAt(target.state, position, { catalogs });
297
+ }, { hoverTime: 300 });
298
+ // Cmd/Ctrl+K Cmd/Ctrl+I shows the hover at the caret (VS Code), and says what it holds.
299
+ let chordStartedAt = 0;
300
+ const showHoverAtCaret = (target) => {
301
+ if (languageName !== "json" || options.hover === false) return false;
302
+ const run = () => {
303
+ const position = target.state.selection.main.head;
304
+ const found = optionsModule.hoverModelAt(target.state, position, { catalogs });
305
+ if (!found) { say("No hover information here"); return; }
306
+ activateHover(target, found.from, 1, { tooltip: hoverExtension, until: (tr) => tr.docChanged || (tr.selection !== undefined && tr.newSelection.main.head !== position) });
307
+ say(optionsModule.docText(found.model));
308
+ };
309
+ if (optionsModule) run(); else loadOptions().then(run, () => {});
310
+ return true;
311
+ };
256
312
  function completionResult(context) {
257
- const found = optionsModule.getJsonCompletions(context.state.doc.toString(), context.pos, catalogs, { explicit: context.explicit });
313
+ const found = optionsModule.completionResult(context.state, context.pos, { explicit: context.explicit, catalogs });
258
314
  if (!found) return null;
259
- return { from: found.from, to: found.to, filter: true, validFor: found.quoted ? QUOTED_TEXT : WORD_TEXT, options: found.options.map((option) => ({
260
- label: option.label, detail: option.detail, info: option.info, boost: option.boost, type: found.kind === "key" ? "property" : "enum", apply: completionApply(found, option),
261
- })) };
315
+ for (const option of found.options) option.apply = applyOption;
316
+ return found;
262
317
  }
263
318
  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;
319
+ if (disposed || languageName !== "json" || context.state.readOnly) return null;
266
320
  if (optionsModule) return completionResult(context);
267
321
  return loadOptions().then(() => (disposed || context.aborted ? null : completionResult(context)), (error) => { options.onError?.(error); return null; });
268
322
  }
@@ -271,12 +325,42 @@ function mountJsonCodeEditor(parent, options) {
271
325
  const redoCommand = (target) => redo(target) || Boolean(options.historyFallback?.redo?.());
272
326
  const historyBindings = historyKeymap.map((binding) => binding.run === undo ? { ...binding, run: undoCommand } : binding.run === redo ? { ...binding, run: redoCommand } : binding);
273
327
  // 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;
328
+ // Screen reader announcements and the problems drawn in the text (squiggles, F8, Cmd/Ctrl+.). The pane supplies the problems.
329
+ const announcer = createAnnouncer(parent, options.announce);
330
+ const say = (message) => announcer.say(message);
331
+ const applyProblemFix = (fix, problem) => {
332
+ if (disposed || view.state.readOnly) return;
333
+ if (typeof fix.run === "function") { fix.run(api, problem); return; }
334
+ if (fix.edits?.length) {
335
+ view.dispatch(editsTransaction(view.state, api.getValue(), fix.edits, { caret: fix.caret, select: fix.select }));
336
+ view.focus();
337
+ say(`${fix.title} applied`);
338
+ }
339
+ };
340
+ const diagnostics = createDiagnostics({ getSource: () => view.state.field(jsonSource), say, applyFix: applyProblemFix });
341
+ // On its own (no source pane to supply the problems) the editor underlines the syntax errors of JSON: one per cause, 300 ms after the last key.
342
+ let syntaxTimer = null;
343
+ function scheduleSyntax() {
344
+ if (options.syntaxProblems === false) return;
345
+ clearTimeout(syntaxTimer);
346
+ syntaxTimer = setTimeout(async () => {
347
+ if (disposed || languageName !== "json") return;
348
+ try {
349
+ const { analyzeSyntax } = await import('./source-syntax.js');
350
+ if (disposed || languageName !== "json") return;
351
+ const source = api.getValue();
352
+ diagnostics.draw(view, analyzeSyntax(source).map((problem) => ({ from: problem.range.from, to: problem.range.to, severity: "error", message: problem.message, next: problem.next, rule: problem.ruleId,
353
+ fixes: (problem.editFixes ?? []).filter((fix) => !fix.action).map((fix) => ({ id: fix.id, title: fix.title, safe: fix.safe, edits: fix.edits, caret: fix.caret })) })));
354
+ } catch (error) { options.onError?.(error); }
355
+ }, 300);
356
+ }
357
+ const completionExtension = options.completions ? [autocompletion({ override: [completionSource], defaultKeymap: false, icons: false, maxRenderedOptions: 60, interactionDelay: 0, activateOnTypingDelay: 0 }), completionKeys(() => optionsModule !== null, (text, position) => optionsModule?.tokenAt(text, position, false), (target, forward) => optionsModule?.snippetStep(target, forward, { say }))] : null;
275
358
  const contentAttributes = (invalid = options.invalid, describedBy = options.describedBy) => EditorView.contentAttributes.of({
276
359
  "aria-label": options.label,
277
360
  "aria-invalid": String(Boolean(invalid)),
278
361
  "aria-describedby": describedBy ?? "",
279
- "aria-keyshortcuts": options.completions ? "Control+Space" : "Control+Space Alt+Shift+O",
362
+ "aria-keyshortcuts": options.completions ? "Control+Space F8 Shift+F8 Control+Period Alt+Shift+F" : "Control+Space Alt+Shift+O Alt+Shift+F",
363
+ "aria-description": options.completions ? "Press Control+Space for suggestions. Press F8 for the next problem. Press Escape then Tab to leave the editor." : "Press Escape then Tab to leave the editor.",
280
364
  spellcheck: "false",
281
365
  autocapitalize: "off",
282
366
  autocorrect: "off"
@@ -290,7 +374,14 @@ function mountJsonCodeEditor(parent, options) {
290
374
  // The field menu: Alt+Shift+O, a chord VS Code does not use.
291
375
  const isOptionsKey = (event) => event.altKey && event.shiftKey && !event.metaKey && !event.ctrlKey && letter(event, "o");
292
376
  const view = new EditorView({ parent, state: EditorState.create({ doc: options.code, extensions: [
377
+ // The problem menu's keys come before the list's: the two are never open together, and Enter must reach whichever is.
378
+ diagnostics.extension,
293
379
  language.of(languageExtension(languageName, completionExtension)),
380
+ hoverExtension,
381
+ Prec.high(keymap.of([
382
+ { key: "Escape", run: (target) => (hasHoverTooltips(target.state) ? (target.dispatch({ effects: closeHoverTooltips }), true) : false) },
383
+ ])),
384
+ a11yExtension(announcer, (completion) => completion.opfDoc ?? (typeof completion.info === "string" ? completion.info : "")),
294
385
  editable.of(editableExtension(options.readOnly)),
295
386
  jsonSource.init(()=>options.code),jsonSourceHistory,
296
387
  indent.of(indentExtension(languageName)),
@@ -323,6 +414,16 @@ function mountJsonCodeEditor(parent, options) {
323
414
  redoCommand(view);
324
415
  return true;
325
416
  }
417
+ // Cmd/Ctrl+K then Cmd/Ctrl+I (within two seconds) shows the hover at the caret. It is read here, not as a CodeMirror chord, because a chord
418
+ // that starts with Mod+K would also swallow Mod+Shift+K (delete line).
419
+ const mod = /Mac|iPhone|iPad/.test(navigator.platform) ? event.metaKey : event.ctrlKey;
420
+ if (mod && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "k") { chordStartedAt = Date.now(); event.preventDefault(); return true; }
421
+ else if (mod && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "i" && Date.now() - chordStartedAt < 2000) {
422
+ chordStartedAt = 0;
423
+ if (showHoverAtCaret(view)) { event.preventDefault(); return true; }
424
+ }
425
+ // The problems panel: Cmd+Shift+M on macOS, Ctrl+Shift+M elsewhere (read from the physical key, as the format chord is).
426
+ if (mod && event.shiftKey && !event.altKey && event.code === "KeyM" && options.onFocusProblems?.()) { event.preventDefault(); return true; }
326
427
  if (isFormatKey(event)) {
327
428
  event.preventDefault();
328
429
  runFormat();
@@ -343,6 +444,10 @@ function mountJsonCodeEditor(parent, options) {
343
444
  // it is also on Ctrl+Space, because the list is not there to take that key.
344
445
  ...(options.completions ? [] : [{ key: "Ctrl-Space", run: () => languageName === "json" && (void openOptions(), true) }, { key: "Mod-Space", run: () => languageName === "json" && (void openOptions(), true) }]),
345
446
  { key: "Tab", run: tabCommand, shift: indentLess },
447
+ // The problems panel (VS Code: Cmd+Shift+M on macOS, Ctrl+Shift+M elsewhere); Ctrl+M (Ctrl+Shift+M on macOS) is VS Code's switch for Tab leaving the editor.
448
+ // JSON has no comments: say so instead of doing nothing (YAML and Markdown comment their lines out).
449
+ { key: "Mod-/", run: () => { if (languageName !== "json") return false; say("JSON has no comments. Use YAML or Markdown for notes."); return true; } },
450
+ { key: "Ctrl-m", mac: "Ctrl-Shift-m", run: toggleTabFocusMode },
346
451
  ...closeBracketsKeymap,
347
452
  ...defaultKeymap,
348
453
  ...historyBindings,
@@ -373,6 +478,7 @@ function mountJsonCodeEditor(parent, options) {
373
478
  const busy = completionStatus(update.state) === "pending", dom = update.view.dom;
374
479
  if (busy !== (dom.dataset.opfBusy === "1")) { if (busy) dom.dataset.opfBusy = "1"; else delete dom.dataset.opfBusy; }
375
480
  if (update.docChanged && !update.transactions.every((tr) => tr.annotation(external))) options.onChange(update.state.field(jsonSource));
481
+ if (update.docChanged) scheduleSyntax();
376
482
  })
377
483
  ] }) });
378
484
  const api = {
@@ -393,6 +499,15 @@ function mountJsonCodeEditor(parent, options) {
393
499
  },
394
500
  /** The indentation unit now in effect: a tab character or some spaces. */
395
501
  indentUnit: () => view.state.facet(indentUnit),
502
+ /** Say `message` in the editor's live region. */
503
+ say,
504
+ /**
505
+ * Apply several edits as one undoable change. `edits` are `{ from, to, insert }` in source offsets of the text as it is now; `caret` (or
506
+ * `select: [from, to]`) is a position in the text after the edits.
507
+ */
508
+ applyEdits(edits, { caret, select, userEvent } = {}) {
509
+ view.dispatch(editsTransaction(view.state, api.getValue(), edits, { caret, select, userEvent }));
510
+ },
396
511
  coordinates: () => {
397
512
  const rect = view.coordsAtPos(view.state.selection.main.head) ?? parent.getBoundingClientRect();
398
513
  return { left: rect.left, top: rect.bottom + 8 };
@@ -434,6 +549,23 @@ function mountJsonCodeEditor(parent, options) {
434
549
  api,
435
550
  openOptions,
436
551
  setCatalogs(value){catalogs=value??[];closeOptions();},
552
+ /**
553
+ * Draw the problems in the text: `{ from, to, severity, message, next?, rule?, fixes? }` with source offsets into the current text. A fix is
554
+ * `{ id, title, safe?, edits?, caret?, select?, run? }`.
555
+ */
556
+ setProblems(problems){if(!disposed)diagnostics.draw(view,problems??[]);},
557
+ /** The problems drawn now, in position order, at their current source offsets. */
558
+ getProblems:()=>diagnostics.problems(view).map(({diagnostic,...problem})=>problem),
559
+ nextProblem:()=>diagnostics.next(view),
560
+ previousProblem:()=>diagnostics.previous(view),
561
+ /** Open the quick fix list for the problems at the caret (Cmd/Ctrl+.). */
562
+ triggerQuickFix:()=>diagnostics.openMenu(view),
563
+ /** Open the suggestion list at the caret (Ctrl+Space). */
564
+ triggerCompletion:()=>startCompletion(view),
565
+ /** Whether a suggestion list is open or being asked for. */
566
+ isCompleting:()=>completionStatus(view.state)!==null,
567
+ /** Perform a fix (its edits as one undoable change, or its own action). */
568
+ applyFix:(fix,problem)=>applyProblemFix(fix,problem),
437
569
  /** Switch the highlighting between "json" (with its parse linter, field highlights and option menu), "yaml" and "markdown". */
438
570
  setLanguage(name){
439
571
  if(name===languageName)return;
@@ -479,7 +611,7 @@ function mountJsonCodeEditor(parent, options) {
479
611
  annotations: [external.of(true), Transaction.addToHistory.of(false)]
480
612
  });
481
613
  },
482
- destroy: () => {if(disposed)return;disposed=true;closeOptions();view.destroy();}
614
+ destroy: () => {if(disposed)return;disposed=true;clearTimeout(syntaxTimer);closeOptions();announcer.destroy();view.destroy();}
483
615
  };
484
616
  }
485
617
  export {
@@ -3,22 +3,7 @@ import { listCatalogRecords } from "./catalogs.js";
3
3
  import { resolveSchema, schemaAtPath, schemaVariants } from "./schema.js";
4
4
  import { findNodeAtOffset, getNodePath, parse as parseJsonc, parseTree } from "jsonc-parser";
5
5
  import { LAYOUT_INTENT_GROUPS, layoutRegionSummary } from "./layout-templates.js";
6
- // The catalog kind a field references (OPF 0.15: every content kind is `id` or `name:id`), read from where the field sits.
7
- const ROOT_REFERENCES = { narrative: "narratives", audience: "audiences", purpose: "purposes", tone: "tones" };
8
- const DESIGN_REFERENCES = { theme: "themes", colorScheme: "colorSchemes", fontScheme: "fontSchemes" };
9
- function catalogAtPath(path) {
10
- const parts = path.map(String);
11
- // A reference's object form names its record with `id`; an audience array's entries are references too.
12
- const field = parts.at(-1) === "id" ? parts.slice(0, -1) : parts;
13
- const tail = /^\d+$/.test(field.at(-1) ?? "") && field.at(-2) === "audience" ? field.slice(0, -1) : field;
14
- const key = tail.at(-1);
15
- if (tail.length === 3 && tail[0] === "slides" && key === "layout") return "layouts";
16
- if (tail.length === 1 && ROOT_REFERENCES[key]) return ROOT_REFERENCES[key];
17
- if (DESIGN_REFERENCES[key] && ((tail.length === 2 && tail[0] === "design") || (tail.length === 4 && tail[0] === "slides" && tail[2] === "design"))) return DESIGN_REFERENCES[key];
18
- // Inside an embedded record: a theme's colour or font scheme.
19
- if (tail.length === 5 && tail[0] === "catalogs" && tail[2] === "themes" && (key === "colorScheme" || key === "fontScheme")) return DESIGN_REFERENCES[key];
20
- return undefined;
21
- }
6
+ import { catalogKindAt } from "@openpresentation/opf/schema";
22
7
  const sourceDetails = {
23
8
  "Loaded catalog": ["Provided by app", "Records from a catalog this app registered; nothing is fetched from a URL. Saving embeds the record in the document."],
24
9
  "Document catalog": ["In this document", "Embedded in this document’s catalogs. Takes precedence over the app’s records with the same reference."],
@@ -44,7 +29,8 @@ function describeOptions(choices, records, current, catalog) {
44
29
  function fieldOptions(presentation, path, loadedCatalogs, { string, boolean }) {
45
30
  const schema = schemaAtPath(presentation, path.map(String));
46
31
  const variants = schemaVariants(schema);
47
- const catalog = catalogAtPath(path);
32
+ // A reference's object form names its record with `id`: the field is the reference.
33
+ const segments = path.map(String), catalog = catalogKindAt(segments.at(-1) === "id" ? segments.slice(0, -1) : segments, presentation);
48
34
  const options = new Map();
49
35
  const records = new Map();
50
36
  // A document group whose source no registered catalog matches: its records beyond the embedded ones are not available here.
@@ -136,7 +122,7 @@ export function fieldOptionEdit(context, value) {
136
122
  const PLACEHOLDER = "__opf_completion__";
137
123
  const WORD = /[A-Za-z0-9_.-]/;
138
124
  // The token being typed: inside a string (a quote opened earlier on this line) or a bare word. JSON strings never span lines.
139
- function tokenAt(text, position, explicit) {
125
+ export function tokenAt(text, position, explicit) {
140
126
  const lineStart = Math.max(text.lastIndexOf("\n", position - 1), text.lastIndexOf("\r", position - 1)) + 1;
141
127
  let open = -1, last = null;
142
128
  for (let index = lineStart; index < position; index++) {