@openpresentation/opf-editor 0.24.0 → 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.
@@ -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
+ }
@@ -6,7 +6,8 @@ import {textInputMap,preferredLineEnding} from './text-input.js';
6
6
  // field, and store only removed newline spellings in its existing undo history.
7
7
  export const setJsonSource=StateEffect.define();
8
8
  const restoreEndings=StateEffect.define({map:(endings,changes)=>{
9
- const mapped=endings.map(({position,ending})=>({position:changes.mapPos(position,1,MapMode.TrackDel),ending})).filter(item=>item.position!==null);
9
+ // A position past the end of the changed document (the text was deleted, then replaced from outside) cannot be placed: drop it.
10
+ const mapped=endings.map(({position,ending})=>({position:position<=changes.length?changes.mapPos(position,1,MapMode.TrackDel):null,ending})).filter(item=>item.position!==null);
10
11
  return mapped.length?mapped:undefined;
11
12
  }});
12
13
  export const jsonSource=StateField.define({
@@ -0,0 +1,44 @@
1
+ import type { EditorSession } from "./index.js";
2
+
3
+ export interface SlideNavigatorLabels {
4
+ /** The accessible name of the control group. Default: "Slide navigation". */
5
+ navigation?: string;
6
+ /** Default: "Previous slide". */
7
+ previous?: string;
8
+ /** Default: "Next slide". */
9
+ next?: string;
10
+ /** The tooltip of the counter, for example "Slide 2 of 3". */
11
+ position?: (index: number, count: number) => string;
12
+ }
13
+ export interface SlideNavigatorOptions {
14
+ /** The editor session. The slide count follows it. */
15
+ editor?: EditorSession;
16
+ /** Without an editor: an OPF presentation, or a function returning the current one. Call `refresh()` after it changes. */
17
+ presentation?: { slides?: unknown[] } | (() => { slides?: unknown[] });
18
+ /** The slide shown first (0-based). Default 0. */
19
+ slideIndex?: number;
20
+ /** Called with the new index when the user moves, and when a shrinking deck clamps the index. Not called by `setSlide`. */
21
+ onChange?: (index: number) => void;
22
+ /** `"comfortable"` (32px controls, default) or `"touch"` (40px). */
23
+ density?: "comfortable" | "touch";
24
+ labels?: SlideNavigatorLabels;
25
+ /** False to stop following the editor on its own; call `refresh()` yourself. Default true. */
26
+ autoRefresh?: boolean;
27
+ }
28
+ export interface SlideNavigator {
29
+ readonly element: HTMLElement;
30
+ getSlide(): number;
31
+ /** Show another slide (clamped to the deck) without calling `onChange`. */
32
+ setSlide(index: number): void;
33
+ /** Read the slide count again. */
34
+ refresh(): void;
35
+ /**
36
+ * Page-level Left and Right arrows (default target: the document). They ignore typing in inputs, selects and editors, menus, dialogs, modified keys and
37
+ * events another handler already handled. Returns a function that removes the listener; `destroy()` removes every one.
38
+ */
39
+ bindKeys(target?: Document | HTMLElement | Window): () => void;
40
+ destroy(): void;
41
+ }
42
+ /** The counter text, for example "01 / 03": both numbers are zero-padded to two digits, or to the width of the slide count from 100 slides. */
43
+ export declare function formatSlideCounter(index: number, count: number): string;
44
+ export declare function createSlideNavigator(container: HTMLElement, options?: SlideNavigatorOptions): SlideNavigator;
@@ -0,0 +1,191 @@
1
+ // Slide navigator (RR-98): previous and next buttons around a mono "01 / 03" counter. It owns no document state, only the slide index the host
2
+ // shows; the slide count follows the editor session (or a presentation the host hands in). Plain DOM, no framework, no network.
3
+ // Icons are the Lucide ChevronLeft and ChevronRight paths (ISC license), inlined.
4
+ const SVG_NS = "http://www.w3.org/2000/svg";
5
+ const CHEVRONS = { previous: "m15 18-6-6 6-6", next: "m9 18 6-6-6-6" };
6
+
7
+ const DEFAULT_LABELS = {
8
+ navigation: "Slide navigation",
9
+ previous: "Previous slide",
10
+ next: "Next slide",
11
+ /** The accessible description of the position, for example "Slide 2 of 3". */
12
+ position: (index, count) => (count === 0 ? "No slides" : `Slide ${index + 1} of ${count}`),
13
+ };
14
+
15
+ /** The counter text: both numbers are zero-padded to two digits, or to the width of the slide count from 100 slides. */
16
+ export function formatSlideCounter(index, count) {
17
+ const width = Math.max(2, String(Math.max(0, count)).length);
18
+ const pad = (value) => String(value).padStart(width, "0");
19
+ return `${pad(count === 0 ? 0 : index + 1)} / ${pad(count)}`;
20
+ }
21
+
22
+ function chevron(name) {
23
+ const svg = document.createElementNS(SVG_NS, "svg");
24
+ svg.setAttribute("class", "opf-nav-icon");
25
+ svg.setAttribute("viewBox", "0 0 24 24");
26
+ svg.setAttribute("aria-hidden", "true");
27
+ svg.setAttribute("focusable", "false");
28
+ svg.setAttribute("fill", "none");
29
+ svg.setAttribute("stroke", "currentColor");
30
+ svg.setAttribute("stroke-width", "2");
31
+ svg.setAttribute("stroke-linecap", "round");
32
+ svg.setAttribute("stroke-linejoin", "round");
33
+ const path = document.createElementNS(SVG_NS, "path");
34
+ path.setAttribute("d", CHEVRONS[name]);
35
+ svg.append(path);
36
+ return svg;
37
+ }
38
+
39
+ /** True when a page-level key press belongs to something else: typing, an editor, a menu or a dialog. */
40
+ function isInteractiveTarget(target) {
41
+ if (!target || typeof target.closest !== "function") return false;
42
+ if (target.isContentEditable) return true;
43
+ const tag = target.tagName;
44
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
45
+ return Boolean(target.closest('[contenteditable=""],[contenteditable="true"],.cm-editor,[role="textbox"],[role="combobox"],[role="listbox"],[role="menu"],[role="slider"],[role="spinbutton"],dialog[open],[role="dialog"]'));
46
+ }
47
+
48
+ /**
49
+ * Mount previous and next buttons with a "01 / 03" counter into `container`.
50
+ *
51
+ * Options: `editor` (an editor session; the slide count follows it) or `presentation` (an OPF object, or a function returning the current
52
+ * one), `slideIndex` (0-based, default 0), `onChange(index)` (called when the user moves, and when a shrinking deck clamps the index),
53
+ * `density` (`"comfortable"` 32px controls, or `"touch"` 40px), `labels` (`navigation`, `previous`, `next`, `position(index, count)`) and
54
+ * `autoRefresh` (default true: follow the editor's changes on its own).
55
+ *
56
+ * Returns `{ element, getSlide(), setSlide(index), refresh(), bindKeys(target?), destroy() }`.
57
+ */
58
+ export function createSlideNavigator(container, options = {}) {
59
+ if (!container?.ownerDocument) throw new TypeError("A DOM container is required.");
60
+ const doc = container.ownerDocument;
61
+ const labels = { ...DEFAULT_LABELS, ...(options.labels ?? {}) };
62
+ const density = options.density === "touch" ? "touch" : "comfortable";
63
+ const { editor } = options;
64
+ const source = options.presentation;
65
+ let index = Math.max(0, Math.trunc(Number(options.slideIndex) || 0));
66
+ let disposed = false;
67
+ const unbinders = new Set();
68
+
69
+ const count = () => {
70
+ // The snapshot is cached between edits; `editor.presentation` clones the whole deck on every read.
71
+ const deck = editor ? (editor.snapshot?.().presentation ?? editor.presentation) : typeof source === "function" ? source() : source;
72
+ return Array.isArray(deck?.slides) ? deck.slides.length : 0;
73
+ };
74
+
75
+ const element = doc.createElement("div");
76
+ element.className = "opf-nav";
77
+ element.dataset.density = density;
78
+ element.setAttribute("role", "group");
79
+ element.setAttribute("aria-label", labels.navigation);
80
+ const button = (name) => {
81
+ const node = doc.createElement("button");
82
+ node.type = "button";
83
+ node.className = "opf-nav-button";
84
+ node.dataset.action = name;
85
+ node.setAttribute("aria-label", labels[name]);
86
+ node.title = labels[name];
87
+ node.append(chevron(name));
88
+ return node;
89
+ };
90
+ const previous = button("previous");
91
+ const next = button("next");
92
+ const counter = doc.createElement("span");
93
+ counter.className = "opf-nav-counter";
94
+ counter.setAttribute("role", "status");
95
+ counter.setAttribute("aria-atomic", "true");
96
+ element.append(previous, counter, next);
97
+ container.append(element);
98
+
99
+ function clamp(value, total) {
100
+ return total === 0 ? 0 : Math.max(0, Math.min(total - 1, value));
101
+ }
102
+ function paint() {
103
+ const total = count();
104
+ index = clamp(index, total);
105
+ counter.textContent = formatSlideCounter(index, total);
106
+ counter.title = labels.position(index, total);
107
+ element.dataset.count = String(total);
108
+ element.dataset.index = String(index);
109
+ const atStart = index <= 0;
110
+ const atEnd = index >= total - 1;
111
+ // A button that becomes disabled while it has focus would drop focus to the page: hand it to the other button.
112
+ const focused = doc.activeElement;
113
+ previous.disabled = total === 0 || atStart;
114
+ next.disabled = total === 0 || atEnd;
115
+ if (focused === previous && previous.disabled && !next.disabled) next.focus();
116
+ else if (focused === next && next.disabled && !previous.disabled) previous.focus();
117
+ }
118
+ function go(target, notify = true) {
119
+ const total = count();
120
+ const clamped = clamp(target, total);
121
+ if (clamped === index) return false;
122
+ index = clamped;
123
+ paint();
124
+ if (notify) options.onChange?.(index);
125
+ return true;
126
+ }
127
+
128
+ previous.addEventListener("click", () => go(index - 1));
129
+ next.addEventListener("click", () => go(index + 1));
130
+ // The arrow keys act when focus is inside the navigator. preventDefault marks the event as handled so a page-level `bindKeys` does not repeat it.
131
+ element.addEventListener("keydown", (event) => {
132
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.isComposing) return;
133
+ const rtl = doc.defaultView?.getComputedStyle(element).direction === "rtl";
134
+ const step = { ArrowLeft: rtl ? 1 : -1, ArrowRight: rtl ? -1 : 1, ArrowUp: -1, ArrowDown: 1 }[event.key];
135
+ if (step !== undefined) { event.preventDefault(); go(index + step); }
136
+ else if (event.key === "Home") { event.preventDefault(); go(0); }
137
+ else if (event.key === "End") { event.preventDefault(); go(count() - 1); }
138
+ });
139
+
140
+ paint();
141
+ const unsubscribe = editor && options.autoRefresh !== false && typeof editor.subscribe === "function" ? editor.subscribe(() => refresh()) : undefined;
142
+
143
+ function refresh() {
144
+ if (disposed) return;
145
+ const before = index;
146
+ paint();
147
+ // The deck shrank under the current slide: the host's index is stale too.
148
+ if (index !== before) options.onChange?.(index);
149
+ }
150
+
151
+ /**
152
+ * Page-level Left and Right arrows (default target: the document). Ignores typing in inputs, selects and editors, menus, dialogs, modified keys and
153
+ * events another handler already took. Returns a function that removes the listener; `destroy()` removes every one.
154
+ */
155
+ function bindKeys(target = doc) {
156
+ const handler = (event) => {
157
+ if (disposed || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.isComposing) return;
158
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
159
+ if (isInteractiveTarget(event.target) || isInteractiveTarget(doc.activeElement)) return;
160
+ const rtl = doc.defaultView?.getComputedStyle(element).direction === "rtl";
161
+ const step = event.key === "ArrowLeft" ? (rtl ? 1 : -1) : rtl ? -1 : 1;
162
+ if (go(index + step)) event.preventDefault();
163
+ };
164
+ target.addEventListener("keydown", handler);
165
+ const unbind = () => { target.removeEventListener("keydown", handler); unbinders.delete(unbind); };
166
+ unbinders.add(unbind);
167
+ return unbind;
168
+ }
169
+
170
+ return {
171
+ element,
172
+ /** The slide index the navigator shows. */
173
+ getSlide: () => index,
174
+ /** Show another slide without calling `onChange` (the host already knows). */
175
+ setSlide(value) {
176
+ if (disposed) return;
177
+ index = clamp(Math.trunc(Number(value) || 0), count());
178
+ paint();
179
+ },
180
+ /** Read the slide count again (it follows the session on its own unless `autoRefresh` is false). */
181
+ refresh,
182
+ bindKeys,
183
+ destroy() {
184
+ if (disposed) return;
185
+ disposed = true;
186
+ unsubscribe?.();
187
+ for (const unbind of [...unbinders]) unbind();
188
+ element.remove();
189
+ },
190
+ };
191
+ }
@@ -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
+ }