@openpresentation/opf-editor 0.24.0 → 0.24.1
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 +152 -2
- package/dist/agent-handoff-dialog.d.ts +45 -0
- package/dist/agent-handoff-dialog.js +182 -0
- package/dist/agent-handoff.d.ts +33 -0
- package/dist/agent-handoff.js +95 -0
- package/dist/block-controls.js +8 -8
- package/dist/export-dom.js +57 -0
- package/dist/export-menu.d.ts +125 -0
- package/dist/export-menu.js +392 -0
- package/dist/json-editor.d.ts +7 -0
- package/dist/json-editor.js +67 -7
- package/dist/json-source.js +2 -1
- package/dist/slide-navigator.d.ts +44 -0
- package/dist/slide-navigator.js +191 -0
- package/dist/source-lines.js +206 -0
- package/dist/source-pane.d.ts +88 -0
- package/dist/source-pane.js +664 -0
- package/dist/styles.css +3339 -0
- package/dist/thumbnail-cache.d.ts +25 -0
- package/dist/thumbnail-cache.js +50 -0
- package/dist/thumbnail-strip.d.ts +50 -0
- package/dist/thumbnail-strip.js +310 -0
- package/package.json +44 -7
|
@@ -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,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,88 @@
|
|
|
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
|
+
onError?(error: unknown): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SourcePane {
|
|
66
|
+
element: HTMLElement;
|
|
67
|
+
/** Re-read the document from the session. */
|
|
68
|
+
refresh(): void;
|
|
69
|
+
/** Replace the host findings; `null` returns to the session's own validation. */
|
|
70
|
+
setFindings(findings: readonly (SourceFinding | Record<string, unknown>)[] | null): void;
|
|
71
|
+
/** Switch format. Returns false, and says why in the findings, when the document cannot be written in it. Does not call `onFormatChange`. */
|
|
72
|
+
setFormat(format: SourceFormat): boolean;
|
|
73
|
+
getFormat(): SourceFormat;
|
|
74
|
+
/** 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. */
|
|
75
|
+
focusPath(path: string): number;
|
|
76
|
+
/** The text as shown, with any pending edit applied first. */
|
|
77
|
+
getText(): string;
|
|
78
|
+
getName(): string;
|
|
79
|
+
setName(name: string): void;
|
|
80
|
+
setDensity(density: SourcePaneDensity): void;
|
|
81
|
+
setReadOnly(readOnly: boolean): void;
|
|
82
|
+
/** The underlying code editor (`mountJsonCodeEditor`). */
|
|
83
|
+
readonly editor: JsonCodeEditor;
|
|
84
|
+
destroy(): void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Mount the source pane in `container`: file name, format menu, Format, Copy and Download, the code, and the findings list. */
|
|
88
|
+
export declare function createSourcePane(container: HTMLElement, options: SourcePaneOptions): SourcePane;
|