@openpresentation/opf-editor 0.24.1 → 0.24.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 +3 -1
- package/dist/json-editor.d.ts +2 -0
- package/dist/json-editor.js +34 -5
- package/dist/json-options.d.ts +21 -0
- package/dist/json-options.js +127 -27
- package/dist/source-findings.js +27 -0
- package/dist/source-pane.d.ts +2 -0
- package/dist/source-pane.js +28 -23
- package/dist/styles.css +45 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -104,6 +104,7 @@ const pane = createSourcePane(container, {
|
|
|
104
104
|
onDownload: file => saveAs(file.filename, file.text), // default: a Blob link named <name>.opf.<ext>
|
|
105
105
|
density: 'comfortable', // 'touch' makes the controls 40px
|
|
106
106
|
readOnly: false,
|
|
107
|
+
completions: true, // live property and value suggestions while typing JSON (default true)
|
|
107
108
|
});
|
|
108
109
|
pane.setFormat('yaml'); pane.focusPath('/slides/1/title'); pane.getText(); pane.destroy();
|
|
109
110
|
```
|
|
@@ -113,7 +114,8 @@ Methods: `setFindings(list | null)`, `setFormat(format)`, `focusPath(pointer)` (
|
|
|
113
114
|
- **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.
|
|
114
115
|
- **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.
|
|
115
116
|
- **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: Format and outside edits drop them and say how many. A document that is not valid OPF cannot be written as YAML or Markdown; the pane stays in JSON and says so.
|
|
116
|
-
- **
|
|
117
|
+
- **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 accepts, Escape closes, and Ctrl+Space or Alt+ArrowDown still open the field menu. YAML and Markdown keep the menu only. `mountJsonCodeEditor` takes the same `completions` option, off by default there.
|
|
118
|
+
- **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.
|
|
117
119
|
- **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.
|
|
118
120
|
- **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.
|
|
119
121
|
|
package/dist/json-editor.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface JsonCodeEditorOptions {
|
|
|
23
23
|
/** Highlighting and behavior: "json" (default) adds the parse linter, field highlights and the option menu. */
|
|
24
24
|
language?: "json" | "yaml" | "markdown";
|
|
25
25
|
readOnly?: boolean;
|
|
26
|
+
/** Live completion popup in JSON: property names and enum, boolean and catalog values at the cursor's JSON path, from the same data as the field menu (default false here; the source pane turns it on). Ctrl+Space still opens the field menu. */
|
|
27
|
+
completions?: boolean;
|
|
26
28
|
catalogs?: JsonCatalogContext;
|
|
27
29
|
/** Overrides the built-in menu, allowing an existing host UI to keep its appearance. */
|
|
28
30
|
onOptions?(left?: number, top?: number): void;
|
package/dist/json-editor.js
CHANGED
|
@@ -4,7 +4,7 @@ import { defaultKeymap, history, historyKeymap, indentWithTab, isolateHistory, r
|
|
|
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 { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
|
|
7
|
+
import { autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap, 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";
|
|
@@ -65,10 +65,25 @@ const markdownLanguage = StreamLanguage.define({
|
|
|
65
65
|
return null;
|
|
66
66
|
}
|
|
67
67
|
});
|
|
68
|
-
function languageExtension(name) {
|
|
68
|
+
function languageExtension(name, completions) {
|
|
69
69
|
if (name === "yaml") return yaml();
|
|
70
70
|
if (name === "markdown") return markdownLanguage;
|
|
71
|
-
return [json(), linter(jsonParseLinter(), { delay: 500 }), fieldDecorations];
|
|
71
|
+
return [json(), linter(jsonParseLinter(), { delay: 500 }), fieldDecorations, completions ?? []];
|
|
72
|
+
}
|
|
73
|
+
// The completion popup keeps CodeMirror's keys (arrows, Enter, Escape) but not Ctrl-Space, which opens the field menu.
|
|
74
|
+
const completionKeys = Prec.highest(keymap.of(completionKeymap.filter((binding) => binding.run !== startCompletion)));
|
|
75
|
+
// Accept a completion by rewriting its whole token (quotes included), so a string key, a bare word and a changed value type all land
|
|
76
|
+
// as valid JSON. A key adds ": " when none follows and then offers the values of that key.
|
|
77
|
+
function completionApply(found, option) {
|
|
78
|
+
return (view, completion, from, to) => {
|
|
79
|
+
const start = found.quoted ? from - 1 : from;
|
|
80
|
+
const end = found.closed ? to + 1 : to;
|
|
81
|
+
let insert = JSON.stringify(option.value);
|
|
82
|
+
const chain = found.kind === "key" && !/^\s*:/.test(view.state.sliceDoc(end, Math.min(view.state.doc.length, end + 200)));
|
|
83
|
+
if (chain) insert += ": ";
|
|
84
|
+
view.dispatch({ changes: { from: start, to: end, insert }, selection: { anchor: start + insert.length }, userEvent: "input.complete", scrollIntoView: true });
|
|
85
|
+
if (chain) setTimeout(() => startCompletion(view), 0);
|
|
86
|
+
};
|
|
72
87
|
}
|
|
73
88
|
function indentation(source) {
|
|
74
89
|
const spaces = source.match(/^[\t ]+(?=\S)/gm) ?? [];
|
|
@@ -168,6 +183,20 @@ function mountJsonCodeEditor(parent, options) {
|
|
|
168
183
|
menu=mountJsonFieldMenu(parent,api,catalogs,left,top,()=>{menu=null;});
|
|
169
184
|
}catch(error){options.onError?.(error);}
|
|
170
185
|
}
|
|
186
|
+
// Live completions (JSON only, `completions: true`): property names and values at the cursor's JSON path, from the same data as the
|
|
187
|
+
// field menu. The data loads with the field menu's chunk on first use.
|
|
188
|
+
async function completionSource(context) {
|
|
189
|
+
if (disposed || languageName !== "json") return null;
|
|
190
|
+
let module;
|
|
191
|
+
try { module = await import('./json-options.js'); } catch (error) { options.onError?.(error); return null; }
|
|
192
|
+
if (disposed || context.aborted) return null;
|
|
193
|
+
const found = module.getJsonCompletions(context.state.doc.toString(), context.pos, catalogs, { explicit: context.explicit });
|
|
194
|
+
if (!found) return null;
|
|
195
|
+
return { from: found.from, to: found.to, filter: true, options: found.options.map((option) => ({
|
|
196
|
+
label: option.label, detail: option.detail, info: option.info, boost: option.boost, type: found.kind === "key" ? "property" : "enum", apply: completionApply(found, option),
|
|
197
|
+
})) };
|
|
198
|
+
}
|
|
199
|
+
const completionExtension = options.completions ? [autocompletion({ override: [completionSource], defaultKeymap: false, icons: false, maxRenderedOptions: 60 }), completionKeys] : null;
|
|
171
200
|
let currentIndent = indentation(options.code);
|
|
172
201
|
const contentAttributes = (invalid = options.invalid, describedBy = options.describedBy) => EditorView.contentAttributes.of({
|
|
173
202
|
"aria-label": options.label,
|
|
@@ -179,7 +208,7 @@ function mountJsonCodeEditor(parent, options) {
|
|
|
179
208
|
autocorrect: "off"
|
|
180
209
|
});
|
|
181
210
|
const view = new EditorView({ parent, state: EditorState.create({ doc: options.code, extensions: [
|
|
182
|
-
language.of(languageExtension(languageName)),
|
|
211
|
+
language.of(languageExtension(languageName, completionExtension)),
|
|
183
212
|
editable.of(editableExtension(options.readOnly)),
|
|
184
213
|
jsonSource.init(()=>options.code),jsonSourceHistory,
|
|
185
214
|
indent.of(indentUnit.of(currentIndent)),
|
|
@@ -300,7 +329,7 @@ function mountJsonCodeEditor(parent, options) {
|
|
|
300
329
|
setLanguage(name){
|
|
301
330
|
if(name===languageName)return;
|
|
302
331
|
languageName=name;closeOptions();
|
|
303
|
-
view.dispatch({ effects: language.reconfigure(languageExtension(name)) });
|
|
332
|
+
view.dispatch({ effects: language.reconfigure(languageExtension(name, completionExtension)) });
|
|
304
333
|
},
|
|
305
334
|
setReadOnly(value){view.dispatch({ effects: editable.reconfigure(editableExtension(value)) });},
|
|
306
335
|
/** The CodeMirror view, for hosts that scroll or measure it. */
|
package/dist/json-options.d.ts
CHANGED
|
@@ -31,3 +31,24 @@ export declare function getJsonFieldContext(source: string, position: number, lo
|
|
|
31
31
|
export declare function replaceFieldOption(context: JsonFieldContext, value: JsonOptionValue): string;
|
|
32
32
|
/** Minimal single replacement for atomic source-editor undo. */
|
|
33
33
|
export declare function fieldOptionEdit(context: JsonFieldContext, value: JsonOptionValue): {from:number;to:number;insert:string};
|
|
34
|
+
export interface JsonCompletionOption {
|
|
35
|
+
/** A key option's property name, or the JSON value to write. */
|
|
36
|
+
value: JsonOptionValue;
|
|
37
|
+
label: string;
|
|
38
|
+
detail?: string;
|
|
39
|
+
info?: string;
|
|
40
|
+
/** CodeMirror ranking, -99 to 99. */
|
|
41
|
+
boost: number;
|
|
42
|
+
}
|
|
43
|
+
export interface JsonCompletions {
|
|
44
|
+
kind: 'key' | 'value';
|
|
45
|
+
/** The text being typed: inside the quotes of a string, or the bare word. */
|
|
46
|
+
from: number;
|
|
47
|
+
to: number;
|
|
48
|
+
quoted: boolean;
|
|
49
|
+
/** Whether a closing quote follows the typed text. */
|
|
50
|
+
closed: boolean;
|
|
51
|
+
options: JsonCompletionOption[];
|
|
52
|
+
}
|
|
53
|
+
/** The property names and values to offer at `position` of a JSON draft (it need not parse); null when there are none. `explicit` also offers choices where nothing is typed yet. */
|
|
54
|
+
export declare function getJsonCompletions(source: string, position: number, loadedCatalogs?: JsonCatalogContext, options?: { explicit?: boolean }): JsonCompletions | null;
|
package/dist/json-options.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { checkFormat, firstErrorMessage } from "./checks.js";
|
|
2
2
|
import { listCatalogRecords } from "./catalogs.js";
|
|
3
|
-
import { schemaAtPath, schemaVariants } from "./schema.js";
|
|
4
|
-
import { findNodeAtOffset, getNodePath, parseTree } from "jsonc-parser";
|
|
3
|
+
import { resolveSchema, schemaAtPath, schemaVariants } from "./schema.js";
|
|
4
|
+
import { findNodeAtOffset, getNodePath, parse as parseJsonc, parseTree } from "jsonc-parser";
|
|
5
5
|
import { LAYOUT_INTENT_GROUPS, layoutRegionSummary } from "./layout-templates.js";
|
|
6
6
|
// The catalog kind a field references (OPF 0.15: every content kind is `id` or `name:id`), read from where the field sits.
|
|
7
7
|
const ROOT_REFERENCES = { narrative: "narratives", audience: "audiences", purpose: "purposes", tone: "tones" };
|
|
@@ -39,29 +39,9 @@ function describeOptions(choices, records, current, catalog) {
|
|
|
39
39
|
const priority = option => option.source === "Document catalog" ? 2 : option.source === "Loaded catalog" ? 1 : 0;
|
|
40
40
|
return choices.sort((a, b) => (catalog === "layouts" ? layoutGroups.indexOf(a.layoutGroup) - layoutGroups.indexOf(b.layoutGroup) : priority(b) - priority(a)) || a.label.localeCompare(b.label, "en", {numeric: true}) || String(a.value).localeCompare(String(b.value), "en"));
|
|
41
41
|
}
|
|
42
|
-
// The
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
let presentation;
|
|
46
|
-
try {
|
|
47
|
-
presentation = JSON.parse(source);
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
const root = parseTree(source);
|
|
53
|
-
if (!root)
|
|
54
|
-
return null;
|
|
55
|
-
let node = findNodeAtOffset(root, position, true);
|
|
56
|
-
if (node?.parent?.type === "property" && node.parent.children?.[0] === node)
|
|
57
|
-
node = node.parent.children[1];
|
|
58
|
-
if (node?.type === "object")
|
|
59
|
-
node = node.children?.find(property => property.children?.[0]?.value === "id")?.children?.[1];
|
|
60
|
-
if (!node || !["string", "number", "boolean"].includes(node.type))
|
|
61
|
-
return null;
|
|
62
|
-
const path = getNodePath(node);
|
|
63
|
-
if (!path.length)
|
|
64
|
-
return null;
|
|
42
|
+
// The choices a field offers, shared by the field menu and live completions: catalog records for a reference field (when the
|
|
43
|
+
// value is a string), schema enum values and booleans otherwise.
|
|
44
|
+
function fieldOptions(presentation, path, loadedCatalogs, { string, boolean }) {
|
|
65
45
|
const schema = schemaAtPath(presentation, path.map(String));
|
|
66
46
|
const variants = schemaVariants(schema);
|
|
67
47
|
const catalog = catalogAtPath(path);
|
|
@@ -70,7 +50,7 @@ export function getJsonFieldContext(source, position, loadedCatalogs = []) {
|
|
|
70
50
|
// A document group whose source no registered catalog matches: its records beyond the embedded ones are not available here.
|
|
71
51
|
const registered = new Set((Array.isArray(loadedCatalogs) ? loadedCatalogs : []).map(entry => entry?.source));
|
|
72
52
|
const unloadedSource = Boolean(catalog) && Object.entries(presentation.catalogs ?? {}).some(([name, group]) => name !== "custom" && group && typeof group === "object" && typeof group.source === "string" && !registered.has(group.source));
|
|
73
|
-
if (catalog &&
|
|
53
|
+
if (catalog && string) {
|
|
74
54
|
const hostCatalogs = Array.isArray(loadedCatalogs) ? loadedCatalogs : [];
|
|
75
55
|
// A draft whose own catalogs are malformed still offers the host's records; a malformed host list offers nothing.
|
|
76
56
|
const attempt = (document) => { try { return listCatalogRecords(document, catalog, { catalogs: hostCatalogs }); } catch { return undefined; } };
|
|
@@ -87,11 +67,37 @@ export function getJsonFieldContext(source, position, loadedCatalogs = []) {
|
|
|
87
67
|
if (["string", "number", "boolean"].includes(typeof value))
|
|
88
68
|
options.set(JSON.stringify(value), { value, label: String(value), source: "Schema" });
|
|
89
69
|
}
|
|
90
|
-
if (
|
|
70
|
+
if (boolean && variants.some(variant => variant.type === "boolean" && variant.const === undefined)) {
|
|
91
71
|
for (const value of [true, false])
|
|
92
72
|
options.set(String(value), { value, label: String(value), source: "Schema" });
|
|
93
73
|
}
|
|
94
74
|
}
|
|
75
|
+
return { options, records, catalog, unloadedSource };
|
|
76
|
+
}
|
|
77
|
+
// The installed schema identifies fields; a document field merely named "layout"
|
|
78
|
+
// inside arbitrary metadata is not mistaken for an OPF layout reference.
|
|
79
|
+
export function getJsonFieldContext(source, position, loadedCatalogs = []) {
|
|
80
|
+
let presentation;
|
|
81
|
+
try {
|
|
82
|
+
presentation = JSON.parse(source);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const root = parseTree(source);
|
|
88
|
+
if (!root)
|
|
89
|
+
return null;
|
|
90
|
+
let node = findNodeAtOffset(root, position, true);
|
|
91
|
+
if (node?.parent?.type === "property" && node.parent.children?.[0] === node)
|
|
92
|
+
node = node.parent.children[1];
|
|
93
|
+
if (node?.type === "object")
|
|
94
|
+
node = node.children?.find(property => property.children?.[0]?.value === "id")?.children?.[1];
|
|
95
|
+
if (!node || !["string", "number", "boolean"].includes(node.type))
|
|
96
|
+
return null;
|
|
97
|
+
const path = getNodePath(node);
|
|
98
|
+
if (!path.length)
|
|
99
|
+
return null;
|
|
100
|
+
const { options, records, catalog, unloadedSource } = fieldOptions(presentation, path, loadedCatalogs, { string: typeof node.value === "string", boolean: node.type === "boolean" });
|
|
95
101
|
if (!options.size)
|
|
96
102
|
return null;
|
|
97
103
|
const choices = [...options.values()];
|
|
@@ -122,3 +128,97 @@ export function fieldOptionEdit(context, value) {
|
|
|
122
128
|
while (to > from && end > from && source[to - 1] === next[end - 1]) { to--; end--; }
|
|
123
129
|
return {from, to, insert: next.slice(from, end)};
|
|
124
130
|
}
|
|
131
|
+
|
|
132
|
+
// ---- Live completions ----------------------------------------------------------------------------------------------------------
|
|
133
|
+
// What the editor offers while you type: the property names the schema allows at the cursor's object, and the same enum, boolean and
|
|
134
|
+
// catalog choices the field menu lists at a value. The draft is usually not valid JSON mid-keystroke, so the token under the cursor
|
|
135
|
+
// is swapped for a placeholder string and the rest is read with the tolerant parser.
|
|
136
|
+
const PLACEHOLDER = "__opf_completion__";
|
|
137
|
+
const WORD = /[A-Za-z0-9_.-]/;
|
|
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
|
+
function tokenAt(text, position, explicit) {
|
|
140
|
+
const lineStart = Math.max(text.lastIndexOf("\n", position - 1), text.lastIndexOf("\r", position - 1)) + 1;
|
|
141
|
+
let open = -1;
|
|
142
|
+
for (let index = lineStart; index < position; index++) {
|
|
143
|
+
if (open >= 0) { if (text[index] === "\\") index++; else if (text[index] === '"') open = -1; }
|
|
144
|
+
else if (text[index] === '"') open = index;
|
|
145
|
+
}
|
|
146
|
+
if (open >= 0) {
|
|
147
|
+
let close = -1;
|
|
148
|
+
for (let index = position; index < text.length && text[index] !== "\n" && text[index] !== "\r"; index++) {
|
|
149
|
+
if (text[index] === "\\") index++;
|
|
150
|
+
else if (text[index] === '"') { close = index; break; }
|
|
151
|
+
}
|
|
152
|
+
return { quoted: true, closed: close >= 0, start: open, end: close >= 0 ? close + 1 : position, from: open + 1, to: close >= 0 ? close : position };
|
|
153
|
+
}
|
|
154
|
+
let start = position, end = position;
|
|
155
|
+
while (start > lineStart && WORD.test(text[start - 1])) start--;
|
|
156
|
+
while (end < text.length && WORD.test(text[end])) end++;
|
|
157
|
+
if (start === position && !explicit) return null;
|
|
158
|
+
let before = start - 1;
|
|
159
|
+
while (before >= lineStart && /\s/.test(text[before])) before--;
|
|
160
|
+
// A bare word starts a key or a value only after `{`, `,`, `:` or `[` (or at the start of an empty line).
|
|
161
|
+
const previous = before >= lineStart ? text[before] : (text.slice(0, lineStart).trimEnd().at(-1) ?? "");
|
|
162
|
+
if (!["{", ",", ":", "["].includes(previous)) return null;
|
|
163
|
+
return { quoted: false, closed: false, start, end, from: start, to: end };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Schema descriptions run to paragraphs; the popup shows the first sentence, at most 200 characters.
|
|
167
|
+
function brief(text) {
|
|
168
|
+
if (typeof text !== "string" || !text.trim()) return undefined;
|
|
169
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
170
|
+
const sentence = /^.*?[.!?](?=\s|$)/.exec(flat)?.[0] ?? flat;
|
|
171
|
+
return sentence.length <= 200 ? sentence : `${sentence.slice(0, 199).trimEnd()}…`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The completions at `position` of a JSON draft (it need not parse): `{ kind: "key" | "value", from, to, quoted, closed, options }`,
|
|
176
|
+
* or null when nothing is offered. `from` and `to` bound the text being typed (inside the quotes of a string); each option is
|
|
177
|
+
* `{ value, label, detail?, info?, boost }`. A key option's `value` is the property name; a value option's is the JSON value.
|
|
178
|
+
* `explicit` also offers choices where nothing has been typed yet.
|
|
179
|
+
*/
|
|
180
|
+
export function getJsonCompletions(source, position, loadedCatalogs = [], { explicit = false } = {}) {
|
|
181
|
+
const token = tokenAt(source, position, explicit);
|
|
182
|
+
if (!token) return null;
|
|
183
|
+
const draft = source.slice(0, token.start) + JSON.stringify(PLACEHOLDER) + source.slice(token.end);
|
|
184
|
+
const root = parseTree(draft, [], { allowTrailingComma: true });
|
|
185
|
+
if (!root) return null;
|
|
186
|
+
const node = findNodeAtOffset(root, token.start + 1, true);
|
|
187
|
+
if (node?.type !== "string" || node.value !== PLACEHOLDER) return null;
|
|
188
|
+
const presentation = parseJsonc(draft, [], { allowTrailingComma: true });
|
|
189
|
+
if (presentation === null || typeof presentation !== "object" || Array.isArray(presentation)) return null;
|
|
190
|
+
const range = { from: token.from, to: token.to, quoted: token.quoted, closed: token.closed };
|
|
191
|
+
try {
|
|
192
|
+
if (node.parent?.type === "property" && node.parent.children?.[0] === node) {
|
|
193
|
+
const object = node.parent.parent;
|
|
194
|
+
const present = new Set((object?.children ?? []).filter(property => property !== node.parent).map(property => property.children?.[0]?.value));
|
|
195
|
+
const properties = new Map();
|
|
196
|
+
for (const variant of schemaVariants(schemaAtPath(presentation, getNodePath(object).map(String))))
|
|
197
|
+
for (const [key, child] of Object.entries(variant.properties ?? {}))
|
|
198
|
+
if (!present.has(key) && !properties.has(key)) properties.set(key, { child, required: (variant.required ?? []).includes(key) });
|
|
199
|
+
if (!properties.size) return null;
|
|
200
|
+
const options = [...properties].map(([key, { child, required }], index) => {
|
|
201
|
+
const resolved = resolveSchema(child);
|
|
202
|
+
const type = Array.isArray(resolved.type) ? resolved.type.join(" | ") : resolved.type ?? (resolved.enum ? "enum" : resolved.oneOf || resolved.anyOf ? "choice" : "");
|
|
203
|
+
return { value: key, label: key, detail: type || undefined, info: brief(child.description ?? resolved.description), boost: Math.max(-99, (required ? 50 : 0) - index) };
|
|
204
|
+
});
|
|
205
|
+
return { kind: "key", ...range, options };
|
|
206
|
+
}
|
|
207
|
+
const path = getNodePath(node);
|
|
208
|
+
if (!path.length) return null;
|
|
209
|
+
const { options: found, records, catalog } = fieldOptions(presentation, path, loadedCatalogs, { string: true, boolean: true });
|
|
210
|
+
if (!found.size) return null;
|
|
211
|
+
const choices = describeOptions([...found.values()], records, undefined, catalog);
|
|
212
|
+
const options = choices.map((option, index) => ({
|
|
213
|
+
value: option.value,
|
|
214
|
+
label: String(option.value),
|
|
215
|
+
detail: String(option.label) !== String(option.value) ? String(option.label) : option.sourceLabel,
|
|
216
|
+
info: [brief(option.description), option.regions].filter(Boolean).join("\n") || undefined,
|
|
217
|
+
boost: Math.max(-99, 50 - index),
|
|
218
|
+
}));
|
|
219
|
+
return { kind: "value", ...range, options };
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Findings of the source pane (RR-98), kept apart from the DOM so they can be tested alone.
|
|
2
|
+
|
|
3
|
+
/** Normalize one finding: core `Finding` objects, `{ path, message, severity, line? }` objects and plain strings. */
|
|
4
|
+
export function normalizeFinding(input, kind) {
|
|
5
|
+
const finding = typeof input === "string" ? { message: input } : input ?? {};
|
|
6
|
+
const severity = finding.severity === "error" || finding.severity === "info" ? finding.severity : finding.severity === "warning" || finding.severity === "warn" ? "warning" : "error";
|
|
7
|
+
const out = { path: String(finding.path ?? ""), message: String(finding.message ?? ""), severity };
|
|
8
|
+
const line = finding.line ?? finding.location?.line;
|
|
9
|
+
if (Number.isInteger(line) && line > 0) out.line = line;
|
|
10
|
+
const own = finding.kind ?? kind;
|
|
11
|
+
if (own) out.kind = own;
|
|
12
|
+
if (finding.ruleId) out.ruleId = finding.ruleId;
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The text of the first problem an error reports. */
|
|
17
|
+
export const errorText = (error) => error?.issues?.[0]?.message ?? error?.message ?? String(error);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The findings for an edit the session refused: one per issue of the thrown error (its validator findings, each with its JSON pointer
|
|
21
|
+
* `path`), so the list has the validator's count. An error without issues becomes one finding with its message.
|
|
22
|
+
*/
|
|
23
|
+
export function rejectionFindings(error) {
|
|
24
|
+
const issues = Array.isArray(error?.issues) ? error.issues.filter((issue) => issue && (typeof issue === "string" || typeof issue === "object")) : [];
|
|
25
|
+
if (!issues.length) return [normalizeFinding({ message: errorText(error) }, "rejected")];
|
|
26
|
+
return issues.map((issue) => normalizeFinding(typeof issue === "string" ? issue : { ...issue, severity: issue.severity ?? "error", kind: undefined }, "rejected"));
|
|
27
|
+
}
|
package/dist/source-pane.d.ts
CHANGED
|
@@ -59,6 +59,8 @@ 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. YAML and Markdown keep the menu only. Default true. */
|
|
63
|
+
completions?: boolean;
|
|
62
64
|
onError?(error: unknown): void;
|
|
63
65
|
}
|
|
64
66
|
|
package/dist/source-pane.js
CHANGED
|
@@ -4,6 +4,7 @@ import { fromMarkdown, toMarkdown } from "@openpresentation/opf/markdown";
|
|
|
4
4
|
import { mountJsonCodeEditor } from "./json-editor.js";
|
|
5
5
|
import { createSourceMemory, findDuplicateKey, updateJsonSource } from "./exact-source.js";
|
|
6
6
|
import { lineForPath, lineOfOffset, offsetOfLine } from "./source-lines.js";
|
|
7
|
+
import { errorText, normalizeFinding, rejectionFindings as findingsOfError } from "./source-findings.js";
|
|
7
8
|
|
|
8
9
|
// The source pane (RR-98): the OPF document as JSON, YAML or Markdown, with the file name, the format menu, Format, Copy
|
|
9
10
|
// and Download, and the findings under the code. JSON is canonical and keeps its authored bytes (exact-source); YAML and
|
|
@@ -65,20 +66,6 @@ function same(a, b) {
|
|
|
65
66
|
}
|
|
66
67
|
const SEVERITY = { error: 0, warning: 1, info: 2 };
|
|
67
68
|
const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
68
|
-
const errorText = (error) => error?.issues?.[0]?.message ?? error?.message ?? String(error);
|
|
69
|
-
|
|
70
|
-
/** Normalize one finding: core `Finding` objects, `{ path, message, severity, line? }` objects and plain strings. */
|
|
71
|
-
function normalizeFinding(input, kind) {
|
|
72
|
-
const finding = typeof input === "string" ? { message: input } : input ?? {};
|
|
73
|
-
const severity = finding.severity === "error" || finding.severity === "info" ? finding.severity : finding.severity === "warning" || finding.severity === "warn" ? "warning" : "error";
|
|
74
|
-
const out = { path: String(finding.path ?? ""), message: String(finding.message ?? ""), severity };
|
|
75
|
-
const line = finding.line ?? finding.location?.line;
|
|
76
|
-
if (Number.isInteger(line) && line > 0) out.line = line;
|
|
77
|
-
const own = finding.kind ?? kind;
|
|
78
|
-
if (own) out.kind = own;
|
|
79
|
-
if (finding.ruleId) out.ruleId = finding.ruleId;
|
|
80
|
-
return out;
|
|
81
|
-
}
|
|
82
69
|
|
|
83
70
|
const JSON_ERRORS = { InvalidSymbol: "Invalid symbol", InvalidNumberFormat: "Invalid number", PropertyNameExpected: "Property name expected", ValueExpected: "Value expected", ColonExpected: "Colon expected", CommaExpected: "Comma expected", CloseBraceExpected: "Closing brace expected", CloseBracketExpected: "Closing bracket expected", EndOfFileExpected: "End of file expected", InvalidCommentToken: "Comments are not allowed in JSON", UnexpectedEndOfComment: "Unexpected end of comment", UnexpectedEndOfString: "Unexpected end of string", UnexpectedEndOfNumber: "Unexpected end of number", InvalidUnicode: "Invalid unicode escape", InvalidEscapeCharacter: "Invalid escape character", InvalidCharacter: "Invalid character" };
|
|
84
71
|
|
|
@@ -137,6 +124,8 @@ export function createSourcePane(container, options = {}) {
|
|
|
137
124
|
let collapsed = false;
|
|
138
125
|
let copyTimer = null;
|
|
139
126
|
let renderedKey = "";
|
|
127
|
+
// Whether the findings include an error, and what the editor's `aria-invalid` currently says (null: not yet set).
|
|
128
|
+
let invalid = false, shownInvalid = null;
|
|
140
129
|
const lineCache = { key: "", values: new Map() };
|
|
141
130
|
|
|
142
131
|
// ---- DOM ----------------------------------------------------------------------------------------------------------
|
|
@@ -232,6 +221,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
232
221
|
lineNumbers: true,
|
|
233
222
|
language: formatOf(format).language,
|
|
234
223
|
readOnly,
|
|
224
|
+
completions: options.completions !== false,
|
|
235
225
|
catalogs: options.catalogs ?? editor.catalogs,
|
|
236
226
|
describedBy: `${id}-findings`,
|
|
237
227
|
onChange: () => schedule(),
|
|
@@ -275,7 +265,8 @@ export function createSourcePane(container, options = {}) {
|
|
|
275
265
|
editor.applyPatch([{ op: "replace", path: "", value: read.deck }], { source: "source-pane", format });
|
|
276
266
|
} catch (error) {
|
|
277
267
|
dirty = true;
|
|
278
|
-
|
|
268
|
+
// Every issue of the refusal is its own finding, with its path; a format's own findings stand in when the error names none.
|
|
269
|
+
rejectionFindings = error?.issues?.length || !read.findings.length ? findingsOfError(error) : read.findings.map((finding) => ({ ...finding, kind: "rejected" }));
|
|
279
270
|
applying = false;
|
|
280
271
|
renderFindings();
|
|
281
272
|
return;
|
|
@@ -292,13 +283,16 @@ export function createSourcePane(container, options = {}) {
|
|
|
292
283
|
function refreshNotes() {
|
|
293
284
|
try { noteFindings = markdownNotes(toMarkdown(deck()).report); } catch { noteFindings = []; }
|
|
294
285
|
}
|
|
286
|
+
/** `aria-invalid` follows the findings: set while any finding is an error (syntax, a refused draft, the session's or the host's), cleared with them. */
|
|
295
287
|
function syncAttributes() {
|
|
296
|
-
|
|
288
|
+
shownInvalid = invalid;
|
|
289
|
+
control.update(control.api.getValue(), invalid, null, `${id}-findings`);
|
|
297
290
|
}
|
|
298
291
|
|
|
299
292
|
/** Show `value` as the text of the current format, as an external change. */
|
|
300
293
|
function show(value) {
|
|
301
294
|
baseline = value;
|
|
295
|
+
shownInvalid = false;
|
|
302
296
|
control.update(value, false, null, `${id}-findings`);
|
|
303
297
|
}
|
|
304
298
|
function fromSession(notice) {
|
|
@@ -332,7 +326,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
332
326
|
|
|
333
327
|
const unsubscribe = editor.subscribe((event) => {
|
|
334
328
|
if (destroyed) return;
|
|
335
|
-
if (event.type === "catalogs") { renderFindings(); return; }
|
|
329
|
+
if (event.type === "catalogs") { control.setCatalogs(options.catalogs ?? editor.catalogs); renderFindings(); return; }
|
|
336
330
|
if (applying) return;
|
|
337
331
|
version++;
|
|
338
332
|
fromSession();
|
|
@@ -539,13 +533,19 @@ export function createSourcePane(container, options = {}) {
|
|
|
539
533
|
catch (error) { options.onError?.(error); host = []; }
|
|
540
534
|
} else if (hostFindings) host = hostFindings.map((finding) => normalizeFinding(finding));
|
|
541
535
|
else host = sessionFindings();
|
|
536
|
+
// A finding repeated by an earlier group is dropped; repeats inside one group stay, so a refusal lists every issue the validator found.
|
|
542
537
|
const seen = new Set();
|
|
543
|
-
const all = [
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
538
|
+
const all = [];
|
|
539
|
+
for (const group of [syntaxFindings, rejectionFindings, transientNotes, noteFindings, host]) {
|
|
540
|
+
const keys = [];
|
|
541
|
+
for (const finding of group) {
|
|
542
|
+
const key = `${finding.severity}|${finding.path}|${finding.message}`;
|
|
543
|
+
if (seen.has(key)) continue;
|
|
544
|
+
keys.push(key);
|
|
545
|
+
all.push(finding);
|
|
546
|
+
}
|
|
547
|
+
for (const key of keys) seen.add(key);
|
|
548
|
+
}
|
|
549
549
|
return all.map((finding, index) => ({ finding, index })).sort((a, b) => SEVERITY[a.finding.severity] - SEVERITY[b.finding.severity] || a.index - b.index).map((entry) => entry.finding);
|
|
550
550
|
}
|
|
551
551
|
function lineOf(finding) {
|
|
@@ -558,9 +558,14 @@ export function createSourcePane(container, options = {}) {
|
|
|
558
558
|
return lineCache.values.get(cacheKey) ?? explicit ?? null;
|
|
559
559
|
}
|
|
560
560
|
function renderFindings() {
|
|
561
|
+
paintFindings();
|
|
562
|
+
if (!destroyed && invalid !== shownInvalid) syncAttributes();
|
|
563
|
+
}
|
|
564
|
+
function paintFindings() {
|
|
561
565
|
if (destroyed) return;
|
|
562
566
|
const all = collect();
|
|
563
567
|
const rows = all.map((finding) => ({ finding, line: lineOf(finding) }));
|
|
568
|
+
invalid = rows.some((row) => row.finding.severity === "error");
|
|
564
569
|
const key = JSON.stringify([format, collapsed, rows.map(({ finding, line }) => [finding.severity, finding.path, finding.message, finding.kind, line])]);
|
|
565
570
|
if (key === renderedKey) return;
|
|
566
571
|
renderedKey = key;
|
package/dist/styles.css
CHANGED
|
@@ -2807,6 +2807,49 @@
|
|
|
2807
2807
|
box-shadow: var(--opf-ui-shadow-pop);
|
|
2808
2808
|
}
|
|
2809
2809
|
|
|
2810
|
+
:root :is(.opf-ui, .opf-json, .opf-code) .cm-tooltip.cm-tooltip-autocomplete > ul {
|
|
2811
|
+
font-family: var(--opf-ui-mono);
|
|
2812
|
+
font-size: 12px;
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
:root :is(.opf-ui, .opf-json, .opf-code) .cm-tooltip.cm-tooltip-autocomplete > ul > li {
|
|
2816
|
+
padding: 2px 8px;
|
|
2817
|
+
color: var(--opf-ui-fg);
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
:root :is(.opf-ui, .opf-json, .opf-code) .cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected] {
|
|
2821
|
+
color: var(--opf-ui-accent-fg);
|
|
2822
|
+
background: var(--opf-ui-accent);
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
:root :is(.opf-ui, .opf-json, .opf-code) .cm-tooltip-autocomplete .cm-completionDetail {
|
|
2826
|
+
margin-left: 12px;
|
|
2827
|
+
font-family: var(--opf-ui-font);
|
|
2828
|
+
font-style: normal;
|
|
2829
|
+
color: var(--opf-ui-muted-fg);
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
:root :is(.opf-ui, .opf-json, .opf-code) .cm-tooltip-autocomplete > ul > li[aria-selected] .cm-completionDetail {
|
|
2833
|
+
color: inherit;
|
|
2834
|
+
opacity: 0.85;
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
:root :is(.opf-ui, .opf-json, .opf-code) .cm-tooltip-autocomplete .cm-completionMatchedText {
|
|
2838
|
+
font-weight: 600;
|
|
2839
|
+
text-decoration: none;
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
:root :is(.opf-ui, .opf-json, .opf-code) .cm-tooltip .cm-completionInfo {
|
|
2843
|
+
max-width: 280px;
|
|
2844
|
+
padding: 6px 8px;
|
|
2845
|
+
font: 12px/1.45 var(--opf-ui-font);
|
|
2846
|
+
white-space: pre-wrap;
|
|
2847
|
+
color: var(--opf-ui-fg);
|
|
2848
|
+
background: var(--opf-ui-surface);
|
|
2849
|
+
border: 1px solid var(--opf-ui-border);
|
|
2850
|
+
border-radius: var(--opf-ui-radius-md);
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2810
2853
|
:is(.opf-ui, .opf-json, .opf-code) .cm-diagnostic-error {
|
|
2811
2854
|
border-left-color: var(--opf-ui-danger);
|
|
2812
2855
|
}
|
|
@@ -3087,7 +3130,8 @@
|
|
|
3087
3130
|
|
|
3088
3131
|
/* Code */
|
|
3089
3132
|
.opf-source-code { position: relative; flex: 1; min-height: 0; overflow: hidden; display: flex; }
|
|
3090
|
-
|
|
3133
|
+
/* The pane frame draws the edge: the editor inside it has no border or radius of its own (panels.css gives a standalone editor both). */
|
|
3134
|
+
.opf-source-code > .cm-editor { flex: 1; min-width: 0; height: 100%; background: transparent; color: var(--opf-ui-fg); border: 0; border-radius: 0; }
|
|
3091
3135
|
.opf-source-code .cm-editor.cm-focused { outline: none; }
|
|
3092
3136
|
.opf-source-code .cm-scroller { overflow: auto; font: 400 13.5px/22px var(--opf-ui-mono); }
|
|
3093
3137
|
.opf-source[data-density="touch"] .opf-source-code .cm-scroller { font-size: 13px; line-height: 21px; }
|