@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.
- package/README.md +10 -7
- package/dist/json-editor.d.ts +83 -4
- package/dist/json-editor.js +319 -79
- package/dist/json-format.js +54 -0
- package/dist/json-menu.js +8 -1
- package/dist/json-options.js +8 -20
- package/dist/presenter.js +33 -9
- package/dist/schema.js +4 -121
- package/dist/source-a11y.js +109 -0
- package/dist/source-completions.js +362 -0
- package/dist/source-diagnostics.js +211 -0
- package/dist/source-docs.js +87 -0
- package/dist/source-findings.js +11 -0
- package/dist/source-fixes.js +106 -0
- package/dist/source-format.js +92 -0
- package/dist/source-json.js +139 -0
- package/dist/source-messages.js +231 -0
- package/dist/source-pane.d.ts +24 -2
- package/dist/source-pane.js +238 -58
- package/dist/source-problems.js +81 -0
- package/dist/source-ranges.js +32 -0
- package/dist/source-snippets.js +93 -0
- package/dist/source-syntax.js +81 -0
- package/dist/styles.css +76 -5
- package/package.json +10 -7
package/dist/json-editor.js
CHANGED
|
@@ -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,
|
|
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
|
|
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 {
|
|
10
|
+
import { findNodeAtOffset, getNodePath, parseTree } from "jsonc-parser";
|
|
12
11
|
import {jsonSource,jsonSourceHistory,setJsonSource} from './json-source.js';
|
|
13
|
-
import {textInputMap
|
|
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(),
|
|
74
|
+
return [json(), fieldDecorations, completions ?? []];
|
|
72
75
|
}
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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] +
|
|
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
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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}
|
|
310
|
-
annotations:[Transaction.userEvent.of(
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
});
|