@openpresentation/opf-editor 0.26.0 → 0.26.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,362 @@
1
+ // The completion engine of the source editor (artifacts/source-editor/SPEC.md, section 4): what the list offers at a position of a JSON draft, how it is
2
+ // ranked, and what accepting an item writes. Keys come from the schema (`describeField` of core), values from the schema's enums, booleans and the
3
+ // catalogs, templates for objects and lists, snippets for slides and the like. Loaded lazily by the code editor with the schema and the catalogs.
4
+ import { hasNextSnippetField, hasPrevSnippetField, pickedCompletion, snippet, startCompletion } from "@codemirror/autocomplete";
5
+ import { EditorSelection } from "@codemirror/state";
6
+ import { describeField } from "@openpresentation/opf/schema";
7
+ import { listCatalogRecords } from "./catalogs.js";
8
+ import { LAYOUT_INTENT_GROUPS, layoutRegionSummary } from "./layout-templates.js";
9
+ import { contextAt, nextSignificant, nodeAt, skeleton, tokenAt } from "./source-json.js";
10
+ import { childDoc, docNode, docText, fieldDoc } from "./source-docs.js";
11
+ import { snippetJson, snippetsFor } from "./source-snippets.js";
12
+
13
+ export { contextAt, tokenAt };
14
+
15
+ // A quoted token keeps its list for any text between the quotes; a bare word for word characters only.
16
+ const QUOTED_TEXT = /^[^"\\\n\r]*$/;
17
+ const WORD_TEXT = /^[A-Za-z0-9_.-]*$/;
18
+ const MIN_ADVANCED_PREFIX = 3;
19
+
20
+ const KIND_LABELS = { layouts: "layout", themes: "theme", colorSchemes: "color scheme", fontSchemes: "font scheme", narratives: "narrative", audiences: "audience", purposes: "purpose", tones: "tone" };
21
+ const SOURCE_LABELS = { document: "In this document", registered: "Provided by app" };
22
+
23
+ const brief = (text) => {
24
+ if (typeof text !== "string" || !text.trim()) return undefined;
25
+ const flat = text.replace(/\s+/g, " ").trim();
26
+ return /^.*?[.!?](?=\s|$)/.exec(flat)?.[0] ?? flat;
27
+ };
28
+
29
+ // ---- ranking ---------------------------------------------------------------------------------------------------------------------------
30
+ // CodeMirror adds `boost` (-99 to 99) to the match score. Prefix matches differ by hundreds from other matches, so the boost orders the items that
31
+ // match equally: the position in the list below, from 99 down.
32
+ const boostAt = (index) => Math.max(-99, 99 - index);
33
+
34
+ // ---- templates -------------------------------------------------------------------------------------------------------------------------
35
+ const primaryVariant = (info) => info.variants[info.primary] ?? info.variants[0];
36
+ const variantKind = (info) => {
37
+ const variant = primaryVariant(info);
38
+ if (!variant) return "any";
39
+ if (variant.enum?.length || info.catalogKind) return "choice";
40
+ return variant.type;
41
+ };
42
+
43
+ /**
44
+ * The text inserted for a value of the field `info` describes, as a snippet template: `{ template, fields, chain }`. `first` is the number of the first
45
+ * tab stop. A string with a closed set of values (an enum, a catalog id) is an empty string between quotes with the list of values opened inside;
46
+ * a boolean leaves the place for `true` or `false` and opens that list; an object with required members writes them, one per line, each a tab stop.
47
+ */
48
+ export function valueTemplate(info, first = 1) {
49
+ const kind = variantKind(info);
50
+ const variant = primaryVariant(info);
51
+ if (kind === "choice") return { template: `"\${${first}}"`, fields: 1, chain: true };
52
+ if (kind === "boolean") return { template: "", fields: 0, chain: true };
53
+ if (kind === "number" || kind === "integer") return { template: `\${${first}}`, fields: 1, chain: false };
54
+ if (kind === "array") return { template: `[\${${first}}]`, fields: 1, chain: false };
55
+ if (kind === "object") {
56
+ const required = variant?.requiredChildren?.length ? variant.requiredChildren : info.requiredChildren;
57
+ if (!required.length) return { template: `{\${${first}}}`, fields: 1, chain: false };
58
+ let next = first;
59
+ const lines = required.map((key) => {
60
+ const child = info.children.find((entry) => entry.key === key);
61
+ const type = child?.types[0] ?? "string";
62
+ const stop = next++;
63
+ const value = type === "boolean" ? "false" : type === "array" ? "[]" : type === "object" ? "{}" : type === "number" || type === "integer" ? `\${${stop}:0}` : `"\${${stop}}"`;
64
+ return `\t${JSON.stringify(key)}: ${value}`;
65
+ });
66
+ return { template: `{\n${lines.join(",\n")}\n}`, fields: next - first, chain: false };
67
+ }
68
+ return { template: `"\${${first}}"`, fields: 1, chain: false };
69
+ }
70
+
71
+ /** A snippet template as plain text, and where its first tab stop is (offset), for inserting at several cursors at once. */
72
+ function plainOf(template) {
73
+ let first = -1, output = "";
74
+ for (let index = 0; index < template.length;) {
75
+ if (template[index] === "\\" && /[{}\\]/.test(template[index + 1] ?? "")) { output += template[index + 1]; index += 2; continue; }
76
+ const match = /^[#$]\{(\d+)(?::((?:\\[{}\\]|[^{}\\])*))?\}/.exec(template.slice(index));
77
+ if (match) {
78
+ if (first < 0 && match[1] !== "0") first = output.length;
79
+ output += (match[2] ?? "").replace(/\\([{}\\])/g, "$1");
80
+ index += match[0].length;
81
+ continue;
82
+ }
83
+ output += template[index++];
84
+ }
85
+ return { text: output, first: first < 0 ? output.length : first };
86
+ }
87
+
88
+ // ---- the options -----------------------------------------------------------------------------------------------------------------------
89
+ function advancedShown(children, typed, explicit) {
90
+ if (!typed || typed.length < (explicit ? 1 : MIN_ADVANCED_PREFIX)) return false;
91
+ return children.some((child) => child.advanced && child.key.toLowerCase().startsWith(typed));
92
+ }
93
+
94
+ function keyOptions(context, text, caret, explicit) {
95
+ const info = describeField(context.path, context.document);
96
+ const typed = text.slice(context.token.from, caret).toLowerCase();
97
+ const options = [];
98
+ if (info.known) {
99
+ const free = info.children.filter((child) => !context.existing.has(child.key));
100
+ const withAdvanced = advancedShown(free, typed, explicit);
101
+ const shown = free.filter((child) => !child.advanced || (withAdvanced && child.key.toLowerCase().startsWith(typed)));
102
+ shown.forEach((child, index) => options.push({
103
+ label: child.key,
104
+ type: "property",
105
+ detail: child.required ? `${child.typeLabel} · required` : child.typeLabel,
106
+ boost: boostAt(index),
107
+ opfRequired: child.required,
108
+ opfDoc: child.description ?? "",
109
+ opf: { kind: "key", key: child.key },
110
+ info: () => docNode(describeChild(context, child)),
111
+ }));
112
+ for (const entry of snippetsFor("key", context.path)) options.push(snippetOption(entry, boostAt(options.length)));
113
+ return { options, advanced: withAdvanced };
114
+ }
115
+ return { options, advanced: false };
116
+ }
117
+
118
+ function describeChild(context, child) {
119
+ try {
120
+ const info = describeField([...context.path, child.key], context.document);
121
+ if (info.known) return { ...fieldDoc(info, child.key), description: child.description ?? info.description };
122
+ } catch {
123
+ // The card still shows what the list knows.
124
+ }
125
+ return childDoc(child);
126
+ }
127
+
128
+ function snippetOption(entry, boost) {
129
+ return { label: entry.prefix, type: "snippet", detail: "snippet", boost, opfDoc: entry.description, opf: { kind: "snippet", prefix: entry.prefix }, info: () => docNode({ title: entry.prefix, type: "snippet", description: entry.description }) };
130
+ }
131
+
132
+ function literalOption(label, literal, extra) {
133
+ return { label, type: extra.type ?? "enum", boost: extra.boost, detail: extra.detail, opfDoc: extra.doc ?? "", opf: { kind: "literal", literal }, info: extra.info };
134
+ }
135
+
136
+ function catalogOptions(kind, context, catalogs) {
137
+ let records = [];
138
+ try { records = listCatalogRecords(context.document, kind, { catalogs }); } catch { records = []; }
139
+ const order = (record) => {
140
+ if (record.origin === "document") return -1;
141
+ if (kind !== "layouts") return 0;
142
+ const id = record.id.replace(/^default:/, "");
143
+ const group = LAYOUT_INTENT_GROUPS.findIndex((entry) => entry.ids.includes(id));
144
+ return group < 0 ? LAYOUT_INTENT_GROUPS.length * 100 : group * 100 + LAYOUT_INTENT_GROUPS[group].ids.indexOf(id);
145
+ };
146
+ records.sort((a, b) => order(a) - order(b) || a.label.localeCompare(b.label, "en", { numeric: true }) || a.id.localeCompare(b.id, "en"));
147
+ const intent = kind === "layouts";
148
+ return records.map((record, index) => {
149
+ const regions = kind === "layouts" ? layoutRegionSummary(record.record) : "";
150
+ const notes = [regions && `Regions: ${regions}`, SOURCE_LABELS[record.origin === "document" ? "document" : "registered"]].filter(Boolean);
151
+ const model = { title: record.id, type: KIND_LABELS[kind], description: record.description ?? undefined, notes: [record.label !== record.id ? record.label : "", ...notes].filter(Boolean) };
152
+ return literalOption(record.id, JSON.stringify(record.id), {
153
+ type: "catalog", boost: intent || record.origin === "document" ? boostAt(index) : 0, detail: record.label !== record.id ? record.label : SOURCE_LABELS[record.origin === "document" ? "document" : "registered"],
154
+ doc: [brief(record.description), regions].filter(Boolean).join(" "), info: () => docNode(model),
155
+ });
156
+ });
157
+ }
158
+
159
+ function valueOptions(context, catalogs) {
160
+ const info = describeField(context.path, context.document);
161
+ const options = [];
162
+ if (!info.known) return { options };
163
+ const variant = primaryVariant(info);
164
+ if (info.catalogKind) options.push(...catalogOptions(info.catalogKind, context, catalogs));
165
+ else if (info.enum?.length) {
166
+ // In the order VS Code lists them: by label.
167
+ info.enum.forEach((entry) => options.push(literalOption(String(entry.value), JSON.stringify(entry.value), {
168
+ doc: entry.description ?? "", info: entry.description ? () => docNode({ title: String(entry.value), type: "value", description: entry.description }) : undefined,
169
+ })));
170
+ } else if (info.types.includes("boolean")) {
171
+ for (const value of ["true", "false"]) options.push(literalOption(value, value, { type: "boolean" }));
172
+ } else if (variant && !info.freeText && (variant.type === "object" || variant.type === "array")) {
173
+ // An object or a list goes in as a template: `{}` with its required members, or `[]`.
174
+ const label = variant.type === "object" ? "{}" : "[]";
175
+ options.push({ label, type: "keyword", detail: variant.type === "object" ? "object" : "list", boost: 99, opfDoc: info.description ?? "", opf: { kind: "template" }, info: () => docNode(fieldDoc(info, context.key ?? label)) });
176
+ }
177
+ for (const entry of snippetsFor(context.mode, context.path)) options.push(snippetOption(entry, boostAt(options.length)));
178
+ return { options };
179
+ }
180
+
181
+ /**
182
+ * The completion result at `position` for CodeMirror, or null. `catalogs` are the host's.
183
+ */
184
+ export function completionResult(state, position, { explicit, catalogs }) {
185
+ const text = state.doc.toString();
186
+ const context = contextAt(text, position, { explicit });
187
+ if (!context) return null;
188
+ const { options, advanced } = context.mode === "key" ? keyOptions(context, text, position, explicit) : valueOptions(context, catalogs);
189
+ if (!options.length) return null;
190
+ const { token } = context;
191
+ const quoted = token.quoted;
192
+ // The result stays valid while the author types within the same token and the set of keys it shows does not change (advanced keys come and go
193
+ // with the prefix).
194
+ const sameSet = context.mode !== "key" ? () => true : (typed) => advancedShown(options.length ? infoChildren(context) : [], typed.toLowerCase(), explicit) === Boolean(advanced);
195
+ return {
196
+ from: token.from,
197
+ // Filtered by the text before the caret, as VS Code does.
198
+ to: Math.max(token.from, Math.min(position, token.to)),
199
+ filter: true,
200
+ options,
201
+ validFor: (typed) => (quoted ? QUOTED_TEXT : WORD_TEXT).test(typed) && sameSet(typed),
202
+ };
203
+ }
204
+ function infoChildren(context) {
205
+ try { return describeField(context.path, context.document).children.filter((child) => !context.existing.has(child.key)); } catch { return []; }
206
+ }
207
+
208
+ // ---- accepting -------------------------------------------------------------------------------------------------------------------------
209
+ const snippetRuns = new WeakMap();
210
+
211
+ /** What accepting `completion` writes: `{ start, end, template, fields, chain, choices }` for the context of the caret. */
212
+ export function planCompletion(context, completion, text, end) {
213
+ const next = nextSignificant(text, end);
214
+ const needsComma = next !== "" && !",}]".includes(next);
215
+ const kind = completion.opf?.kind;
216
+ if (kind === "key") {
217
+ const key = completion.opf.key;
218
+ const quoted = JSON.stringify(key);
219
+ if (/^\s*:/.test(text.slice(end))) return { template: quoted, fields: 0, chain: false };
220
+ let info;
221
+ try { info = describeField([...context.path, key], context.document); } catch { info = null; }
222
+ const value = info?.known ? valueTemplate(info) : { template: '"${1}"', fields: 1, chain: false };
223
+ const body = `${quoted}: ${value.template}${needsComma ? "," : ""}`;
224
+ // The last stop is after the value: Tab leaves the string, the braces or the list.
225
+ return { template: value.fields ? `${body}\${0}` : body, fields: value.fields, chain: value.chain };
226
+ }
227
+ if (kind === "literal") return { template: `${completion.opf.literal.replace(/[\\{}]/g, (character) => `\\${character}`)}${needsComma ? "," : ""}`, fields: 0, chain: false };
228
+ if (kind === "template") {
229
+ let info;
230
+ try { info = describeField(context.path, context.document); } catch { info = null; }
231
+ const value = info?.known ? valueTemplate(info) : { template: "{${1}}", fields: 1, chain: false };
232
+ const template = value.template || "";
233
+ return { template: `${template}${needsComma ? "," : ""}${value.fields ? "\${0}" : ""}`, fields: value.fields, chain: false };
234
+ }
235
+ if (kind === "snippet") {
236
+ const entry = snippetsFor(context.mode, context.path).find((candidate) => candidate.prefix === completion.opf.prefix);
237
+ if (!entry) return null;
238
+ const rendered = snippetJson(entry, { comma: needsComma && context.mode !== "key" });
239
+ return { template: rendered.text, fields: rendered.fields.length, chain: false, choices: rendered.fields };
240
+ }
241
+ return null;
242
+ }
243
+
244
+ /** Accept `completion`: write its text over the token under the caret (quotes included) and, if it has tab stops, start the snippet. */
245
+ export function applyCompletion(view, completion, fallbackFrom, fallbackTo, say) {
246
+ const { state } = view;
247
+ const text = state.doc.toString();
248
+ const main = state.selection.main;
249
+ const context = contextAt(text, main.head, { explicit: true });
250
+ const token = context?.token;
251
+ const start = token ? token.start : fallbackFrom, end = token ? token.end : fallbackTo;
252
+ const plan = context ? planCompletion(context, completion, text, end) : null;
253
+ if (!plan) {
254
+ view.dispatch({ changes: { from: fallbackFrom, to: fallbackTo, insert: completion.label }, selection: { anchor: fallbackFrom + completion.label.length }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete" });
255
+ return;
256
+ }
257
+ if (state.selection.ranges.length > 1) { applyAtEveryCursor(view, completion, plan); return; }
258
+ // A string between quotes that are already there keeps them: only what is inside is replaced. In the stop of a snippet the caret stays inside
259
+ // the quotes, so Tab goes on to the next stop; elsewhere it ends after the closing quote.
260
+ if (completion.opf?.kind === "literal" && token?.quoted && token.closed && completion.opf.literal.startsWith('"')) {
261
+ const inner = completion.opf.literal.slice(1, -1);
262
+ const inSnippet = snippetRuns.has(view) && (hasNextSnippetField(state) || hasPrevSnippetField(state));
263
+ const next = nextSignificant(text, token.end);
264
+ const changes = [{ from: token.from, to: token.to, insert: inner }];
265
+ if (next !== "" && !",}]".includes(next)) changes.push({ from: token.end, insert: "," });
266
+ const caret = inSnippet ? token.from + inner.length : token.end + inner.length - (token.to - token.from);
267
+ view.dispatch({ changes, selection: { anchor: caret }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete", scrollIntoView: true });
268
+ return;
269
+ }
270
+ if (plan.fields > 0 || plan.template.includes("${")) {
271
+ snippet(plan.template)(view, completion, start, end);
272
+ if (plan.choices) snippetRuns.set(view, { fields: plan.choices, index: -1 });
273
+ else snippetRuns.delete(view);
274
+ if (plan.choices) snippetStep(view, true, { start: true, say });
275
+ } else {
276
+ const insert = plan.template.replace(/\\([{}\\])/g, "$1");
277
+ view.dispatch({ changes: { from: start, to: end, insert }, selection: { anchor: start + insert.length }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete", scrollIntoView: true });
278
+ snippetRuns.delete(view);
279
+ }
280
+ // A key whose value is one of a closed set opens that list at once.
281
+ if (plan.chain) startCompletion(view);
282
+ }
283
+
284
+ // With several cursors the item goes in at each of them as plain text (no tab stops), each over its own token.
285
+ function applyAtEveryCursor(view, completion, plan) {
286
+ const { state } = view;
287
+ const text = state.doc.toString();
288
+ const plain = plainOf(plan.template);
289
+ const changes = [], carets = [];
290
+ let shift = 0;
291
+ for (const range of [...state.selection.ranges].sort((a, b) => a.from - b.from)) {
292
+ const context = contextAt(text, range.head, { explicit: true });
293
+ const token = context?.token ?? { start: range.from, end: range.to };
294
+ changes.push({ from: token.start, to: token.end, insert: plain.text });
295
+ carets.push(token.start + shift + plain.first);
296
+ shift += plain.text.length - (token.end - token.start);
297
+ }
298
+ view.dispatch({ changes, selection: EditorSelection.create(carets.map((position) => EditorSelection.cursor(position)), 0), annotations: [pickedCompletion.of(completion)], userEvent: "input.complete", scrollIntoView: true });
299
+ }
300
+
301
+ /**
302
+ * A tab stop of the snippet just accepted was reached (`forward`: Tab, else Shift+Tab). Says which one, and opens the list of values at a stop
303
+ * the snippet marks as a choice. `start` is the first stop. Returns whether a snippet is being walked.
304
+ */
305
+ export function snippetStep(view, forward, { start = false, say } = {}) {
306
+ const run = snippetRuns.get(view);
307
+ if (!run) return false;
308
+ run.index = start ? 0 : run.index + (forward ? 1 : -1);
309
+ const total = run.fields.length;
310
+ const announce = say;
311
+ if (run.index >= total) {
312
+ snippetRuns.delete(view);
313
+ announce?.("End of snippet");
314
+ return true;
315
+ }
316
+ if (run.index < 0) { run.index = 0; return true; }
317
+ const stop = run.fields[run.index];
318
+ announce?.(`Tab stop ${run.index + 1} of ${total}${stop.text ? `: ${stop.text}` : ""}`);
319
+ if (stop.choice) startCompletion(view);
320
+ return true;
321
+ }
322
+ export const snippetActive = (view) => snippetRuns.has(view);
323
+ export const forgetSnippet = (view) => snippetRuns.delete(view);
324
+
325
+ /** The plain description of an option for the live region. */
326
+ export const describeOption = (completion) => completion.opfDoc ?? "";
327
+ export { docText };
328
+
329
+ // ---- hover -----------------------------------------------------------------------------------------------------------------------------
330
+ /**
331
+ * What the schema and the catalogs say about the key or the value under `position`: `{ from, to, model }` (offsets of the token; `model` is a
332
+ * source-docs.js card), or null over free text, punctuation and whitespace.
333
+ */
334
+ export function hoverModelAt(state, position, { catalogs }) {
335
+ const found = nodeAt(state.doc.toString(), position);
336
+ if (!found) return null;
337
+ let info;
338
+ try { info = describeField(found.path, skeleton(found.root, found.path, false)); } catch { return null; }
339
+ if (!info.known) return null;
340
+ const name = String(found.path.at(-1));
341
+ if (found.kind === "key") return { from: found.from, to: found.to, model: fieldDoc(info, name) };
342
+ if (typeof found.value === "string" && info.catalogKind) {
343
+ let record;
344
+ try { record = listCatalogRecords(skeleton(found.root, found.path, false), info.catalogKind, { catalogs }).find((entry) => entry.id === found.value); } catch { record = undefined; }
345
+ if (!record) return { from: found.from, to: found.to, model: { title: found.value, type: `${KIND_LABELS[info.catalogKind]} id`, description: `No ${KIND_LABELS[info.catalogKind]} named ${found.value} is available here.` } };
346
+ const regions = info.catalogKind === "layouts" ? layoutRegionSummary(record.record) : "";
347
+ return { from: found.from, to: found.to, model: { title: record.id, type: KIND_LABELS[info.catalogKind], description: record.description, notes: [record.label !== record.id ? record.label : "", regions && `Regions: ${regions}`, SOURCE_LABELS[record.origin === "document" ? "document" : "registered"]].filter(Boolean) } };
348
+ }
349
+ if (info.enum?.length && ["string", "number", "boolean"].includes(typeof found.value)) {
350
+ const entry = info.enum.find((candidate) => candidate.value === found.value);
351
+ return { from: found.from, to: found.to, model: { ...fieldDoc(info, name), description: entry?.description ?? info.description } };
352
+ }
353
+ if (info.freeText || found.nodeType === "object" || found.nodeType === "array") return null;
354
+ return { from: found.from, to: found.to, model: fieldDoc(info, name) };
355
+ }
356
+
357
+ /** A CodeMirror tooltip for `hoverModelAt`, or null. */
358
+ export function hoverAt(state, position, options) {
359
+ const found = hoverModelAt(state, position, options);
360
+ if (!found) return null;
361
+ return { pos: found.from, end: found.to, above: true, create: () => ({ dom: docNode(found.model) }) };
362
+ }
@@ -0,0 +1,211 @@
1
+ // Problems in the text (artifacts/source-editor/SPEC.md, section 9): every finding the pane has a range for is a squiggle with its message, what
2
+ // could come next and its quick fixes; F8 and Shift+F8 walk them; Cmd/Ctrl+. lists the fixes at the caret. The pane decides what the problems
3
+ // are (`control.setProblems`); this module only draws them and acts on them. Ranges are original-source offsets (CRLF counts as two).
4
+ import { ChangeSet, EditorSelection, Prec, StateEffect, StateField, Transaction } from "@codemirror/state";
5
+ import { EditorView, activateHover, keymap, showTooltip } from "@codemirror/view";
6
+ import { forEachDiagnostic, linter, setDiagnostics } from "@codemirror/lint";
7
+ import { isolateHistory } from "@codemirror/commands";
8
+ import { textInputMap } from "./text-input.js";
9
+ import { setJsonSource } from "./json-source.js";
10
+ import { applySourceEdits } from "./source-fixes.js";
11
+
12
+ const SEVERITY_WORD = { error: "Error", warning: "Warning", info: "Note" };
13
+ const MAX_FIXES = 7;
14
+ const el = (tag, className, text) => {
15
+ const node = document.createElement(tag);
16
+ if (className) node.className = className;
17
+ if (text !== undefined) node.textContent = text;
18
+ return node;
19
+ };
20
+
21
+ /** The platform's name for the quick fix chord, for the hint in a problem's tooltip. */
22
+ const isMac = () => /Mac|iPhone|iPad/.test(globalThis.navigator?.platform ?? "");
23
+ export const quickFixChord = () => (isMac() ? "Cmd+." : "Ctrl+.");
24
+
25
+ // ---- the diagnostics ---------------------------------------------------------------------------------------------------------------
26
+ /** One problem as CodeMirror's lint state keeps it (`problem` is the pane's object, with source offsets as of when it was set). */
27
+ function toDiagnostic(problem, map, length, applyFix) {
28
+ const clamp = (offset) => Math.max(0, Math.min(offset, length));
29
+ const from = map.toInput(clamp(problem.from)), to = map.toInput(clamp(problem.to));
30
+ return {
31
+ from, to: Math.max(from, to),
32
+ severity: problem.severity === "warning" || problem.severity === "info" ? problem.severity : "error",
33
+ message: problem.message,
34
+ source: problem.rule,
35
+ problem,
36
+ renderMessage: () => renderProblem(problem),
37
+ actions: (problem.fixes ?? []).slice(0, MAX_FIXES).map((fix) => ({ name: fix.title, apply: () => applyFix(fix, problem) })),
38
+ };
39
+ }
40
+ /** The tooltip body: the message, what could come next, and the quick fix hint. */
41
+ function renderProblem(problem) {
42
+ const body = el("div", "opf-problem");
43
+ body.append(el("div", "opf-problem-message", problem.message));
44
+ if (problem.next) body.append(el("div", "opf-problem-next", `Next: ${problem.next}`));
45
+ if (problem.fixes?.length) body.append(el("div", "opf-problem-hint", `Quick Fix (${quickFixChord()}): ${problem.fixes.length} ${problem.fixes.length === 1 ? "action" : "actions"}`));
46
+ return body;
47
+ }
48
+
49
+ /** The problems now in the text, in position order, with their current source offsets. */
50
+ export function currentProblems(state, source) {
51
+ const map = textInputMap(source), out = [];
52
+ forEachDiagnostic(state, (diagnostic, from, to) => {
53
+ if (!diagnostic.problem) return;
54
+ out.push({ ...diagnostic.problem, from: map.toSource(Math.min(from, map.text.length)), to: map.toSource(Math.min(to, map.text.length)), diagnostic });
55
+ });
56
+ return out.sort((a, b) => a.from - b.from || a.to - b.to || a.message.localeCompare(b.message));
57
+ }
58
+
59
+ // ---- quick fix menu ----------------------------------------------------------------------------------------------------------------
60
+ const openFixes = StateEffect.define();
61
+ const closeFixes = StateEffect.define();
62
+ const moveFixes = StateEffect.define();
63
+ const fixMenu = StateField.define({
64
+ create: () => null,
65
+ update(value, tr) {
66
+ for (const effect of tr.effects) {
67
+ if (effect.is(openFixes)) value = { pos: effect.value.pos, items: effect.value.items, selected: 0 };
68
+ else if (effect.is(closeFixes)) value = null;
69
+ else if (effect.is(moveFixes) && value) value = { ...value, selected: (value.selected + effect.value + value.items.length) % value.items.length };
70
+ }
71
+ // Any change of the text or the caret closes the menu.
72
+ if (value && !tr.effects.some((effect) => effect.is(openFixes) || effect.is(moveFixes)) && (tr.docChanged || tr.selection)) value = null;
73
+ return value;
74
+ },
75
+ provide: (field) => showTooltip.from(field, (value) => value && {
76
+ pos: value.pos,
77
+ above: false,
78
+ strictSide: false,
79
+ arrow: false,
80
+ create() {
81
+ const dom = el("div", "cm-tooltip-section opf-fixes");
82
+ dom.setAttribute("role", "listbox");
83
+ dom.setAttribute("aria-label", "Quick fixes");
84
+ value.items.forEach((fix, index) => {
85
+ const item = el("div", "opf-fix", fix.title);
86
+ item.setAttribute("role", "option");
87
+ item.setAttribute("aria-selected", String(index === value.selected));
88
+ item.dataset.fix = fix.id;
89
+ if (fix.safe) item.dataset.safe = "true";
90
+ dom.append(item);
91
+ });
92
+ return { dom };
93
+ },
94
+ }),
95
+ });
96
+
97
+ /** The fixes of the problems at the selection (or, when none touches it, on its line), the safest first. */
98
+ export function fixesAtCaret(state, source) {
99
+ const { from, to } = state.selection.main;
100
+ const map = textInputMap(source);
101
+ const problems = currentProblems(state, source);
102
+ const caretFrom = map.toSource(from), caretTo = map.toSource(to);
103
+ const touching = problems.filter((problem) => problem.from <= caretTo && problem.to >= caretFrom);
104
+ let chosen = touching;
105
+ if (!chosen.length) {
106
+ const line = state.doc.lineAt(from);
107
+ chosen = problems.filter((problem) => state.doc.lineAt(map.toInput(Math.min(problem.from, source.length))).number === line.number);
108
+ }
109
+ const fixes = [];
110
+ const seen = new Set();
111
+ for (const problem of chosen) for (const fix of problem.fixes ?? []) {
112
+ const key = `${fix.id}|${fix.title}`;
113
+ if (seen.has(key)) continue;
114
+ seen.add(key);
115
+ fixes.push({ ...fix, problem });
116
+ }
117
+ fixes.sort((a, b) => Number(Boolean(b.safe)) - Number(Boolean(a.safe)));
118
+ return fixes.slice(0, MAX_FIXES);
119
+ }
120
+
121
+ // ---- the extension -----------------------------------------------------------------------------------------------------------------
122
+ /**
123
+ * `getSource()` is the original text; `applyFix(fix, problem)` performs a fix (the editor's edits or the pane's own action); `say(message)` is
124
+ * the live region. Returns `{ extension, draw(view, problems), problems(view) }`.
125
+ */
126
+ export function createDiagnostics({ getSource, applyFix, say }) {
127
+ let announced = null;
128
+ const stepProblem = (forward) => (view) => {
129
+ const source = getSource(), map = textInputMap(source), list = currentProblems(view.state, source);
130
+ if (!list.length) { say("No problems"); return true; }
131
+ const sel = view.state.selection.main, here = map.toSource(sel.from), end = map.toSource(sel.to);
132
+ let target;
133
+ if (forward) target = list.find((problem) => problem.from > here || (problem.from === here && problem.to > end)) ?? list[0];
134
+ else target = [...list].reverse().find((problem) => problem.from < here || (problem.from === here && problem.to < end)) ?? list[list.length - 1];
135
+ selectProblem(view, target, map);
136
+ return true;
137
+ };
138
+ const selectProblem = (view, problem, map) => {
139
+ const anchor = map.toInput(problem.from), head = map.toInput(problem.to);
140
+ view.dispatch({ selection: EditorSelection.single(anchor, head), scrollIntoView: true, userEvent: "select.problem" });
141
+ announced = problem.diagnostic;
142
+ say(`${SEVERITY_WORD[problem.severity] ?? "Error"}: ${problem.message}${problem.next ? ` Next: ${problem.next}` : ""}`);
143
+ activateHover(view, anchor, 1, { until: (tr) => tr.docChanged || (tr.selection !== undefined && !tr.isUserEvent("select.problem")) });
144
+ };
145
+ const openMenu = (view) => {
146
+ const fixes = fixesAtCaret(view.state, getSource());
147
+ if (!fixes.length) { say("No quick fixes available"); return true; }
148
+ view.dispatch({ effects: openFixes.of({ pos: view.state.selection.main.from, items: fixes }) });
149
+ say(`${fixes.length} quick ${fixes.length === 1 ? "fix" : "fixes"}. ${fixes[0].title}`);
150
+ return true;
151
+ };
152
+ const menuOpen = (view) => view.state.field(fixMenu, false) !== null;
153
+ const whenOpen = (run) => (view) => (menuOpen(view) ? (run(view), true) : false);
154
+ const move = (by) => (view) => {
155
+ view.dispatch({ effects: moveFixes.of(typeof by === "function" ? by(view.state.field(fixMenu)) : by) });
156
+ const menu = view.state.field(fixMenu);
157
+ say(menu.items[menu.selected].title);
158
+ };
159
+ const keys = Prec.highest(keymap.of([
160
+ { key: "ArrowDown", run: whenOpen(move(1)) },
161
+ { key: "ArrowUp", run: whenOpen(move(-1)) },
162
+ { key: "Home", run: whenOpen(move((menu) => -menu.selected)) },
163
+ { key: "End", run: whenOpen(move((menu) => menu.items.length - 1 - menu.selected)) },
164
+ { key: "Enter", run: whenOpen((view) => { const menu = view.state.field(fixMenu), fix = menu.items[menu.selected]; view.dispatch({ effects: closeFixes.of(null) }); applyFix(fix, fix.problem); }) },
165
+ { key: "Escape", run: whenOpen((view) => view.dispatch({ effects: closeFixes.of(null) })) },
166
+ { key: "Tab", run: whenOpen((view) => view.dispatch({ effects: closeFixes.of(null) })) },
167
+ ]));
168
+ const plain = keymap.of([
169
+ { key: "F8", run: stepProblem(true), shift: stepProblem(false) },
170
+ { key: "Mod-.", run: openMenu },
171
+ ]);
172
+ // The caret entering a squiggle says what the problem is, once per problem (a screen reader user finds it without looking for it).
173
+ const caretWatch = EditorView.updateListener.of((update) => {
174
+ if (!update.selectionSet || update.docChanged || update.transactions.some((tr) => tr.isUserEvent("select.problem"))) return;
175
+ const { head } = update.state.selection.main;
176
+ let found = null;
177
+ forEachDiagnostic(update.state, (diagnostic, from, to) => { if (!found && diagnostic.problem && head >= from && head <= to && from !== to) found = diagnostic; });
178
+ if (found && found !== announced) say(`${SEVERITY_WORD[found.severity] ?? "Error"}: ${found.message}`);
179
+ announced = found;
180
+ });
181
+ return {
182
+ extension: [linter(null), fixMenu, keys, plain, caretWatch],
183
+ /** Replace the problems drawn in the text. `problems` carry source offsets into `getSource()`. */
184
+ draw(view, problems) {
185
+ const source = getSource(), map = textInputMap(source);
186
+ announced = null;
187
+ view.dispatch(setDiagnostics(view.state, problems
188
+ .filter((problem) => Number.isInteger(problem.from) && Number.isInteger(problem.to) && problem.from <= source.length)
189
+ .map((problem) => toDiagnostic(problem, map, source.length, applyFix))));
190
+ },
191
+ problems: (view) => currentProblems(view.state, getSource()),
192
+ openMenu,
193
+ next: stepProblem(true),
194
+ previous: stepProblem(false),
195
+ };
196
+ }
197
+
198
+ /** The edit of a fix as one CodeMirror transaction spec (source offsets in, editor offsets out), or null when an edit splits a CRLF pair. */
199
+ export function editsTransaction(state, source, edits, { caret, select, userEvent = "input.fix" } = {}) {
200
+ const map = textInputMap(source);
201
+ const sorted = edits.map((edit, index) => ({ edit, index })).sort((a, b) => a.edit.from - b.edit.from || a.index - b.index).map((entry) => entry.edit);
202
+ const next = applySourceEdits(source, edits);
203
+ const changes = ChangeSet.of(sorted.map((edit) => ({ from: map.toInput(edit.from), to: map.toInput(edit.to), insert: textInputMap(edit.insert).text })), state.doc.length);
204
+ const spec = { changes, effects: setJsonSource.of(next), annotations: [Transaction.userEvent.of(userEvent), isolateHistory.of("full")], scrollIntoView: true };
205
+ const nextMap = textInputMap(next);
206
+ // A caret or selection is given as offsets in the new text; otherwise the selection follows the edits.
207
+ if (select) spec.selection = EditorSelection.single(nextMap.toInput(Math.min(select[0], next.length)), nextMap.toInput(Math.min(select[1], next.length)));
208
+ else if (caret !== undefined) spec.selection = EditorSelection.single(nextMap.toInput(Math.min(caret, next.length)));
209
+ else spec.selection = state.selection.map(changes, 1);
210
+ return spec;
211
+ }
@@ -0,0 +1,87 @@
1
+ // The documentation cards of the source editor: the panel beside the selected suggestion and the hover over a key or a value. One model, two uses,
2
+ // plain DOM (no markup is parsed from the schema: its text is shown as text).
3
+
4
+ const el = (tag, className, text) => {
5
+ const node = document.createElement(tag);
6
+ if (className) node.className = className;
7
+ if (text !== undefined) node.textContent = text;
8
+ return node;
9
+ };
10
+
11
+ /**
12
+ * @typedef {object} DocModel
13
+ * @property {string} title the key, the value or the record name
14
+ * @property {string} [type] "string", "array of Slide", "layout"
15
+ * @property {boolean} [required]
16
+ * @property {boolean} [deprecated]
17
+ * @property {string} [description]
18
+ * @property {{ value: unknown, description?: string }[]} [values] allowed values, shown as chips
19
+ * @property {unknown[]} [examples]
20
+ * @property {string} [definition] the schema definition's name
21
+ * @property {string[]} [notes] extra lines (regions of a layout, where a record comes from)
22
+ */
23
+
24
+ const MAX_CHIPS = 12;
25
+ const show = (value) => (typeof value === "string" ? value : JSON.stringify(value));
26
+
27
+ /** The card as DOM. */
28
+ export function docNode(model) {
29
+ const root = el("div", "opf-doc");
30
+ const head = el("div", "opf-doc-head");
31
+ head.append(el("code", "opf-doc-title", model.title));
32
+ if (model.type) head.append(el("span", "opf-doc-type", model.type));
33
+ if (model.required) head.append(el("span", "opf-doc-badge", "required"));
34
+ if (model.deprecated) head.append(el("span", "opf-doc-badge", "deprecated"));
35
+ root.append(head);
36
+ if (model.description) root.append(el("p", "opf-doc-text", model.description));
37
+ for (const note of model.notes ?? []) root.append(el("p", "opf-doc-note", note));
38
+ if (model.values?.length) {
39
+ const chips = el("div", "opf-doc-values");
40
+ for (const entry of model.values.slice(0, MAX_CHIPS)) {
41
+ const chip = el("code", "opf-doc-chip", show(entry.value));
42
+ if (entry.description) chip.title = entry.description;
43
+ chips.append(chip);
44
+ }
45
+ if (model.values.length > MAX_CHIPS) chips.append(el("span", "opf-doc-more", `and ${model.values.length - MAX_CHIPS} more`));
46
+ root.append(chips);
47
+ }
48
+ const example = model.examples?.find((value) => value !== undefined && !(typeof value === "string" && value.includes("...")));
49
+ if (example !== undefined) {
50
+ const line = el("p", "opf-doc-example");
51
+ line.append("Example: ", el("code", undefined, typeof example === "string" ? JSON.stringify(example) : JSON.stringify(example).slice(0, 80)));
52
+ root.append(line);
53
+ }
54
+ if (model.definition) root.append(el("p", "opf-doc-source", `Schema: ${model.definition}`));
55
+ return root;
56
+ }
57
+
58
+ /** The card as plain text (what a screen reader hears and what the tests read). */
59
+ export function docText(model) {
60
+ return [model.title, model.type, model.required ? "required" : "", model.description, ...(model.notes ?? [])].filter(Boolean).join(". ");
61
+ }
62
+
63
+ /** The model of a field described by `describeField` (`info`), named `title`. */
64
+ export function fieldDoc(info, title) {
65
+ return {
66
+ title,
67
+ type: typeOfInfo(info),
68
+ required: info.required,
69
+ deprecated: info.deprecated,
70
+ description: info.description,
71
+ values: info.enum,
72
+ examples: info.examples,
73
+ definition: info.definition,
74
+ };
75
+ }
76
+
77
+ function typeOfInfo(info) {
78
+ if (info.catalogKind) return info.catalogKind.replace(/s$/, "").replace(/([A-Z])/g, " $1").toLowerCase() + " id";
79
+ if (info.enum?.length > 1) return "enum";
80
+ const kinds = info.types.map((type) => (type === "array" && info.itemInfo ? `array of ${info.itemInfo.definition ?? info.itemInfo.types.join(" | ")}` : type));
81
+ return [...new Set(kinds)].join(" | ");
82
+ }
83
+
84
+ /** The model of one property of an object (a `FieldChild` of `describeField`): the card beside a key suggestion. */
85
+ export function childDoc(child) {
86
+ return { title: child.key, type: child.typeLabel, required: child.required, deprecated: child.deprecated, description: child.description };
87
+ }