@openpresentation/opf-editor 0.26.0 → 0.26.2

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.
@@ -1,16 +1,19 @@
1
- import { Annotation, Compartment, EditorSelection, EditorState, Prec, StateEffect, StateField, Transaction } from "@codemirror/state";
2
- import { Decoration, EditorView, drawSelection, keymap, lineNumbers } from "@codemirror/view";
3
- import { defaultKeymap, history, historyKeymap, indentWithTab, isolateHistory, redo, undo, undoDepth } from "@codemirror/commands";
1
+ import { Annotation, ChangeSet, Compartment, EditorSelection, EditorState, Prec, StateEffect, StateField, Transaction, countColumn } from "@codemirror/state";
2
+ import { Decoration, EditorView, 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 { autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap, 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
- import { applyEdits, findNodeAtOffset, format, getNodePath, parseTree } from "jsonc-parser";
10
+ import { findNodeAtOffset, getNodePath, parseTree } from "jsonc-parser";
12
11
  import {jsonSource,jsonSourceHistory,setJsonSource} from './json-source.js';
13
- import {textInputMap,preferredLineEnding} from './text-input.js';
12
+ import {textInputMap} from './text-input.js';
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';
14
17
  const external = Annotation.define();
15
18
  const hoverField = StateEffect.define();
16
19
  const activeField = StateField.define({ create: () => null, update: (value, tr) => tr.effects.reduce((value2, effect) => effect.is(hoverField) ? effect.value : value2, value) });
@@ -68,26 +71,117 @@ const markdownLanguage = StreamLanguage.define({
68
71
  function languageExtension(name, completions) {
69
72
  if (name === "yaml") return yaml();
70
73
  if (name === "markdown") return markdownLanguage;
71
- return [json(), linter(jsonParseLinter(), { delay: 500 }), fieldDecorations, completions ?? []];
74
+ return [json(), fieldDecorations, completions ?? []];
72
75
  }
73
- // The completion popup keeps CodeMirror's keys (arrows, Enter, Escape) but not Ctrl-Space, which opens the field menu.
74
- const completionKeys = Prec.highest(keymap.of(completionKeymap.filter((binding) => binding.run !== startCompletion)));
75
- // Accept a completion by rewriting its whole token (quotes included), so a string key, a bare word and a changed value type all land
76
- // as valid JSON. A key adds ": " when none follows and then offers the values of that key.
77
- function completionApply(found, option) {
78
- return (view, completion, from, to) => {
79
- const start = found.quoted ? from - 1 : from;
80
- const end = found.closed ? to + 1 : to;
81
- let insert = JSON.stringify(option.value);
82
- const chain = found.kind === "key" && !/^\s*:/.test(view.state.sliceDoc(end, Math.min(view.state.doc.length, end + 200)));
83
- if (chain) insert += ": ";
84
- view.dispatch({ changes: { from: start, to: end, insert }, selection: { anchor: start + insert.length }, userEvent: "input.complete", scrollIntoView: true });
85
- if (chain) setTimeout(() => startCompletion(view), 0);
76
+ // Tab or Enter pressed right after a keystroke can arrive before the completion query that keystroke started has answered (with the data
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
78
+ // about to open; when no list comes, `fallback` does what the key does without a list.
79
+ function acceptOrFallBack(isReady, fallback) {
80
+ return (view) => {
81
+ if (acceptCompletion(view)) return true;
82
+ if (completionStatus(view.state) !== "pending" || !isReady()) return fallback(view);
83
+ let tries = 0;
84
+ const settle = () => {
85
+ if (!view.dom.isConnected) return;
86
+ if (completionStatus(view.state) === "pending" && ++tries < 50) { setTimeout(settle, 2); return; }
87
+ if (!acceptCompletion(view)) fallback(view);
88
+ };
89
+ setTimeout(settle, 0);
90
+ return true;
86
91
  };
87
92
  }
88
- function indentation(source) {
89
- const spaces = source.match(/^[\t ]+(?=\S)/gm) ?? [];
90
- return spaces.some((value) => value.includes(" ")) ? " " : " ".repeat(spaces.length ? Math.min(8, ...spaces.map((value) => value.length)) : 2);
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);
141
+ return Prec.highest(keymap.of([
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) },
151
+ { mac: "Mod-i", run: startCompletion },
152
+ { mac: "Ctrl-n", run: moveCompletionSelection(true) },
153
+ { mac: "Ctrl-p", run: moveCompletionSelection(false) },
154
+ ]));
155
+ }
156
+ const DEFAULT_INDENT = " ";
157
+ const tabWidth = (unit) => (unit === "\t" ? 4 : unit.length);
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
159
+ // one line; a selection over several lines, or one line in full, is indented line by line. Every selection range does the same.
160
+ function tabCommand(view) {
161
+ const { state } = view;
162
+ if (state.readOnly) return false;
163
+ const unit = state.facet(indentUnit), size = state.tabSize;
164
+ const lineWise = state.selection.ranges.some((range) => {
165
+ if (range.empty) return false;
166
+ const first = state.doc.lineAt(range.from), last = state.doc.lineAt(range.to);
167
+ return first.number !== last.number || (range.from === first.from && range.to === first.to && first.length > 0);
168
+ });
169
+ if (lineWise) {
170
+ // Indent the lines; a selection that began at the start of its line still does, so it keeps covering whole lines.
171
+ const wholeStart = state.selection.ranges.map((range) => !range.empty && range.from === state.doc.lineAt(range.from).from);
172
+ if (!indentMore(view)) return false;
173
+ const after = view.state.selection;
174
+ view.dispatch({ selection: EditorSelection.create(after.ranges.map((range, index) => (wholeStart[index] ? EditorSelection.range(view.state.doc.lineAt(range.from).from, range.to) : range)), after.mainIndex) });
175
+ return true;
176
+ }
177
+ const change = state.changeByRange((range) => {
178
+ const line = state.doc.lineAt(range.from);
179
+ const column = countColumn(state.sliceDoc(line.from, range.from), size);
180
+ const insert = unit === "\t" ? "\t" : " ".repeat(unit.length - (column % unit.length));
181
+ return { changes: { from: range.from, to: range.to, insert }, range: EditorSelection.cursor(range.from + insert.length) };
182
+ });
183
+ view.dispatch(state.update(change, { scrollIntoView: true, userEvent: "input" }));
184
+ return true;
91
185
  }
92
186
  function sourceDecorations(state) {
93
187
  const ranges = [];
@@ -139,7 +233,7 @@ function nextStringItem(view) {
139
233
  const line = state.doc.lineAt(node.offset);
140
234
  const existingIndent = /^\s*/.exec(line.text)[0];
141
235
  const parentLine = state.doc.lineAt(node.parent.offset);
142
- const indent = line.number === parentLine.number ? /^\s*/.exec(parentLine.text)[0] + indentation(source) : existingIndent;
236
+ const indent = line.number === parentLine.number ? /^\s*/.exec(parentLine.text)[0] + state.facet(indentUnit) : existingIndent;
143
237
  const inserted = `,
144
238
  ${indent}""${hasNext ? "," : ""}`;
145
239
  view.dispatch({
@@ -171,6 +265,11 @@ function mountJsonCodeEditor(parent, options) {
171
265
  const editableExtension = (readOnly) => [EditorState.readOnly.of(Boolean(readOnly)), EditorView.editable.of(!readOnly)];
172
266
  let languageName = options.language ?? "json";
173
267
  let disposed=false, menu=null, catalogs=options.catalogs??[], generation=0;
268
+ // 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,
269
+ // and when the host replaces the text), YAML and Markdown always use two spaces. Typing never changes it.
270
+ let jsonIndent = detectIndent(options.code) ?? DEFAULT_INDENT;
271
+ const unitOf = (name) => (name === "json" ? jsonIndent : DEFAULT_INDENT);
272
+ const indentExtension = (name) => [indentUnit.of(unitOf(name)), EditorState.tabSize.of(tabWidth(unitOf(name)))];
174
273
  function closeOptions(){generation++;menu?.destroy();menu=null;}
175
274
  async function openOptions(left,top){
176
275
  closeOptions();
@@ -183,43 +282,117 @@ function mountJsonCodeEditor(parent, options) {
183
282
  menu=mountJsonFieldMenu(parent,api,catalogs,left,top,()=>{menu=null;});
184
283
  }catch(error){options.onError?.(error);}
185
284
  }
186
- // Live completions (JSON only, `completions: true`): property names and values at the cursor's JSON path, from the same data as the
187
- // field menu. The data loads with the field menu's chunk on first use.
188
- async function completionSource(context) {
189
- if (disposed || languageName !== "json") return null;
190
- let module;
191
- try { module = await import('./json-options.js'); } catch (error) { options.onError?.(error); return null; }
192
- if (disposed || context.aborted) return null;
193
- const found = module.getJsonCompletions(context.state.doc.toString(), context.pos, catalogs, { explicit: context.explicit });
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.
288
+ let optionsModule = null, optionsLoading = null;
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
+ };
312
+ function completionResult(context) {
313
+ const found = optionsModule.completionResult(context.state, context.pos, { explicit: context.explicit, catalogs });
194
314
  if (!found) return null;
195
- return { from: found.from, to: found.to, filter: true, options: found.options.map((option) => ({
196
- label: option.label, detail: option.detail, info: option.info, boost: option.boost, type: found.kind === "key" ? "property" : "enum", apply: completionApply(found, option),
197
- })) };
315
+ for (const option of found.options) option.apply = applyOption;
316
+ return found;
317
+ }
318
+ function completionSource(context) {
319
+ if (disposed || languageName !== "json" || context.state.readOnly) return null;
320
+ if (optionsModule) return completionResult(context);
321
+ return loadOptions().then(() => (disposed || context.aborted ? null : completionResult(context)), (error) => { options.onError?.(error); return null; });
198
322
  }
199
- // Undo and Redo run CodeMirror's history first. When it has nothing left, the host's fallback may step further (the source pane
200
- // routes a whole-document replace to the session); a fallback answers true when it did something.
323
+ // Undo and Redo run CodeMirror's history first. When it has nothing left, the host's fallback may step further (the source pane routes a whole-document replace to the session); a fallback answers true when it did something.
201
324
  const undoCommand = (target) => undo(target) || Boolean(options.historyFallback?.undo?.());
202
325
  const redoCommand = (target) => redo(target) || Boolean(options.historyFallback?.redo?.());
203
326
  const historyBindings = historyKeymap.map((binding) => binding.run === undo ? { ...binding, run: undoCommand } : binding.run === redo ? { ...binding, run: redoCommand } : binding);
204
- const completionExtension = options.completions ? [autocompletion({ override: [completionSource], defaultKeymap: false, icons: false, maxRenderedOptions: 60 }), completionKeys] : null;
205
- let currentIndent = indentation(options.code);
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).
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;
206
358
  const contentAttributes = (invalid = options.invalid, describedBy = options.describedBy) => EditorView.contentAttributes.of({
207
359
  "aria-label": options.label,
208
360
  "aria-invalid": String(Boolean(invalid)),
209
361
  "aria-describedby": describedBy ?? "",
210
- "aria-keyshortcuts": "Control+Space Meta+Space Alt+ArrowDown",
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.",
211
364
  spellcheck: "false",
212
365
  autocapitalize: "off",
213
366
  autocorrect: "off"
214
367
  });
368
+ // Format the document: the host's command (the source pane formats every language) or, standalone, the JSON formatter.
369
+ const runFormat = () => { if (options.onFormat) options.onFormat(); else api.format(); return true; };
370
+ // 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.
371
+ const letter = (event, name) => event.code === `Key${name.toUpperCase()}` || event.key.toLowerCase() === name;
372
+ // Cmd/Ctrl+Shift+F (kept from earlier versions) and VS Code's Shift+Alt+F.
373
+ const isFormatKey = (event) => event.shiftKey && !((event.metaKey || event.ctrlKey) && event.altKey) && (event.metaKey || event.ctrlKey || event.altKey) && letter(event, "f");
374
+ // The field menu: Alt+Shift+O, a chord VS Code does not use.
375
+ const isOptionsKey = (event) => event.altKey && event.shiftKey && !event.metaKey && !event.ctrlKey && letter(event, "o");
215
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,
216
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 : "")),
217
385
  editable.of(editableExtension(options.readOnly)),
218
386
  jsonSource.init(()=>options.code),jsonSourceHistory,
219
- indent.of(indentUnit.of(currentIndent)),
387
+ indent.of(indentExtension(languageName)),
220
388
  indentOnInput(),
221
389
  historyField.of(history()),
222
390
  drawSelection(),
391
+ // 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.
392
+ EditorState.allowMultipleSelections.of(true),
393
+ EditorView.clickAddsSelectionRange.of((event) => event.altKey || (/Mac|iPhone|iPad/.test(navigator.platform) ? event.metaKey : event.ctrlKey)),
394
+ rectangularSelection({ eventFilter: (event) => event.altKey && event.shiftKey && event.button === 0 }),
395
+ crosshairCursor(),
223
396
  bracketMatching(),
224
397
  closeBrackets(),
225
398
  codeFolding(),
@@ -241,9 +414,24 @@ function mountJsonCodeEditor(parent, options) {
241
414
  redoCommand(view);
242
415
  return true;
243
416
  }
244
- if ((event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey && event.key.toLowerCase() === "f") {
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; }
427
+ if (isFormatKey(event)) {
245
428
  event.preventDefault();
246
- api.format();
429
+ runFormat();
430
+ return true;
431
+ }
432
+ if (languageName === "json" && isOptionsKey(event)) {
433
+ event.preventDefault();
434
+ void openOptions();
247
435
  return true;
248
436
  }
249
437
  return false;
@@ -252,19 +440,14 @@ function mountJsonCodeEditor(parent, options) {
252
440
  { key: "Enter", run: nextStringItem },
253
441
  { key: '"', run: (view2) => skipDelimiter(view2, '"') },
254
442
  { key: ",", run: (view2) => skipDelimiter(view2, ",") },
255
- { key: "Mod-Space", run: () => {
256
- void openOptions();
257
- return true;
258
- } },
259
- { key: "Ctrl-Space", run: () => {
260
- void openOptions();
261
- return true;
262
- } },
263
- { key: "Alt-ArrowDown", run: () => {
264
- void openOptions();
265
- return true;
266
- } },
267
- indentWithTab,
443
+ // 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
444
+ // it is also on Ctrl+Space, because the list is not there to take that key.
445
+ ...(options.completions ? [] : [{ key: "Ctrl-Space", run: () => languageName === "json" && (void openOptions(), true) }, { key: "Mod-Space", run: () => languageName === "json" && (void openOptions(), true) }]),
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 },
268
451
  ...closeBracketsKeymap,
269
452
  ...defaultKeymap,
270
453
  ...historyBindings,
@@ -272,16 +455,14 @@ function mountJsonCodeEditor(parent, options) {
272
455
  ...foldKeymap
273
456
  ]),
274
457
  EditorView.domEventHandlers({
275
- click(event) {
276
- if (view.state.selection.main.empty) void openOptions(event.clientX, event.clientY + 12);
277
- },
278
458
  paste(event){
279
459
  if(!event.clipboardData||view.state.selection.ranges.length!==1)return false;
280
460
  const text=event.clipboardData.getData('text/plain');
281
461
  if(!text)return false;
282
- event.preventDefault();api.replace(...api.getSelection(),text);return true;
462
+ event.preventDefault();api.replace(...api.getSelection(),text,{userEvent:'input.paste'});return true;
283
463
  },
284
464
  focus() {
465
+ if (options.completions) void loadOptions().catch(() => {});
285
466
  options.onFocus?.();
286
467
  },
287
468
  mousemove(event) {
@@ -293,7 +474,11 @@ function mountJsonCodeEditor(parent, options) {
293
474
  }),
294
475
  EditorView.updateListener.of((update) => {
295
476
  if(update.docChanged||update.selectionSet)closeOptions();
477
+ // Tests and hosts wait on this: a completion query is still running.
478
+ const busy = completionStatus(update.state) === "pending", dom = update.view.dom;
479
+ if (busy !== (dom.dataset.opfBusy === "1")) { if (busy) dom.dataset.opfBusy = "1"; else delete dom.dataset.opfBusy; }
296
480
  if (update.docChanged && !update.transactions.every((tr) => tr.annotation(external))) options.onChange(update.state.field(jsonSource));
481
+ if (update.docChanged) scheduleSyntax();
297
482
  })
298
483
  ] }) });
299
484
  const api = {
@@ -302,39 +487,90 @@ function mountJsonCodeEditor(parent, options) {
302
487
  hasFocus: () => view.hasFocus,
303
488
  focus: () => view.focus(),
304
489
  setSelection: (from, to=from) => {const map=textInputMap(api.getValue());view.dispatch({ selection: EditorSelection.single(map.toInput(from),map.toInput(to)), scrollIntoView: true });},
305
- replace: (from, to, text) => {
490
+ // `keepSelection` leaves the cursor where the edit maps it (a format keeps it on the same token) instead of moving it to the end of the new text.
491
+ // `userEvent` names the edit; the default is not "input.complete" or "input.type", which would run indentOnInput over the inserted lines and make
492
+ // the text differ from the source the edit declares (a pasted block ending in "}" was dropped that way).
493
+ replace: (from, to, text, { keepSelection = false, userEvent = "input.replace" } = {}) => {
306
494
  const source=api.getValue(),map=textInputMap(source),a=map.toInput(from),b=map.toInput(to);
307
495
  if(to<from||map.toSource(a)!==from||map.toSource(b)!==to)throw new RangeError('JSON edits must use source boundaries outside a CRLF pair.');
308
496
  const insert=textInputMap(text).text,next=source.slice(0,from)+text+source.slice(to);
309
- view.dispatch({changes:{from:a,to:b,insert},selection:{anchor:a+insert.length},effects:setJsonSource.of(next),
310
- annotations:[Transaction.userEvent.of('input.complete'),isolateHistory.of('full')],scrollIntoView:true});
497
+ view.dispatch({changes:{from:a,to:b,insert},...(keepSelection?{}:{selection:{anchor:a+insert.length}}),effects:setJsonSource.of(next),
498
+ annotations:[Transaction.userEvent.of(userEvent),isolateHistory.of('full')],scrollIntoView:true});
499
+ },
500
+ /** The indentation unit now in effect: a tab character or some spaces. */
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 }));
311
510
  },
312
511
  coordinates: () => {
313
512
  const rect = view.coordsAtPos(view.state.selection.main.head) ?? parent.getBoundingClientRect();
314
513
  return { left: rect.left, top: rect.bottom + 8 };
315
514
  },
316
515
  format: () => {
516
+ if (languageName !== "json") return false;
517
+ // jsonc-parser formats tokens, so it also lays out a draft that does not parse yet.
518
+ api.replaceDiff(formatJsonSource(api.getValue(), view.state.facet(indentUnit)));
519
+ return true;
520
+ },
521
+ /**
522
+ * Replace the whole text with `next` as one undoable edit. When `next` differs only in whitespace (a format), each run of whitespace that
523
+ * changed is its own change, so the cursor stays on its token; otherwise only the part that differs is replaced.
524
+ */
525
+ replaceDiff: (next) => {
317
526
  const source = api.getValue();
318
- try {
319
- JSON.parse(source);
320
- } catch {
321
- return false;
527
+ if (source === next) return;
528
+ const edits = whitespaceEdits(source, next);
529
+ if (edits) {
530
+ const map = textInputMap(source);
531
+ try {
532
+ const changes = ChangeSet.of(edits.map((edit) => ({ from: map.toInput(edit.from), to: map.toInput(edit.to), insert: textInputMap(edit.insert).text })), view.state.doc.length);
533
+ view.dispatch({ changes, selection: view.state.selection.map(changes, 1), effects: setJsonSource.of(next), annotations: [Transaction.userEvent.of("input.format"), isolateHistory.of("full")], scrollIntoView: true });
534
+ return;
535
+ } catch (error) {
536
+ // An edit that splits a CRLF pair: replace the differing middle instead.
537
+ if (!(error instanceof RangeError)) throw error;
538
+ }
322
539
  }
323
- const unit = indentation(source);
324
- const next = applyEdits(source, format(source, void 0, { insertSpaces: unit !== " ", tabSize: unit.length, eol: preferredLineEnding(source) }));
325
- if (source !== next) api.replace(0, source.length, next);
326
- return true;
540
+ let from = 0, end = source.length, nextEnd = next.length;
541
+ while (from < end && from < nextEnd && source[from] === next[from]) from++;
542
+ while (end > from && nextEnd > from && source[end - 1] === next[nextEnd - 1]) { end--; nextEnd--; }
543
+ if (source[from - 1] === "\r" && source[from] === "\n") from--;
544
+ if (source[end - 1] === "\r" && source[end] === "\n") { end++; nextEnd++; }
545
+ api.replace(from, end, next.slice(from, nextEnd), { keepSelection: true, userEvent: "input.format" });
327
546
  }
328
547
  };
329
548
  return {
330
549
  api,
331
550
  openOptions,
332
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),
333
569
  /** Switch the highlighting between "json" (with its parse linter, field highlights and option menu), "yaml" and "markdown". */
334
570
  setLanguage(name){
335
571
  if(name===languageName)return;
336
572
  languageName=name;closeOptions();
337
- view.dispatch({ effects: language.reconfigure(languageExtension(name, completionExtension)) });
573
+ view.dispatch({ effects: [language.reconfigure(languageExtension(name, completionExtension)), indent.reconfigure(indentExtension(name))] });
338
574
  },
339
575
  /** Forget the typed edits CodeMirror can undo (the text they were typed against is gone). Returns nothing; the document is unchanged. */
340
576
  resetHistory(){
@@ -350,10 +586,14 @@ function mountJsonCodeEditor(parent, options) {
350
586
  if(code!==api.getValue())closeOptions();
351
587
  const current = view.state.doc.toString(), normalized=textInputMap(code).text;
352
588
  const effects = [attributes.reconfigure(contentAttributes(invalid, describedBy)), hoverField.of(field),setJsonSource.of(code)];
353
- const nextIndent = indentation(code);
354
- if (nextIndent !== currentIndent) {
355
- currentIndent = nextIndent;
356
- effects.push(indent.reconfigure(indentUnit.of(nextIndent)));
589
+ // A different text is a different document (or a rewrite of it by the host): read its indentation again. The same text, which the host
590
+ // passes after every applied edit to refresh the attributes, keeps the unit.
591
+ if (current !== normalized) {
592
+ const detected = detectIndent(code);
593
+ if (detected && detected !== jsonIndent) {
594
+ jsonIndent = detected;
595
+ if (languageName === "json") effects.push(indent.reconfigure(indentExtension("json")));
596
+ }
357
597
  }
358
598
  if (current === normalized) {
359
599
  view.dispatch({ effects });
@@ -371,7 +611,7 @@ function mountJsonCodeEditor(parent, options) {
371
611
  annotations: [external.of(true), Transaction.addToHistory.of(false)]
372
612
  });
373
613
  },
374
- destroy: () => {if(disposed)return;disposed=true;closeOptions();view.destroy();}
614
+ destroy: () => {if(disposed)return;disposed=true;clearTimeout(syntaxTimer);closeOptions();announcer.destroy();view.destroy();}
375
615
  };
376
616
  }
377
617
  export {
@@ -0,0 +1,54 @@
1
+ import { applyEdits, format } from "jsonc-parser";
2
+ import { preferredLineEnding } from "./text-input.js";
3
+
4
+ /**
5
+ * The indentation unit of a JSON text: a tab when an indented line starts with one, else the smallest indentation found (at most 8
6
+ * spaces); null when no line is indented.
7
+ */
8
+ export function detectIndent(source) {
9
+ const spaces = source.match(/^[\t ]+(?=\S)/gm) ?? [];
10
+ if (!spaces.length) return null;
11
+ return spaces.some((value) => value.includes("\t")) ? "\t" : " ".repeat(Math.min(8, ...spaces.map((value) => value.length)));
12
+ }
13
+
14
+ /**
15
+ * The whitespace edits that lay JSON text out with `unit` (a tab or spaces) as the indentation (`{ offset, length, content }`, offsets in
16
+ * `source`): whitespace only, so values and numeric lexemes stay as written, the first line ending is kept and a final newline stays if there
17
+ * is one. jsonc-parser formats tokens, so a draft that does not parse is laid out too.
18
+ */
19
+ export function jsonFormatEdits(source, unit = " ") {
20
+ return format(source, undefined, {
21
+ insertSpaces: unit !== "\t",
22
+ tabSize: unit === "\t" ? 4 : unit.length,
23
+ eol: preferredLineEnding(source),
24
+ insertFinalNewline: /[\r\n]\s*$/.test(source),
25
+ });
26
+ }
27
+
28
+ /** `source` laid out by `jsonFormatEdits`. */
29
+ export const formatJsonSource = (source, unit = " ") => applyEdits(source, jsonFormatEdits(source, unit));
30
+
31
+ const isSpace = (char) => char === " " || char === "\t" || char === "\n" || char === "\r";
32
+ /**
33
+ * The edits (`{ from, to, insert }`, offsets in `source`) that turn `source` into `formatted` when the two differ in whitespace only, one per run
34
+ * of whitespace that changed, so a cursor between two tokens stays between them. Null when something other than whitespace differs.
35
+ */
36
+ export function whitespaceEdits(source, formatted) {
37
+ const edits = [];
38
+ let i = 0, j = 0;
39
+ while (i < source.length || j < formatted.length) {
40
+ if (isSpace(source[i]) || isSpace(formatted[j])) {
41
+ let endSource = i, endFormatted = j;
42
+ while (isSpace(source[endSource])) endSource++;
43
+ while (isSpace(formatted[endFormatted])) endFormatted++;
44
+ const before = source.slice(i, endSource), after = formatted.slice(j, endFormatted);
45
+ if (before !== after) edits.push({ from: i, to: endSource, insert: after });
46
+ i = endSource;
47
+ j = endFormatted;
48
+ } else if (source[i] === formatted[j] && i < source.length) {
49
+ i++;
50
+ j++;
51
+ } else return null;
52
+ }
53
+ return edits;
54
+ }
package/dist/json-menu.js CHANGED
@@ -7,7 +7,7 @@ export function mountJsonFieldMenu(parent,api,catalogs,left,top,onClose){
7
7
  const context=getJsonFieldContext(source,selection[0],catalogs);
8
8
  if(!context)return null;
9
9
  const coordinates=api.coordinates();left??=coordinates.left;top??=coordinates.top;
10
- const root=doc.createElement('div');root.className='json-field-menu';root.setAttribute('role','dialog');root.setAttribute('aria-label',`${context.label} options`);
10
+ const root=doc.createElement('div');root.className='json-field-menu';root.setAttribute('role','dialog');root.setAttribute('aria-modal','true');root.setAttribute('aria-label',`${context.label} options`);
11
11
  root.style.cssText='position:fixed;z-index:10000;width:380px;max-width:calc(100vw - 16px);max-height:calc(100vh - 16px);overflow:auto;box-sizing:border-box;padding:12px;background:Canvas;color:CanvasText;border:1px solid GrayText;border-radius:8px;font:13px system-ui;box-shadow:0 8px 28px #0003';
12
12
  const add=(tag,text,className,container=root)=>{const node=doc.createElement(tag);if(text!==undefined)node.textContent=text;if(className)node.className=className;container.append(node);return node;};
13
13
  const heading=add('div',undefined,'json-field-heading');add('strong',context.label,undefined,heading);
@@ -59,6 +59,13 @@ export function mountJsonFieldMenu(parent,api,catalogs,left,top,onClose){
59
59
  search.addEventListener('input',()=>{selected=0;render();});
60
60
  root.addEventListener('keydown',event=>{
61
61
  if(event.key==='Escape'){event.preventDefault();event.stopPropagation();close(true);}
62
+ // Tab walks the dialog's two controls and wraps; the options are reached with the arrow keys from the search field. Focus never leaves
63
+ // the dialog by Tab, so it cannot be left open behind the page.
64
+ else if(event.key==='Tab'){
65
+ event.preventDefault();event.stopPropagation();
66
+ const stops=[closeButton,search],at=stops.indexOf(doc.activeElement);
67
+ stops[(at+(event.shiftKey?-1:1)+stops.length)%stops.length].focus();
68
+ }
62
69
  else if(event.target===search&&['ArrowDown','ArrowUp'].includes(event.key)){event.preventDefault();selected=Math.max(0,Math.min(choices.length-1,selected+(event.key==='ArrowDown'?1:-1)));highlight();}
63
70
  else if(event.target===search&&event.key==='Enter'&&choices[selected]){event.preventDefault();choose(choices[selected]);}
64
71
  });