@openpresentation/opf-editor 0.26.5 → 0.26.7

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
@@ -122,10 +122,16 @@ Methods: `replaceDocument(document, { source?, format?, name?, meta? })` (below)
122
122
  - **Completions in Markdown.** The front matter and the YAML in `opf-slide` and `opf-block` fences are the YAML above (the front matter never offers `slides`); `<!-- ` offers `slide:` and `block:`; inside a slide options comment the keys (`id`, `type`, `layout`, `section`, `tag`, `hidden`, `beat`) and their values (layout ids, slide types, `true` and `false`), inside a block options comment `id`, `type`, `as` (`bullets`, `video`) and `region` (the regions of the slide's layout, then the promoted ones); after a fence's three backticks the fence kinds (`chart`, `metric`, `timeline`, `opf-block`, `opf-slide` and the code languages), after `chart ` the chart types, after the info word the attributes (`alt=`, `name=`, `description=`, `title=`); the keys and values of a `metric` fence and the status marks (`[x]`, `[>]`, `[ ]`) at the start of a `timeline` event; `{` after `]` the attributes of an inline span (`bold`, `color=`, `size=`, `font=`, `lang=`); and at the start of a line `Note:`, `Notes:` and the snippets (a slide of each kind, with its `---` separator and a blank line above it when it follows another slide). Code fences, chart data, speaker notes and plain comments never open a list, and prose does not either: a word at the start of a line opens one from the third letter on, only while it is the start of a keyword or a snippet (`Ctrl+Space` opens it anywhere). Hover (and `Cmd/Ctrl+K Cmd/Ctrl+I`) explains a front matter key or value and a slide option as in YAML, and the dialect's own syntax: the `#` of a title and the `##` of a subtitle, a list marker, `Note:`, a quote's `>`, the word of a `chart`, `metric`, `timeline`, `opf-slide` or `opf-block` fence, `slide:` and `block:`, and a `---` line, each with the schema's description of the field it writes and how it is written. The vocabulary is core's (`scanMarkdown`, `SLIDE_OPTION_KEYS`, `FENCE_KINDS`, ... of `@openpresentation/opf/source`).
123
123
  - **References inside the document.** In all three formats the lists also offer the ids the document itself declares, read from the text being edited (a draft too): `asset:<id>` from `assets` (and `var:<id>` of image variables) where an image or other asset goes, dataset ids from `datasets` (`chart.data.dataset`, `table.dataset`), the column names of that dataset (or of the chart's own `data.columns`) in `fields`, `mapping`, `line`, `secondaryAxis` and `highlight.series`, `cite` ids from `references`, `beat` ids from the narrative the deck names (in a Markdown slide options comment too), `region` names from the slide's layout (then the promoted regions), `var:<id>` color variables where a color goes, and the variables of `variables` after `{{` in any text (`Tab` writes the name and the closing braces; speaker notes stay verbatim). Which field takes which kind is one table in `source-references.js`, checked against the schema by a unit test. A slide with a `layout` lists the keys that layout's regions call for first (after the required ones). A list of catalog records ends with the line "Records from catalog X are not loaded" when the document names a catalog group whose source the host did not register (its embedded records are still offered). Accepting a key of a closed set of values and then its value is one undo step. The deck's `design`, `organization` and `speaker` keys are accepted as snippets (a tab stop for each member; `design` opens the theme, color scheme and font scheme lists in turn). In YAML (and the YAML of Markdown) the list also works on the lines of a flow collection or a quoted scalar that runs over several lines.
124
124
  - **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). Phase 3 writes every problem in plain words from core (`describeFinding`: the schema keywords, all 77 `opf/*` rules, the YAML and Markdown readers; each with a `Next:` line) and adds a Details disclosure per row (`explain(ruleId)`: what the rule checks, why, how to fix it, an example). YAML and Markdown syntax problems have fixes too (`Convert tabs to spaces`, `Insert closing quote`, ``Insert ]``, `Add a colon`, `Quote the value`, `Insert ---`, `Insert closing fence`, `Insert -->`, `Rename to layout`, `Insert column`, ``Add `numbering` ``, ...), and every format gets `Move into extensions`, `Convert to string`, `Wrap in a list` and `Add a slide` as comment-preserving text edits. `pane.getProblems()`, `nextProblem()`, `previousProblem()`, `showProblem(index)` and `triggerCompletion()` expose the same to a host.
125
+ - **Shape hint, built-in variables and image targets.** `Cmd/Ctrl+Shift+Space` shows the fields of the object around the caret as a tooltip (`title set`, `items next`, `notes optional` for a slide with a layout; the required fields of an `organization`, a chart's `type` and `data`, the field of a block's `type`), and typing `{` or `,` between the members of a slide that has a `layout` shows it by itself; it follows the caret across members, closes on `Escape` or when the caret leaves the object, never takes the arrow keys, is said once in the live region, and works in JSON, YAML, Markdown (front matter, a slide's options comment, `opf-slide` fences) and read-only editors (`hints: false` turns the automatic show off). After `{{` the list offers the document's variables first and then the built-in ones (`deck.*`, `speaker.*`, `organization.*`, `slide.*`, from core's `listBuiltinVariables`), each saying whether the document sets it; accepting one with several cursors writes it at every cursor in one undo step. In Markdown, `![alt](` offers `asset:<id>` and `var:<id>` (image variables) from the front matter, the videos first after `<!-- block: as=video -->`. The Markdown quick fixes now cover a property set twice ("Remove `id` from the fence"), an empty quote, an orphan or trailing options comment, a malformed option, an attribute a fence does not take and a metric key given twice.
125
126
  - **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.
127
+ - **One table of keys, the shortcuts dialog and `getShortcuts()`.** Every binding is declared in `source-keymap.js` (keys per platform, title, group) and the editor installs the keys it reads from there, so `pane.getShortcuts(platform?)` (`{ id, title, group, keys, symbols, strokes, when? }`) lists exactly what is installed. `F1`, `Cmd/Ctrl+K Cmd/Ctrl+S`, the `?` button of the bar, or `?` on any control that is not a text field open the searchable dialog; `onShowShortcuts(shortcuts)` returning `true` lets the host show its own. `Cmd/Ctrl+click` goes to the definition (`Alt+click` adds a cursor, as in VS Code); `Ctrl+G` goes to a line, `Cmd+Alt+F` / `Ctrl+H` replaces, `Cmd/Ctrl+L` expands the line selection, `Shift+Alt+Right/Left` (`Ctrl+Shift+Cmd+Right/Left` on macOS) expands and shrinks the selection, `Cmd/Ctrl+Shift+Enter` inserts a line above.
128
+ - **Folding.** A fold gutter in all three formats (JSON objects and lists, YAML by indent, Markdown: the front matter, each fence and each slide). `Cmd+Alt+[` / `Ctrl+Shift+[` folds the innermost range around the caret and `]` unfolds it; the chords `Cmd/Ctrl+K` then `0` (fold all), `J` (unfold all), `1` to `7` (fold level), `L` (toggle), `[` and `]` (recursively) are VS Code's.
129
+ - **Outline.** `Cmd/Ctrl+Shift+O` opens the symbol picker: the slides (`3. Highlights`) and the document's assets, datasets, variables, references, design/organization/speaker sections and embedded catalog records, filtered as you type; `Enter` goes there and selects its range, `Escape` returns. `pane.getOutline()` returns the same symbols for any text (broken too); `outline: true` (or `"open"`) adds an Outline button and a panel that follows the caret and the text, `pane.toggleOutline()`.
126
130
  - **Keyboard in YAML and Markdown.** `Enter` follows the text (see the two bullets above); in Markdown it continues `- `, `* `, `+ ` and `1. ` items (the number counts up), continues `> `, ends the list on an empty marker (one level out when it is nested), and keeps the indentation inside a code fence. `Tab` on a Markdown list item nests it one level (two spaces) and `Shift+Tab` brings it out; `Backspace` right after a marker takes the marker away. `Cmd/Ctrl+/` toggles `# ` on YAML lines and in the front matter, and wraps a Markdown line in `<!-- -->` (a slide or block options comment says it already is one). Typing `*`, `_`, `~` or `"` over a Markdown selection wraps it (`*` twice makes bold). `Cmd/Ctrl+Alt+[` and `]` fold and unfold a YAML mapping and, in Markdown, the front matter, a fenced block and a slide (a `---` inside a fence is not the end of one); the fold gutter and the chords for folding everything come with the navigation round. The squiggle, `F8`, the quick fix menu (`Cmd/Ctrl+.`) and the `Ln n, Col m` of the problems panel are at the right place in YAML and Markdown too: core's `locate` finds a field's key or value in the text (and a Markdown slide's title, list item or `layout=` token); the fixes (`Change to center`, `Rename to design`, `Remove property`, ``Add `name` ``) are text edits that keep the comments.
131
+ - **Go to definition, peek, references and rename (phase 4).** The ids of a document are symbols, in JSON, YAML and Markdown, also while the text is broken: assets (`asset:logo`, a Markdown `![](asset:logo)`), variables (`var:brand`, `{{name}}`, `{{name|format}}`), datasets and their columns, references (`cite`), slide ids (`"id"`, `id:`, `<!-- slide: id=... -->`), the beats and regions of a narrative or layout, and every catalog id (layout, theme, color scheme, font scheme, narrative, audience, purpose, tone, also as `name:id`). `F12` (and Cmd/Ctrl+click) jumps to the declaration and selects it; a record only a registered catalog has (the app's) opens a read-only card in the widget (name, description, regions, "Provided by app", and the preview from the host's `resolvePreview(kind, id)` option of the pane or the code editor, an element or an `https:` or `data:image/` address); a built-in variable (`organization.name`) or an id nothing declares says so and does not move. On a declaration `F12` lists its uses instead, as VS Code does. `Alt+F12` peeks the declaration (its lines with a line of context) in a widget under the line, with the focus in it: `Enter` goes to the definition, `Escape` closes it and returns to the editor. `Shift+F12` lists the declaration first (marked "Declaration") and then every use with its line, slide number and text: Up, Down, Home and End move, `Enter` (or a click) jumps and selects the use, `Escape` closes. `F2` opens an input at the symbol, filled with the id and selected; the name is checked as you type (not empty, the schema's pattern for that kind of id, no collision with another id of the same kind) and the message appears in the widget and is announced; `Enter` renames the declaration and every use in one transaction, so one undo restores them all, and keeps the selection on the renamed symbol. Renamable: assets, variables (a `{{name|format}}` keeps its format), datasets, references, slide ids and the ids of records embedded in `catalogs` (with the uses of a `name:id` reference of that group). Not renamable, with the reason said: a record only the app provides, a built-in variable, a dataset column, a beat or a region (they are named in their record), an id nothing declares, and anything in a read-only editor. Every command announces what it did in the live region; the widgets are named (`role`, `aria-label`), operable with the keyboard alone and return the focus to the editor; the declaration is marked with the word and an arrow, not by color alone. The widgets move with the edits above them. The work is one read of the text per edit (a big YAML text is read in pieces with the main thread given back between them): on a 1000-slide deck each command answers well under 100 ms (10 to 30 ms measured) and never holds the main thread for 50 ms.
127
132
  - **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.
128
133
  - **Canonicalize with OPF.** The old behavior of Format (the text rewritten from the document with `toYaml` or `toMarkdown`, in the order and style OPF writes) is its own button in YAML and Markdown, `pane.canonicalize()`. It drops comments, so when there are any it asks first, names how many (YAML `#` comments; Markdown `<!-- -->` comments other than slide and block options, and the YAML comments of the front matter and fences), and waits for "Canonicalize anyway" or Cancel (`Escape`); undo brings the comments back. It is one undo step.
134
+ - **Checking in a Web Worker (`worker`).** Reading the text (YAML, Markdown), the quick syntax pass of JSON and validating the document take 50 to 100 ms on a thousand slides, so with `worker` the pane does that off the main thread: typing in a 1000-slide deck never blocks the page for 50 ms (the budget is tested in Chromium and WebKit). The worker runs the same functions as the pane's own path (`source-check.js`) and hands back the deck read, the findings and the validation the session would compute, which the session accepts through `editor.applyPatch(operations, meta, { validated })` only for exactly that document and its current `catalogs` (it never lands in the undo history). The host bundles the worker script, `@openpresentation/opf-editor/source-worker` (an ES module with no exports; bundlers such as Vite, webpack and Parcel build it with `new Worker(new URL('@openpresentation/opf-editor/source-worker', import.meta.url), { type: 'module' })`; otherwise build `dist/source-worker.js` as its own entry and serve it next to the page), and passes `worker: () => new Worker(...)` (a function, a `Worker`, or `true` to try `new Worker(new URL('./source-worker.js', import.meta.url), { type: 'module' })` beside the pane's module). Without `worker` (the default) nothing changes: everything runs on the main thread. Only the typing delay (`applyDelay`) uses the worker; `getText()`, switching the format, `replaceDocument`, Format, Undo and `destroy` apply a pending edit on the main thread at once, as before. If the worker cannot start (a CSP, a wrong URL, a bundler), cannot clone a message, throws or stops answering, the pane checks on the main thread from then on, `onError` is called once, and no edit is lost. Results are identical either way (a test compares the problems, squiggles, document and validation for the same texts). `pane.whenIdle()` resolves when no edit waits for its delay and no check runs; `pane.checkerStats()` is `{ worker, inline, available }` (how the applied edits were checked); `data-opf-checking` is on the pane's root while a worker check is running. While you type faster than the worker answers, older answers are dropped and the newest text is applied when you pause.
129
135
  - **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.
130
136
  - **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.
131
137
  - **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.
package/dist/index.d.ts CHANGED
@@ -71,6 +71,16 @@ export interface EditorEvent {
71
71
  export interface EditorHistoryEntry { patches: JsonPatchOperation[]; inversePatches: JsonPatchOperation[]; meta?: Record<string, unknown> }
72
72
  export interface EditorHistory { undo: EditorHistoryEntry[]; redo: EditorHistoryEntry[] }
73
73
 
74
+ /** A validation computed ahead of an `applyPatch`: the report for `document` under `catalogs` (core's `validate` for the editor's categories, `checkFormat`). */
75
+ export interface PrecomputedValidation {
76
+ document: unknown;
77
+ validation: ValidationReport;
78
+ catalogs: readonly Catalog[];
79
+ }
80
+ export interface ApplyPatchOptions {
81
+ validated?: PrecomputedValidation;
82
+ }
83
+
74
84
  export interface EditorSession {
75
85
  paginateSlide(slideIndex: number, options?: PaginationOptions & EditorDiagnosticOptions, meta?: Record<string, unknown>): { change: EditorChange | null; pagination: PaginationResult };
76
86
  /** Composes the slide with `{{slide.number}}`, `{{slide.section}}` and `{{deck.slideCount}}` substituted for `options.slideNumber` and `options.slideCount` (default: its place in the open deck and the deck's slide count); the document keeps the tokens. */
@@ -91,7 +101,12 @@ export interface EditorSession {
91
101
  set(path: string | string[], value: unknown, meta?: Record<string, unknown>): EditorChange;
92
102
  setGroupComposition(path: string, composition: Composition, meta?: Record<string, unknown>): EditorChange;
93
103
  setCatalog(path: string | string[], catalogKind: string, id: string, meta?: Record<string, unknown>): EditorChange;
94
- applyPatch(operations: JsonPatchOperation[], meta?: Record<string, unknown>): EditorChange;
104
+ /**
105
+ * Apply JSON Patch operations as one undoable change. `options.validated` hands in a validation the caller already computed off the main
106
+ * thread (the source pane's worker): it is used only when `document` equals the patched document exactly and `catalogs` is the session's
107
+ * current `catalogs` list (the same object); otherwise the session validates the document itself. It is never stored in `meta` or the history.
108
+ */
109
+ applyPatch(operations: JsonPatchOperation[], meta?: Record<string, unknown>, options?: ApplyPatchOptions): EditorChange;
95
110
  /**
96
111
  * Open a different document in the session: the whole document is replaced as one undoable change, and the replacement is never
97
112
  * read as an edit of the catalog records the two documents share, so nothing under `catalogs.default` or a named group is forked
package/dist/index.js CHANGED
@@ -165,7 +165,14 @@ export function createEditorSession(input, options = {}) {
165
165
  for (const listener of listeners) listener({ ...event, snapshot });
166
166
  }
167
167
 
168
- function commitPatch(operations, meta = {}) {
168
+ /**
169
+ * `validated`: a report the caller already computed for the document the patches produce (`{ document, validation, catalogs }`, see `applyPatch`).
170
+ * It stands in for `check(next)` only when it is for exactly this document and the session's current catalogs; otherwise the session checks itself.
171
+ */
172
+ const validationFor = (validated, document) =>
173
+ validated && validated.validation && validated.catalogs === catalogs && jsonEqual(validated.document, document) ? validated.validation : check(document);
174
+
175
+ function commitPatch(operations, meta = {}, applyOptions = {}) {
169
176
  assertPatchOperations(operations);
170
177
  let patches = operations.map(normalizeOperation);
171
178
  const before = presentation;
@@ -192,7 +199,7 @@ export function createEditorSession(input, options = {}) {
192
199
  meta = { ...meta, forked: forks, notice: forks.map(forkNotice).join(" ") };
193
200
  }
194
201
  }
195
- const nextValidation = check(next);
202
+ const nextValidation = validationFor(applyOptions?.validated, next);
196
203
 
197
204
  if ((meta.rejectInvalid ?? rejectInvalid) && !nextValidation.valid) {
198
205
  throw new OPFEditorError("invalid-opf-edit", "OPF edit produced an invalid document.", {
@@ -55,6 +55,8 @@ export interface JsonEditorApi {
55
55
  format(): boolean;
56
56
  }
57
57
  export interface JsonCodeEditorOptions {
58
+ /** Called with the shortcuts list when the dialog is about to open; returning `true` means the host shows its own. */
59
+ onShowShortcuts?(shortcuts: import("./source-pane.js").Shortcut[]): boolean | void;
58
60
  code: string;
59
61
  label: string;
60
62
  onChange(source: string): void;
@@ -77,6 +79,11 @@ export interface JsonCodeEditorOptions {
77
79
  completions?: boolean;
78
80
  /** Enter continues a list the way the text writes it: a new string item after a string in a JSON list, the next `- ` in YAML, the next marker in Markdown. Default true; false keeps only the indentation. */
79
81
  smartListEnter?: boolean;
82
+ /**
83
+ * A preview of a catalog record for the card that F12 and Alt+F12 open on a layout, theme or other record only the app provides: an element, an `https:` or
84
+ * `data:image/` address, or a promise of either. Optional; the card shows the name, the description and the regions without it.
85
+ */
86
+ resolvePreview?: (kind: string, id: string) => HTMLElement | string | undefined | Promise<HTMLElement | string | undefined>;
80
87
  catalogs?: JsonCatalogContext;
81
88
  /** Overrides the built-in menu, allowing an existing host UI to keep its appearance. */
82
89
  onOptions?(left?: number, top?: number): void;
@@ -93,6 +100,11 @@ export interface JsonCodeEditorOptions {
93
100
  onFormat?(): void;
94
101
  /** Hover documentation over keys and catalog ids in JSON, and Cmd/Ctrl+K Cmd/Ctrl+I (default true). */
95
102
  hover?: boolean;
103
+ /**
104
+ * The shape hint: Cmd/Ctrl+Shift+Space shows the fields of the object around the caret, and typing `{` or `,` between the members of a slide that has a
105
+ * layout shows it by itself (default true; false turns the automatic one off, the key stays).
106
+ */
107
+ hints?: boolean;
96
108
  /** Called with every message the live region says (hosts mirror them into a region of their own). */
97
109
  announce?(message: string): void;
98
110
  /**
@@ -125,6 +137,14 @@ export interface JsonCodeEditor {
125
137
  triggerCompletion(): boolean;
126
138
  /** Whether a suggestion list is open or being asked for. */
127
139
  isCompleting(): boolean;
140
+ /** Every key binding this editor installed, for `platform` (default: the page's): the keyboard shortcuts list. */
141
+ getShortcuts(platform?: "mac" | "win" | "linux"): import("./source-pane.js").Shortcut[];
142
+ /** Bindings installed without a title in the list (empty unless a binding was added without a place in source-keymap.js). */
143
+ unlistedShortcuts(): string[];
144
+ /** Open the keyboard shortcuts dialog (F1, Cmd/Ctrl+K then Cmd/Ctrl+S). */
145
+ showShortcuts(): boolean;
146
+ /** Open the symbol picker (Cmd/Ctrl+Shift+O). */
147
+ showSymbols(): boolean;
128
148
  /** Perform a quick fix. */
129
149
  applyFix(fix: EditorFix, problem?: EditorProblem): void;
130
150
  setLanguage(language: "json" | "yaml" | "markdown"): void;
@@ -1,11 +1,11 @@
1
1
  import { Annotation, ChangeSet, Compartment, EditorSelection, EditorState, Prec, StateEffect, StateField, Transaction, countColumn } from "@codemirror/state";
2
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
- import { HighlightStyle, StreamLanguage, bracketMatching, codeFolding, foldKeymap, indentOnInput, indentUnit, syntaxHighlighting } from "@codemirror/language";
3
+ import { defaultKeymap, deleteCharBackward, history, historyKeymap, indentLess, indentMore, insertNewlineAndIndent, isolateHistory, redo, selectAll, selectLine, selectParentSyntax, toggleTabFocusMode, undo, undoDepth } from "@codemirror/commands";
4
+ import { HighlightStyle, StreamLanguage, bracketMatching, codeFolding, indentOnInput, indentUnit, syntaxHighlighting } from "@codemirror/language";
5
5
  import { json } from "@codemirror/lang-json";
6
6
  import { yaml } from "@codemirror/lang-yaml";
7
- import { acceptCompletion, autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, hasNextSnippetField, hasPrevSnippetField, moveCompletionSelection, nextSnippetField, prevSnippetField, startCompletion } from "@codemirror/autocomplete";
8
- import { highlightSelectionMatches, search, searchKeymap } from "@codemirror/search";
7
+ import { acceptCompletion, autocompletion, closeBrackets, closeBracketsKeymap, closeCompletion, completionStatus, currentCompletions, deleteBracketPair, hasNextSnippetField, hasPrevSnippetField, moveCompletionSelection, nextSnippetField, prevSnippetField, startCompletion } from "@codemirror/autocomplete";
8
+ import { closeSearchPanel, gotoLine, highlightSelectionMatches, openSearchPanel, search, searchKeymap } from "@codemirror/search";
9
9
  import { tags } from "@lezer/highlight";
10
10
  import { findNodeAtOffset, getNodePath, parseTree } from "jsonc-parser";
11
11
  import {jsonSource,jsonSourceHistory,setJsonSource} from './json-source.js';
@@ -15,6 +15,9 @@ import { redoPair, undoPair } from "./source-undo-groups.js";
15
15
  import {a11yExtension,createAnnouncer} from './source-a11y.js';
16
16
  import { forEachDiagnostic } from "@codemirror/lint";
17
17
  import {createDiagnostics,editsTransaction} from './source-diagnostics.js';
18
+ import { createKeymap, domKeymap, strokeText } from './source-keymap.js';
19
+ import { fold, foldEverything, foldGutterExtension, foldLevel, foldRecursively, toggleFoldHere, unfold, unfoldEverything, unfoldRecursively } from './source-folding.js';
20
+ import { hangingIndent } from './source-wrap.js';
18
21
  import {languageDataFor,markdownBackspace,markdownEnter,markdownFolding,markdownTab,markdownToggleComment,markdownWrapSelection,yamlEnter} from './source-editing.js';
19
22
  const external = Annotation.define();
20
23
  const hoverField = StateEffect.define();
@@ -148,27 +151,30 @@ function backspaceInToken(tokenOf) {
148
151
  // accepted item always replaces the whole token), Escape closes the list, Ctrl+Space (and Cmd+I on macOS) opens it wherever the schema has
149
152
  // 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)
150
153
  // 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
151
- // a line.
152
- function completionKeys(isReady, tokenOf, onStep, { newline, tab }) {
154
+ // a line. The keys come from the table in source-keymap.js.
155
+ function completionKeys(km, isReady, tokenOf, onStep, { newline, tab }) {
153
156
  // 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.
154
157
  const walk = (forward, fallback) => (view) => {
155
158
  if (forward ? hasNextSnippetField(view.state) && nextSnippetField(view) : hasPrevSnippetField(view.state) && prevSnippetField(view)) { onStep(view, forward); return true; }
156
159
  return fallback(view);
157
160
  };
158
161
  const enter = newline;
162
+ km.note("suggest.details");
163
+ km.note("snippet.next");
164
+ km.note("snippet.previous");
159
165
  return Prec.highest([releaseHeldKey, keymap.of([
160
- ...completionKeymap.filter((binding) => binding.key !== "Enter" && binding.key !== "Ctrl-Space"),
161
- { key: "Ctrl-Space", run: (view) => (listIsOpen(view.state) ? toggleDocs(view) : startCompletion(view)) },
162
- { key: "Enter", run: acceptOrFallBack(isReady, enter) },
163
- { key: "Shift-Enter", run: acceptOrFallBack(isReady, insertNewlineAndIndent) },
164
- { key: "Tab", run: acceptOrFallBack(isReady, walk(true, tab)) },
165
- { key: "Shift-Tab", run: acceptOrFallBack(isReady, walk(false, indentLess)) },
166
+ ...km.bind("suggest.dismiss", closeCompletion),
167
+ ...km.bind("suggest.next", moveCompletionSelection(true)),
168
+ ...km.bind("suggest.previous", moveCompletionSelection(false)),
169
+ ...km.bind("suggest.nextPage", moveCompletionSelection(true, "page")),
170
+ ...km.bind("suggest.previousPage", moveCompletionSelection(false, "page")),
171
+ // Ctrl+Space opens the list, or shows and hides the documentation when it is open; Cmd+I (macOS) opens it again.
172
+ ...km.bind("suggest.trigger", [(view) => (listIsOpen(view.state) ? toggleDocs(view) : startCompletion(view)), startCompletion]),
173
+ ...km.bind("suggest.accept", [acceptOrFallBack(isReady, walk(true, tab)), acceptOrFallBack(isReady, enter)]),
174
+ ...km.bind("suggest.acceptReplace", [acceptOrFallBack(isReady, walk(false, indentLess)), acceptOrFallBack(isReady, insertNewlineAndIndent)]),
166
175
  { key: "ArrowLeft", run: moveInToken(false, tokenOf) },
167
176
  { key: "ArrowRight", run: moveInToken(true, tokenOf) },
168
177
  { key: "Backspace", run: backspaceInToken(tokenOf) },
169
- { mac: "Mod-i", run: startCompletion },
170
- { mac: "Ctrl-n", run: moveCompletionSelection(true) },
171
- { mac: "Ctrl-p", run: moveCompletionSelection(false) },
172
178
  ])]);
173
179
  }
174
180
  const DEFAULT_INDENT = " ";
@@ -280,9 +286,12 @@ function skipDelimiter(view, delimiter) {
280
286
  }
281
287
  function mountJsonCodeEditor(parent, options) {
282
288
  const attributes = new Compartment(), indent = new Compartment(), language = new Compartment(), editable = new Compartment(), historyField = new Compartment();
283
- const editableExtension = (readOnly) => [EditorState.readOnly.of(Boolean(readOnly)), EditorView.editable.of(!readOnly)];
289
+ // A read-only text is not editable, so it takes the keyboard by tabindex: the keys that only read (Find, fold, the hover and the shape hint) work in it.
290
+ const editableExtension = (readOnly) => [EditorState.readOnly.of(Boolean(readOnly)), EditorView.editable.of(!readOnly), EditorView.contentAttributes.of(readOnly ? { tabindex: "0" } : {})];
284
291
  let languageName = options.language ?? "json";
285
292
  let disposed=false, menu=null, catalogs=options.catalogs??[], generation=0;
293
+ // Every key binding is declared in source-keymap.js; `km` records what this editor installs, for getShortcuts() and the shortcuts dialog.
294
+ const km = createKeymap();
286
295
  // 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,
287
296
  // and when the host replaces the text), YAML and Markdown always use two spaces. Typing never changes it.
288
297
  let jsonIndent = detectIndent(options.code) ?? DEFAULT_INDENT;
@@ -314,7 +323,6 @@ function mountJsonCodeEditor(parent, options) {
314
323
  return optionsModule.hoverAt(target.state, position, { catalogs, format: formatOf(languageName) });
315
324
  }, { hoverTime: 300 });
316
325
  // Cmd/Ctrl+K Cmd/Ctrl+I shows the hover at the caret (VS Code), and says what it holds.
317
- let chordStartedAt = 0;
318
326
  const showHoverAtCaret = (target) => {
319
327
  if (options.hover === false) return false;
320
328
  const run = () => {
@@ -341,7 +349,6 @@ function mountJsonCodeEditor(parent, options) {
341
349
  // 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.
342
350
  const undoCommand = (target) => undoPair(target) || undo(target) || Boolean(options.historyFallback?.undo?.());
343
351
  const redoCommand = (target) => redoPair(target) || redo(target) || Boolean(options.historyFallback?.redo?.());
344
- const historyBindings = historyKeymap.map((binding) => binding.run === undo ? { ...binding, run: undoCommand } : binding.run === redo ? { ...binding, run: redoCommand } : binding);
345
352
  // 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).
346
353
  // Screen reader announcements and the problems drawn in the text (squiggles, F8, Cmd/Ctrl+.). The pane supplies the problems.
347
354
  const announcer = createAnnouncer(parent, options.announce);
@@ -355,7 +362,7 @@ function mountJsonCodeEditor(parent, options) {
355
362
  say(`${fix.title} applied`);
356
363
  }
357
364
  };
358
- const diagnostics = createDiagnostics({ getSource: () => view.state.field(jsonSource), say, applyFix: applyProblemFix });
365
+ const diagnostics = createDiagnostics({ getSource: () => view.state.field(jsonSource), say, applyFix: applyProblemFix, km });
359
366
  // 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.
360
367
  let syntaxTimer = null;
361
368
  function scheduleSyntax() {
@@ -376,25 +383,143 @@ function mountJsonCodeEditor(parent, options) {
376
383
  const smart = options.smartListEnter !== false;
377
384
  const newlineCommand = (target) => (languageName === "json" ? smart && nextStringItem(target) : languageName === "yaml" ? yamlEnter(target, { lists: smart }) : markdownEnter(target, { lists: smart }));
378
385
  const tabCommandFor = (target) => (languageName === "markdown" && markdownTab(target)) || tabCommand(target);
379
- const completionExtension = options.completions ? [autocompletion({ override: [completionSource], defaultKeymap: false, icons: false, maxRenderedOptions: 60, interactionDelay: 0, activateOnTypingDelay: 0 }), completionKeys(() => optionsModule !== null, (text, position) => optionsModule?.tokenFor(formatOf(languageName), text, position), (target, forward) => optionsModule?.snippetStep(target, forward, { say }), { newline: (target) => newlineCommand(target) || insertNewlineAndIndent(target), tab: (target) => tabCommandFor(target) })] : null;
386
+ const completionExtension = options.completions ? [autocompletion({ override: [completionSource], defaultKeymap: false, icons: false, maxRenderedOptions: 60, interactionDelay: 0, activateOnTypingDelay: 0 }), completionKeys(km, () => optionsModule !== null, (text, position) => optionsModule?.tokenFor(formatOf(languageName), text, position), (target, forward) => optionsModule?.snippetStep(target, forward, { say }), { newline: (target) => newlineCommand(target) || insertNewlineAndIndent(target), tab: (target) => tabCommandFor(target) })] : null;
387
+ // Format the document: the host's command (the source pane formats every language) or, standalone, the JSON formatter.
388
+ const runFormat = () => { if (options.onFormat) options.onFormat(); else api.format(); return true; };
389
+
390
+ // ---- Features loaded on first use (go to definition and rename, the shape hint, the symbol picker, the shortcuts dialog). Each module exports commands
391
+ // `(view, context) => boolean`; the context is what the editor knows.
392
+ const lazy = (load) => { const slot = { module: null, loading: null, load: () => (slot.loading ??= load().then((module) => (slot.module = module), (error) => { slot.loading = null; throw error; })) }; return slot; };
393
+ const modules = { navigation: lazy(() => import('./source-navigation.js')), hints: lazy(() => import('./source-hints.js')), outline: lazy(() => import('./source-outline.js')), shortcuts: lazy(() => import('./source-shortcuts.js')) };
394
+ const featureContext = () => ({ format: formatOf(languageName), catalogs, say, api, options, view, platform: km.platform, getShortcuts: (platform) => km.shortcuts(platform), readOnly: view.state.readOnly });
395
+ /** A command that runs `module[name](view, context)`, loading the module the first time. The key is always taken. */
396
+ const feature = (moduleName, name, extra) => (target) => {
397
+ const slot = modules[moduleName];
398
+ const run = () => { if (!disposed) slot.module[name]?.(target, extra ? { ...featureContext(), ...extra } : featureContext()); };
399
+ if (slot.module) run(); else slot.load().then(run, (error) => options.onError?.(error));
400
+ return true;
401
+ };
402
+ // After a `{` or a `,` was typed the shape hint may open by itself (source-hints.js decides); it runs once the update that typed the character is done.
403
+ const autoHint = (target) => {
404
+ const head = target.state.selection.main.head, typed = target.state.sliceDoc(head - 1, head);
405
+ if (options.hints === false || (typed !== "{" && typed !== ",")) return;
406
+ queueMicrotask(() => {
407
+ const slot = modules.hints, run = () => { if (!disposed) slot.module.autoShapeHint?.(target, featureContext(), typed); };
408
+ if (slot.module) run(); else slot.load().then(run, (error) => options.onError?.(error));
409
+ });
410
+ };
411
+ const commentCommand = (target) => {
412
+ if (languageName === "markdown") return markdownToggleComment(target, say);
413
+ // JSON has no comments: say so instead of doing nothing (YAML and Markdown comment their lines out).
414
+ if (languageName !== "json") return false;
415
+ say("JSON has no comments. Use YAML or Markdown for notes.");
416
+ return true;
417
+ };
418
+ // Insert line above (Cmd/Ctrl+Shift+Enter): a new line with the indentation of this one, above it, with the caret on it.
419
+ const insertLineAbove = (target) => {
420
+ const { state } = target;
421
+ if (state.readOnly) return false;
422
+ const update = state.changeByRange((range) => {
423
+ const line = state.doc.lineAt(range.from), indent = /^\s*/.exec(line.text)[0];
424
+ return { changes: { from: line.from, insert: `${indent}\n` }, range: EditorSelection.cursor(line.from + indent.length) };
425
+ });
426
+ target.dispatch(state.update(update, { scrollIntoView: true, userEvent: "input" }));
427
+ return true;
428
+ };
429
+ // Expand selection (word, then each enclosing syntax node) and Shrink selection (back, one step at a time) as in VS Code.
430
+ const expansions = new WeakMap();
431
+ const expandSelection = (target) => {
432
+ const before = target.state.selection, main = before.main;
433
+ const word = main.empty ? target.state.wordAt(main.head) : null;
434
+ if (word) target.dispatch({ selection: EditorSelection.single(word.from, word.to), scrollIntoView: true, userEvent: "select.expand" });
435
+ else selectParentSyntax(target);
436
+ if (target.state.selection.eq(before)) return true;
437
+ const history = expansions.get(target) ?? [];
438
+ expansions.set(target, [...(history.length && history.at(-1).after.eq(before) ? history : []), { before, after: target.state.selection }]);
439
+ return true;
440
+ };
441
+ const shrinkSelection = (target) => {
442
+ const history = expansions.get(target);
443
+ const last = history?.at(-1);
444
+ if (!last || !last.after.eq(target.state.selection)) { say("Nothing to shrink"); return true; }
445
+ history.pop();
446
+ target.dispatch({ selection: last.before, scrollIntoView: true, userEvent: "select.shrink" });
447
+ return true;
448
+ };
449
+
450
+ // The keys VS Code reads from the event, so the physical key counts where Alt changes the character (macOS) and a lowercase letter is accepted
451
+ // with Shift held (some platforms report it): redo, the problems panel, Format, the field menu, the symbol picker and the chords of Cmd/Ctrl+K.
452
+ // A lowercase "z" with Shift can match CodeMirror's unshifted undo binding first, so redo resolves before it, even with older undo steps still available.
453
+ km.bindDom("edit.redo", (target) => { redoCommand(target); return true; });
454
+ km.bindDom("problem.panel", () => Boolean(options.onFocusProblems?.()));
455
+ km.bindDom("hover.show", (target) => showHoverAtCaret(target));
456
+ km.bindDom("format.document", () => runFormat());
457
+ km.bindDom("edit.fieldMenu", () => { if (languageName !== "json") return false; void openOptions(); return true; });
458
+ // Cmd/Ctrl+L: read from the event because a platform that reports Ctrl+Shift+L as a lowercase "l" would otherwise run it instead of Select All Occurrences.
459
+ km.bindDom("select.line", (target) => selectLine(target));
460
+ km.bindDom("nav.symbol", feature("outline", "showSymbolPicker"));
461
+ km.bindDom("help.shortcuts", feature("shortcuts", "showShortcuts"));
462
+ const saying = (run) => (target) => run(target, say);
463
+ km.bindDom("fold.toggle", saying(toggleFoldHere));
464
+ km.bindDom("fold.foldRecursively", saying(foldRecursively));
465
+ km.bindDom("fold.unfoldRecursively", saying(unfoldRecursively));
466
+ km.bindDom("fold.all", saying(foldEverything));
467
+ km.bindDom("fold.unfoldAll", saying(unfoldEverything));
468
+ for (let level = 1; level <= 7; level++) km.bindDom(`fold.level${level}`, saying(foldLevel(level)));
469
+ const keydownKeys = Prec.high(domKeymap(km, say));
470
+
471
+ // CodeMirror's own bindings, kept as they are except where the table gives a key to something else: the expand-selection key is ours, Ctrl+G goes to a
472
+ // line (Find next is F3 and, on macOS, Cmd+G), and Fold All and Unfold All are chords. Undo and Redo run CodeMirror's history, then the host's.
473
+ const kept = {
474
+ basic: defaultKeymap.filter((binding) => binding.run !== selectParentSyntax && binding.run !== selectLine),
475
+ history: historyKeymap.filter((binding) => binding.run !== undo && binding.run !== redo),
476
+ // Find next on Cmd+G is macOS only; elsewhere F3 (Ctrl+G is Go to line).
477
+ search: searchKeymap.filter((binding) => binding.run !== gotoLine).map((binding) => (binding.key === "Mod-g" ? { mac: "Mod-g", run: binding.run, shift: binding.shift, scope: binding.scope } : binding)),
478
+ };
479
+ const mainKeymap = keymap.of([
480
+ ...km.bind("edit.enter", (target) => newlineCommand(target)),
481
+ ...km.hidden([
482
+ { key: '"', run: (view2) => languageName === "json" && skipDelimiter(view2, '"') },
483
+ { key: ",", run: (view2) => languageName === "json" && skipDelimiter(view2, ",") },
484
+ { key: "Backspace", run: (target) => languageName === "markdown" && markdownBackspace(target) },
485
+ // 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
486
+ // it is also on Ctrl+Space, because the list is not there to take that key.
487
+ ...(options.completions ? [] : [{ key: "Ctrl-Space", run: () => languageName === "json" && (void openOptions(), true) }, { key: "Mod-Space", run: () => languageName === "json" && (void openOptions(), true) }]),
488
+ ]),
489
+ ...km.bind("edit.tab", (target) => tabCommandFor(target)),
490
+ ...km.bind("edit.shiftTab", indentLess),
491
+ ...km.bind("lines.comment", commentCommand),
492
+ ...km.bind("lines.insertAbove", insertLineAbove),
493
+ ...km.bind("focus.toggleTab", toggleTabFocusMode),
494
+ ...km.bind("nav.line", gotoLine),
495
+ ...km.bind("select.expand", expandSelection),
496
+ ...km.bind("select.shrink", shrinkSelection),
497
+ ...km.bind("find.replace", openSearchPanel),
498
+ ...km.bind("fold.fold", saying(fold)),
499
+ ...km.bind("fold.unfold", saying(unfold)),
500
+ ...km.bind("edit.undo", undoCommand),
501
+ ...km.bind("hint.shape", feature("hints", "showShapeHint")),
502
+ ...(km.note("hint.close"), []),
503
+ ...km.bind("nav.definition", feature("navigation", "goToDefinition")),
504
+ ...km.bind("nav.peek", feature("navigation", "peekDefinition")),
505
+ ...km.bind("nav.references", feature("navigation", "findReferences")),
506
+ ...km.bind("nav.rename", feature("navigation", "renameSymbol")),
507
+ ...km.hidden(closeBracketsKeymap),
508
+ ...km.spread(kept.basic),
509
+ ...km.spread(kept.history),
510
+ ...km.spread(kept.search),
511
+ ]);
512
+ const shortcutAttribute = (ids) => ids.flatMap((id) => km.shortcuts().find((row) => row.id === id)?.strokes.slice(0, 1) ?? []).map((stroke) => strokeText(stroke, km.platform).replace(/Cmd/g, "Meta").replace(/Ctrl/g, "Control")).join(" ");
380
513
  const contentAttributes = (invalid = options.invalid, describedBy = options.describedBy) => EditorView.contentAttributes.of({
381
514
  "aria-label": options.label,
382
515
  "aria-invalid": String(Boolean(invalid)),
383
516
  "aria-describedby": describedBy ?? "",
384
- "aria-keyshortcuts": options.completions ? "Control+Space F8 Shift+F8 Control+Period Alt+Shift+F" : "Control+Space Alt+Shift+O Alt+Shift+F",
385
- "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.",
517
+ "aria-keyshortcuts": shortcutAttribute(["suggest.trigger", "problem.next", "problem.previous", "problem.quickFix", "format.document", "nav.symbol"]),
518
+ "aria-description": options.completions ? `Press ${strokeText("Ctrl-Space", km.platform).replace("Ctrl", "Control")} for suggestions. Press F8 for the next problem. Press ${strokeText("Mod-k Mod-s", km.platform).replace(/Cmd/g, "Command").replace(/Ctrl/g, "Control")} for the keyboard shortcuts. Press Escape then Tab to leave the editor.` : "Press Escape then Tab to leave the editor.",
386
519
  spellcheck: "false",
387
520
  autocapitalize: "off",
388
521
  autocorrect: "off"
389
522
  });
390
- // Format the document: the host's command (the source pane formats every language) or, standalone, the JSON formatter.
391
- const runFormat = () => { if (options.onFormat) options.onFormat(); else api.format(); return true; };
392
- // 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.
393
- const letter = (event, name) => event.code === `Key${name.toUpperCase()}` || event.key.toLowerCase() === name;
394
- // Cmd/Ctrl+Shift+F (kept from earlier versions) and VS Code's Shift+Alt+F.
395
- const isFormatKey = (event) => event.shiftKey && !((event.metaKey || event.ctrlKey) && event.altKey) && (event.metaKey || event.ctrlKey || event.altKey) && letter(event, "f");
396
- // The field menu: Alt+Shift+O, a chord VS Code does not use.
397
- const isOptionsKey = (event) => event.altKey && event.shiftKey && !event.metaKey && !event.ctrlKey && letter(event, "o");
398
523
  const view = new EditorView({ parent, state: EditorState.create({ doc: options.code, extensions: [
399
524
  // The problem menu's keys come before the list's: the two are never open together, and Enter must reach whichever is.
400
525
  diagnostics.extension,
@@ -410,14 +535,16 @@ function mountJsonCodeEditor(parent, options) {
410
535
  indentOnInput(),
411
536
  historyField.of(history()),
412
537
  drawSelection(),
413
- // 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.
538
+ // Multiple cursors, as in VS Code: Cmd/Ctrl+D, Cmd/Ctrl+Shift+L, Cmd/Ctrl+Alt+Up and Down, Alt+click, and Shift+Alt+drag for a column.
414
539
  EditorState.allowMultipleSelections.of(true),
415
- EditorView.clickAddsSelectionRange.of((event) => event.altKey || (/Mac|iPhone|iPad/.test(navigator.platform) ? event.metaKey : event.ctrlKey)),
540
+ // Alt+click adds a cursor (VS Code's default); Cmd/Ctrl+click goes to the definition.
541
+ EditorView.clickAddsSelectionRange.of((event) => event.altKey),
416
542
  rectangularSelection({ eventFilter: (event) => event.altKey && event.shiftKey && event.button === 0 }),
417
543
  crosshairCursor(),
418
544
  bracketMatching(),
419
545
  closeBrackets(),
420
546
  codeFolding(),
547
+ options.fold === false ? [] : foldGutterExtension,
421
548
  search({ top: true }),
422
549
  highlightSelectionMatches(),
423
550
  syntaxHighlighting(highlightStyle),
@@ -425,63 +552,9 @@ function mountJsonCodeEditor(parent, options) {
425
552
  attributes.of(contentAttributes()),
426
553
  options.lineNumbers ? lineNumbers() : [],
427
554
  EditorView.lineWrapping,
428
- // Some platforms report a lowercase key even with Shift held. Handle
429
- // formatting before the standard Mod-f search binding normalizes the key.
430
- Prec.high(EditorView.domEventHandlers({ keydown(event) {
431
- // A lowercase "z" with Shift can match CodeMirror's unshifted undo
432
- // binding first. Resolve redo before that fallback, even with older
433
- // undo steps still available (otherwise it silently performs undo).
434
- if ((event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey && event.key.toLowerCase() === "z") {
435
- event.preventDefault();
436
- redoCommand(view);
437
- return true;
438
- }
439
- // 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
440
- // that starts with Mod+K would also swallow Mod+Shift+K (delete line).
441
- const mod = /Mac|iPhone|iPad/.test(navigator.platform) ? event.metaKey : event.ctrlKey;
442
- if (mod && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "k") { chordStartedAt = Date.now(); event.preventDefault(); return true; }
443
- else if (mod && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "i" && Date.now() - chordStartedAt < 2000) {
444
- chordStartedAt = 0;
445
- if (showHoverAtCaret(view)) { event.preventDefault(); return true; }
446
- }
447
- // The problems panel: Cmd+Shift+M on macOS, Ctrl+Shift+M elsewhere (read from the physical key, as the format chord is).
448
- if (mod && event.shiftKey && !event.altKey && event.code === "KeyM" && options.onFocusProblems?.()) { event.preventDefault(); return true; }
449
- if (isFormatKey(event)) {
450
- event.preventDefault();
451
- runFormat();
452
- return true;
453
- }
454
- if (languageName === "json" && isOptionsKey(event)) {
455
- event.preventDefault();
456
- void openOptions();
457
- return true;
458
- }
459
- return false;
460
- } })),
461
- keymap.of([
462
- { key: "Enter", run: (target) => newlineCommand(target) },
463
- { key: '"', run: (view2) => languageName === "json" && skipDelimiter(view2, '"') },
464
- { key: ",", run: (view2) => languageName === "json" && skipDelimiter(view2, ",") },
465
- { key: "Backspace", run: (target) => languageName === "markdown" && markdownBackspace(target) },
466
- // 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
467
- // it is also on Ctrl+Space, because the list is not there to take that key.
468
- ...(options.completions ? [] : [{ key: "Ctrl-Space", run: () => languageName === "json" && (void openOptions(), true) }, { key: "Mod-Space", run: () => languageName === "json" && (void openOptions(), true) }]),
469
- { key: "Tab", run: (target) => tabCommandFor(target), shift: indentLess },
470
- // 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.
471
- // JSON has no comments: say so instead of doing nothing (YAML and Markdown comment their lines out).
472
- { key: "Mod-/", run: (target) => {
473
- if (languageName === "markdown") return markdownToggleComment(target, say);
474
- if (languageName !== "json") return false;
475
- say("JSON has no comments. Use YAML or Markdown for notes.");
476
- return true;
477
- } },
478
- { key: "Ctrl-m", mac: "Ctrl-Shift-m", run: toggleTabFocusMode },
479
- ...closeBracketsKeymap,
480
- ...defaultKeymap,
481
- ...historyBindings,
482
- ...searchKeymap,
483
- ...foldKeymap
484
- ]),
555
+ hangingIndent,
556
+ keydownKeys,
557
+ mainKeymap,
485
558
  EditorView.domEventHandlers({
486
559
  paste(event){
487
560
  if(!event.clipboardData||view.state.selection.ranges.length!==1)return false;
@@ -491,8 +564,21 @@ function mountJsonCodeEditor(parent, options) {
491
564
  },
492
565
  focus() {
493
566
  if (options.completions) void loadOptions().catch(() => {});
567
+ if (options.hints !== false) void modules.hints.load().catch(() => {});
494
568
  options.onFocus?.();
495
569
  },
570
+ mousedown(event, target) {
571
+ // Cmd/Ctrl+click on an id goes to its definition (a click anywhere else is a plain click).
572
+ const mod = km.platform === "mac" ? event.metaKey : event.ctrlKey;
573
+ if (!mod || event.altKey || event.shiftKey || event.button !== 0 || event.detail > 1) return false;
574
+ const position = target.posAtCoords({ x: event.clientX, y: event.clientY });
575
+ if (position === null) return false;
576
+ event.preventDefault();
577
+ target.dispatch({ selection: { anchor: position } });
578
+ target.focus();
579
+ feature("navigation", "goToDefinition", { pointer: true })(target);
580
+ return true;
581
+ },
496
582
  mousemove(event) {
497
583
  options.onHoverField?.(event.target.closest("[data-field]")?.dataset.field ?? null);
498
584
  },
@@ -507,6 +593,8 @@ function mountJsonCodeEditor(parent, options) {
507
593
  if (busy !== (dom.dataset.opfBusy === "1")) { if (busy) dom.dataset.opfBusy = "1"; else delete dom.dataset.opfBusy; }
508
594
  if (update.docChanged && !update.transactions.every((tr) => tr.annotation(external))) options.onChange(update.state.field(jsonSource));
509
595
  if (update.docChanged) scheduleSyntax();
596
+ if (update.selectionSet || update.docChanged) options.onSelectionChange?.();
597
+ if (update.docChanged && update.transactions.some((tr) => tr.isUserEvent("input.type"))) autoHint(update.view);
510
598
  })
511
599
  ] }) });
512
600
  const api = {
@@ -590,6 +678,17 @@ function mountJsonCodeEditor(parent, options) {
590
678
  triggerQuickFix:()=>diagnostics.openMenu(view),
591
679
  /** Open the suggestion list at the caret (Ctrl+Space). */
592
680
  triggerCompletion:()=>startCompletion(view),
681
+ /**
682
+ * Every key binding this editor installed, for `platform` ("mac", "win" or "linux"; default: the page's): `{ id, title, group, keys, symbols, strokes,
683
+ * when? }` in the order of the shortcuts list. The same table installs the keys, so the list is the keymap.
684
+ */
685
+ getShortcuts:(platform)=>km.shortcuts(platform),
686
+ /** Bindings the editor installed that have no title in the list (empty unless a binding was added without a place in source-keymap.js). */
687
+ unlistedShortcuts:()=>km.unlisted(),
688
+ /** Open the keyboard shortcuts dialog (Cmd/Ctrl+K Cmd/Ctrl+S). */
689
+ showShortcuts:()=>feature("shortcuts","showShortcuts")(view),
690
+ /** Open the symbol picker (Cmd/Ctrl+Shift+O). */
691
+ showSymbols:()=>feature("outline","showSymbolPicker")(view),
593
692
  /** Whether a suggestion list is open or being asked for. */
594
693
  isCompleting:()=>completionStatus(view.state)!==null,
595
694
  /** Perform a fix (its edits as one undoable change, or its own action). */
@@ -140,9 +140,13 @@ export function createLayoutEditor(container, options = {}) {
140
140
  paint();
141
141
 
142
142
  // The container's width decides the layout: wide shows the palette, canvas and side panels; narrow stacks them with tabs.
143
- const measure = () => { root.dataset.compact = String((container.clientWidth || root.clientWidth || COMPACT_WIDTH) < COMPACT_WIDTH); };
143
+ const measure = () => {
144
+ const compact = String((container.clientWidth || root.clientWidth || COMPACT_WIDTH) < COMPACT_WIDTH);
145
+ if (root.dataset.compact !== compact) root.dataset.compact = compact;
146
+ };
147
+ // The switch changes the editor's own height, so a host whose height follows it would loop inside the observer: the change is made a frame later.
144
148
  let observer = null;
145
- if (typeof win.ResizeObserver === "function") { observer = new win.ResizeObserver(measure); observer.observe(container); }
149
+ if (typeof win.ResizeObserver === "function") { observer = new win.ResizeObserver(() => win.requestAnimationFrame(measure)); observer.observe(container); }
146
150
  measure();
147
151
 
148
152
  root.addEventListener("keydown", (event) => { handleGlobalShortcut(event, model); });