@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.
@@ -0,0 +1,206 @@
1
+ import { parseTree } from "jsonc-parser";
2
+
3
+ // Line lookup for a JSON Pointer in the three source formats of the source pane (RR-98). All functions return a
4
+ // one-based line number, or null when the path cannot be placed. JSON is exact; YAML and Markdown are best effort:
5
+ // they follow the shape `toYaml` and `toMarkdown` write (block style, one heading per slide) and fall back to the
6
+ // deepest place they could find.
7
+
8
+ /** Split a JSON Pointer ("/slides/0/title") or a dotted OPF path ("slides.0.title") into segments. */
9
+ export function pathSegments(path) {
10
+ if (Array.isArray(path)) return path.map(String);
11
+ if (path === undefined || path === null || path === "" || path === "/") return [];
12
+ const text = String(path);
13
+ const parts = text.startsWith("/") ? text.slice(1).split("/") : text.split(".");
14
+ return parts.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"));
15
+ }
16
+
17
+ /** The one-based line that holds a zero-based character offset. */
18
+ export function lineOfOffset(text, offset) {
19
+ let line = 1;
20
+ const end = Math.min(offset, text.length);
21
+ for (let i = 0; i < end; i++) {
22
+ const code = text.charCodeAt(i);
23
+ if (code === 10 || (code === 13 && text.charCodeAt(i + 1) !== 10)) line++;
24
+ }
25
+ return line;
26
+ }
27
+
28
+ /** The zero-based character offset where a one-based line starts (a CRLF pair counts as one line break). */
29
+ export function offsetOfLine(text, line) {
30
+ let current = 1;
31
+ if (line <= 1) return 0;
32
+ for (let i = 0; i < text.length; i++) {
33
+ const code = text.charCodeAt(i);
34
+ if (code === 13 && text.charCodeAt(i + 1) === 10) continue;
35
+ if (code === 10 || code === 13) {
36
+ current++;
37
+ if (current === line) return i + 1;
38
+ }
39
+ }
40
+ return text.length;
41
+ }
42
+
43
+ const splitLines = (text) => text.split(/\r\n|\r|\n/);
44
+
45
+ function jsonLine(text, segments) {
46
+ const root = parseTree(text);
47
+ if (!root) return null;
48
+ let node = root, target = root;
49
+ for (const segment of segments) {
50
+ let next;
51
+ if (node.type === "array") {
52
+ const index = /^\d+$/.test(segment) ? Number(segment) : -1;
53
+ next = node.children?.[index];
54
+ } else if (node.type === "object") {
55
+ next = node.children?.find((property) => property.children?.[0]?.value === segment);
56
+ }
57
+ if (!next) break;
58
+ target = next.type === "property" ? next.children[0] : next;
59
+ node = next.type === "property" ? next.children[1] ?? next : next;
60
+ }
61
+ return lineOfOffset(text, target.offset);
62
+ }
63
+
64
+ // One YAML line as its structural entries: block sequence markers and a leading mapping key, each with its column.
65
+ const KEY = /^("(?:[^"\\]|\\.)*"|'(?:[^']|'')*'|[^\s#'"[\]{},&*!|>%@`-][^:#]*?|-[^\s:][^:#]*?)\s*:(?=\s|$)/;
66
+ function unquote(key) {
67
+ if (key.startsWith('"')) { try { return JSON.parse(key); } catch { return key.slice(1, -1); } }
68
+ if (key.startsWith("'")) return key.slice(1, -1).replaceAll("''", "'");
69
+ return key;
70
+ }
71
+ function yamlEntries(lines) {
72
+ const parsed = [];
73
+ let skipBelow = -1;
74
+ lines.forEach((line) => {
75
+ const indent = line.length - line.trimStart().length;
76
+ const body = line.trim();
77
+ if (!body || body.startsWith("#") || body === "---" || body === "...") { parsed.push([]); return; }
78
+ if (skipBelow >= 0) {
79
+ if (indent > skipBelow) { parsed.push([]); return; }
80
+ skipBelow = -1;
81
+ }
82
+ const entries = [];
83
+ let position = indent;
84
+ while (line[position] === "-" && (position + 1 === line.length || line[position + 1] === " ")) {
85
+ entries.push({ kind: "seq", col: position });
86
+ position++;
87
+ while (line[position] === " ") position++;
88
+ }
89
+ const rest = line.slice(position);
90
+ const match = KEY.exec(rest);
91
+ if (match) {
92
+ entries.push({ kind: "key", col: position, key: unquote(match[1]) });
93
+ if (/^[|>][-+0-9]*\s*(#.*)?$/.test(rest.slice(match[0].length).trim())) skipBelow = position;
94
+ }
95
+ parsed.push(entries);
96
+ });
97
+ return parsed;
98
+ }
99
+
100
+ function yamlLine(text, segments) {
101
+ const lines = splitLines(text);
102
+ const entries = yamlEntries(lines);
103
+ let from = 0, to = lines.length, minCol = -1, found = null, sameColSeq = false;
104
+ for (const segment of segments) {
105
+ const isIndex = /^\d+$/.test(segment);
106
+ let childCol = Infinity;
107
+ for (let i = from; i < to; i++) for (const entry of entries[i]) if (entry.col > minCol) childCol = Math.min(childCol, entry.col);
108
+ if (sameColSeq) childCol = minCol + 1;
109
+ let hit = null, seen = 0;
110
+ scan: for (let i = from; i < to; i++) {
111
+ for (const entry of entries[i]) {
112
+ if (entry.col !== childCol) continue;
113
+ if (isIndex && entry.kind === "seq") { if (seen++ === Number(segment)) { hit = { line: i, entry }; break scan; } }
114
+ else if (!isIndex && entry.kind === "key" && entry.key === segment) { hit = { line: i, entry }; break scan; }
115
+ }
116
+ }
117
+ if (!hit) return found === null ? null : found + 1;
118
+ found = hit.line;
119
+ const { entry } = hit;
120
+ // A mapping key with no inline value whose sequence sits at the key's own column ("items:" then "- a").
121
+ const next = entries.findIndex((list, i) => i > hit.line && list.length > 0);
122
+ sameColSeq = entry.kind === "key" && next !== -1 && entries[next][0].kind === "seq" && entries[next][0].col === entry.col
123
+ && !lines[hit.line].slice(lines[hit.line].indexOf(":", entry.col) + 1).trim();
124
+ let end = to;
125
+ for (let i = hit.line + 1; i < to; i++) {
126
+ const first = entries[i][0];
127
+ if (!first) continue;
128
+ if (first.col < entry.col || (first.col === entry.col && !(sameColSeq && first.kind === "seq"))) { end = i; break; }
129
+ }
130
+ from = hit.line;
131
+ to = end;
132
+ minCol = sameColSeq ? entry.col - 1 : entry.col;
133
+ }
134
+ return found === null ? 1 : found + 1;
135
+ }
136
+
137
+ function markdownLine(text, segments) {
138
+ const lines = splitLines(text);
139
+ let bodyStart = 0, frontEnd = -1;
140
+ if (lines[0]?.trim() === "---") {
141
+ frontEnd = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
142
+ if (frontEnd > 0) bodyStart = frontEnd + 1; else frontEnd = -1;
143
+ }
144
+ const slides = [];
145
+ let start = bodyStart, fence = false;
146
+ const close = (end) => {
147
+ let first = start;
148
+ while (first < end && !lines[first].trim()) first++;
149
+ slides.push({ start: first < end ? first : start, end });
150
+ };
151
+ for (let i = bodyStart; i < lines.length; i++) {
152
+ const line = lines[i];
153
+ if (/^(```|~~~)/.test(line)) fence = !fence;
154
+ if (!fence && line.trim() === "---") { close(i); start = i + 1; }
155
+ }
156
+ close(lines.length);
157
+ if (!segments.length) return 1;
158
+ if (segments[0] !== "slides") {
159
+ if (frontEnd < 0) return 1;
160
+ const inside = yamlLine(lines.slice(1, frontEnd).join("\n"), segments);
161
+ return inside === null ? 2 : inside + 1;
162
+ }
163
+ if (segments.length === 1) return slides[0].start + 1;
164
+ const slide = slides[/^\d+$/.test(segments[1]) ? Number(segments[1]) : -1];
165
+ if (!slide) return null;
166
+ if (segments.length === 2) return slide.start + 1;
167
+ const within = (pattern) => {
168
+ let inFence = false;
169
+ for (let i = slide.start; i < slide.end; i++) {
170
+ if (/^(```|~~~)/.test(lines[i])) { if (!inFence && pattern.test(lines[i])) return i + 1; inFence = !inFence; continue; }
171
+ if (!inFence && pattern.test(lines[i])) return i + 1;
172
+ }
173
+ return null;
174
+ };
175
+ const key = segments[2];
176
+ const nthItem = (n) => {
177
+ let count = 0, inFence = false;
178
+ for (let i = slide.start; i < slide.end; i++) {
179
+ if (/^(```|~~~)/.test(lines[i])) { inFence = !inFence; continue; }
180
+ if (!inFence && /^([-*+]|\d+\.)\s/.test(lines[i]) && count++ === n) return i + 1;
181
+ }
182
+ return within(/^\s*([-*+]|\d+\.)\s/);
183
+ };
184
+ const native = key === "title" ? within(/^# /)
185
+ : key === "subtitle" ? within(/^## /)
186
+ : key === "notes" ? within(/^Notes?:/)
187
+ : key === "items" ? (/^\d+$/.test(segments[3] ?? "") ? nthItem(Number(segments[3])) : within(/^\s*([-*+]|\d+\.)\s/))
188
+ : key === "text" ? within(/^(?!#|>|\s*([-*+]|\d+\.)\s|<!--|```|~~~|Notes?:)\S/)
189
+ : null;
190
+ if (native !== null) return native;
191
+ const embedded = within(key === "blocks" ? /^(```|~~~)opf-block/ : /^(```|~~~)opf-slide/);
192
+ return embedded ?? slide.start + 1;
193
+ }
194
+
195
+ /** The one-based line of a path in a source text of the given format ("json", "yaml" or "md"), or null. */
196
+ export function lineForPath(format, text, path) {
197
+ const segments = pathSegments(path);
198
+ if (!segments.length) return 1;
199
+ try {
200
+ return format === "json" ? jsonLine(text, segments)
201
+ : format === "yaml" ? yamlLine(text, segments)
202
+ : markdownLine(text, segments);
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
@@ -0,0 +1,90 @@
1
+ import type { EditorSession } from "./index.js";
2
+ import type { JsonCatalogContext } from "./json-options.js";
3
+ import type { JsonCodeEditor } from "./json-editor.js";
4
+
5
+ /** The source formats of the pane: `json` is canonical; `yaml` and `md` are core's `@openpresentation/opf/yaml` and `/markdown`. */
6
+ export type SourceFormat = "json" | "yaml" | "md";
7
+ export type SourcePaneDensity = "comfortable" | "touch";
8
+ export type SourceFindingSeverity = "error" | "warning" | "info";
9
+
10
+ /** One finding under the code. Core validator `Finding` objects fit: they carry `path`, `message` and `severity`. */
11
+ export interface SourceFinding {
12
+ /** A JSON Pointer into the document (`/slides/0/title`); "" is the document or a position-only finding. */
13
+ path: string;
14
+ message: string;
15
+ severity: SourceFindingSeverity;
16
+ /** A one-based line in the pane's current text. Used when the pane cannot place `path` itself and for syntax errors. */
17
+ line?: number;
18
+ /** `schema` (the default count label), `syntax`, `format` (a note about what the format cannot hold) or `rejected` (the session refused the edit). */
19
+ kind?: string;
20
+ ruleId?: string;
21
+ }
22
+
23
+ /** What a host receives when the pane copies or downloads. */
24
+ export interface SourceFile {
25
+ /** The name without the extension, spaces and slashes already replaced by `-`. */
26
+ name: string;
27
+ /** `<name>.opf.<json|yaml|md>` */
28
+ filename: string;
29
+ text: string;
30
+ format: SourceFormat;
31
+ mime: string;
32
+ }
33
+
34
+ export interface SourcePaneOptions {
35
+ /** The editor session. Every edit in any format reaches it as a patch (`applyPatch`), so it is validated and undoable. */
36
+ editor: EditorSession;
37
+ /** File name without extension (default `deck`). Spaces and slashes become `-`. */
38
+ name?: string;
39
+ onRename?(name: string): void;
40
+ /** The starting format (default `json`). */
41
+ format?: SourceFormat;
42
+ onFormatChange?(format: SourceFormat, previous: SourceFormat): void;
43
+ /** The JSON text as the author wrote it. Used when it parses to the session's document, so its spelling is kept. */
44
+ source?: string;
45
+ /** Findings to list. Without `findings` and `getFindings` the pane lists the session's own validation. */
46
+ findings?: readonly (SourceFinding | Record<string, unknown>)[];
47
+ /** Called whenever the list is rebuilt, so a host can pass core validator output without pushing it. */
48
+ getFindings?(context: { editor: EditorSession; format: SourceFormat; text: string }): readonly (SourceFinding | Record<string, unknown>)[];
49
+ /** A row was clicked (after the cursor moved). `line` is the line it moved to. */
50
+ onFindingSelect?(finding: SourceFinding, line: number): void;
51
+ /** Replace the default (`navigator.clipboard.writeText`). */
52
+ onCopy?(file: SourceFile): void | Promise<void>;
53
+ /** Replace the default (a Blob link named `<name>.opf.<ext>`). */
54
+ onDownload?(file: SourceFile): void | Promise<void>;
55
+ density?: SourcePaneDensity;
56
+ readOnly?: boolean;
57
+ /** Milliseconds a typed edit waits before it is applied to the session (default 250; 0 applies each change). */
58
+ applyDelay?: number;
59
+ /** Accessible name of the code editor (default `OPF source`). */
60
+ label?: string;
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;
64
+ onError?(error: unknown): void;
65
+ }
66
+
67
+ export interface SourcePane {
68
+ element: HTMLElement;
69
+ /** Re-read the document from the session. */
70
+ refresh(): void;
71
+ /** Replace the host findings; `null` returns to the session's own validation. */
72
+ setFindings(findings: readonly (SourceFinding | Record<string, unknown>)[] | null): void;
73
+ /** Switch format. Returns false, and says why in the findings, when the document cannot be written in it. Does not call `onFormatChange`. */
74
+ setFormat(format: SourceFormat): boolean;
75
+ getFormat(): SourceFormat;
76
+ /** Move the cursor to the line of a JSON Pointer (or dotted OPF path) in the current format; best effort for YAML and Markdown. Returns the line. */
77
+ focusPath(path: string): number;
78
+ /** The text as shown, with any pending edit applied first. */
79
+ getText(): string;
80
+ getName(): string;
81
+ setName(name: string): void;
82
+ setDensity(density: SourcePaneDensity): void;
83
+ setReadOnly(readOnly: boolean): void;
84
+ /** The underlying code editor (`mountJsonCodeEditor`). */
85
+ readonly editor: JsonCodeEditor;
86
+ destroy(): void;
87
+ }
88
+
89
+ /** Mount the source pane in `container`: file name, format menu, Format, Copy and Download, the code, and the findings list. */
90
+ export declare function createSourcePane(container: HTMLElement, options: SourcePaneOptions): SourcePane;