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