@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 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
- - **Accessibility.** 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
+ - **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
 
@@ -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;
@@ -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. */
@@ -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;
@@ -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 installed schema identifies fields; a document field merely named "layout"
43
- // inside arbitrary metadata is not mistaken for an OPF layout reference.
44
- export function getJsonFieldContext(source, position, loadedCatalogs = []) {
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 && typeof node.value === "string") {
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 (node.type === "boolean" && variants.some(variant => variant.type === "boolean" && variant.const === undefined)) {
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
+ }
@@ -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
 
@@ -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
- rejectionFindings = read.findings.length ? read.findings.map((finding) => ({ ...finding, kind: "rejected" })) : [normalizeFinding({ message: errorText(error), kind: "rejected" })];
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
- control.update(control.api.getValue(), syntaxFindings.length > 0 || rejectionFindings.length > 0, null, `${id}-findings`);
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 = [...syntaxFindings, ...rejectionFindings, ...transientNotes, ...noteFindings, ...host].filter((finding) => {
544
- const key = `${finding.severity}|${finding.path}|${finding.message}`;
545
- if (seen.has(key)) return false;
546
- seen.add(key);
547
- return true;
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
- .opf-source-code > .cm-editor { flex: 1; min-width: 0; height: 100%; background: transparent; color: var(--opf-ui-fg); }
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; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-editor",
3
- "version": "0.24.1",
3
+ "version": "0.24.2",
4
4
  "description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
5
5
  "license": "MIT",
6
6
  "type": "module",