@openpresentation/opf-editor 0.26.5 → 0.26.7

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,106 @@
1
+ // The keyboard shortcuts dialog (artifacts/source-editor/SPEC.md, section 8.6): every binding the editor installed, grouped and searchable, with the keys
2
+ // of the platform. The list is `getShortcuts()`, produced from the same table that installs the keymap (source-keymap.js), so it cannot drift from the keys.
3
+ // Cmd/Ctrl+K Cmd/Ctrl+S, F1 or the "?" button of the source pane open it; Escape closes it and returns focus to the editor. A host that wants its own
4
+ // dialog passes `onShowShortcuts(shortcuts)` and returns true. Plain DOM, loaded lazily.
5
+ const FOCUSABLE = 'button, input, [tabindex]:not([tabindex="-1"])';
6
+ let counter = 0;
7
+ const open = new WeakMap();
8
+
9
+ const element = (doc, tag, className, attributes = {}) => {
10
+ const node = doc.createElement(tag);
11
+ if (className) node.className = className;
12
+ for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value);
13
+ return node;
14
+ };
15
+
16
+ /** The words a search looks in: title, group, id and the keys with `+`, `-` and spaces folded to spaces. */
17
+ const haystack = (row) => `${row.title} ${row.group} ${row.id} ${row.when ?? ""} ${row.keys.join(" ")}`.toLowerCase().replace(/[+-]/g, " ");
18
+ export const matches = (row, query) => {
19
+ const words = query.toLowerCase().replace(/[+-]/g, " ").split(/\s+/).filter(Boolean);
20
+ const text = haystack(row);
21
+ return words.every((word) => text.includes(word));
22
+ };
23
+
24
+ /** Open the dialog for `view` (or close it when it is open). Returns true: the key was taken. */
25
+ export function showShortcuts(view, context) {
26
+ const shortcuts = context.getShortcuts();
27
+ if (context.options.onShowShortcuts?.(shortcuts) === true) return true;
28
+ const existing = open.get(view);
29
+ if (existing) { existing.close(); return true; }
30
+ const doc = view.dom.ownerDocument;
31
+ const host = view.dom.closest(".opf-ui") ?? doc.body;
32
+ const opener = doc.activeElement;
33
+ const id = `opf-shortcuts-${++counter}`;
34
+ const root = element(doc, "div", `opf-shortcuts${host === doc.body ? " opf-ui" : ""}`);
35
+ const dialog = element(doc, "div", "opf-shortcuts-dialog", { role: "dialog", "aria-modal": "true", "aria-labelledby": `${id}-title`, "aria-describedby": `${id}-help`, tabindex: "-1" });
36
+ const head = element(doc, "div", "opf-shortcuts-head");
37
+ const title = Object.assign(element(doc, "h2", "opf-shortcuts-title", { id: `${id}-title` }), { textContent: "Keyboard shortcuts" });
38
+ const closeButton = Object.assign(element(doc, "button", "opf-shortcuts-close", { type: "button", "aria-label": "Close the keyboard shortcuts" }), { textContent: "×" });
39
+ head.append(title, closeButton);
40
+ const help = Object.assign(element(doc, "p", "opf-shortcuts-help", { id: `${id}-help` }), { textContent: `Keys for ${context.platform === "mac" ? "macOS" : context.platform === "win" ? "Windows" : "Linux"}. Type to search by name or key; Escape closes.` });
41
+ const search = element(doc, "input", "opf-shortcuts-search", { type: "search", "aria-label": "Search the keyboard shortcuts", placeholder: "Search shortcuts", autocomplete: "off", spellcheck: "false" });
42
+ const status = element(doc, "div", "opf-shortcuts-status", { role: "status", "aria-live": "polite" });
43
+ const list = element(doc, "div", "opf-shortcuts-list", { tabindex: "0", role: "region", "aria-label": "Shortcuts" });
44
+ dialog.append(head, help, search, status, list);
45
+ root.append(dialog);
46
+ host.append(root);
47
+
48
+ const plural = (count) => `${count} ${count === 1 ? "shortcut" : "shortcuts"}`;
49
+ function render() {
50
+ const rows = shortcuts.filter((row) => matches(row, search.value));
51
+ list.replaceChildren();
52
+ let group = null, section = null;
53
+ for (const row of rows) {
54
+ if (row.group !== group) {
55
+ group = row.group;
56
+ section = element(doc, "section", "opf-shortcuts-group", { "aria-label": group });
57
+ section.append(Object.assign(element(doc, "h3", "opf-shortcuts-group-title"), { textContent: group }));
58
+ list.append(section);
59
+ }
60
+ const item = element(doc, "div", "opf-shortcuts-row", { "data-id": row.id });
61
+ const name = element(doc, "span", "opf-shortcuts-name");
62
+ name.append(document.createTextNode(row.title));
63
+ if (row.when) name.append(Object.assign(element(doc, "span", "opf-shortcuts-when"), { textContent: ` (${row.when})` }));
64
+ const keys = element(doc, "span", "opf-shortcuts-keys");
65
+ row.keys.forEach((keyText, index) => {
66
+ if (index) keys.append(Object.assign(element(doc, "span", "opf-shortcuts-or"), { textContent: "or" }));
67
+ for (const stroke of keyText.split(" ")) keys.append(Object.assign(element(doc, "kbd", "opf-shortcuts-key"), { textContent: stroke }));
68
+ });
69
+ item.append(name, keys);
70
+ section.append(item);
71
+ }
72
+ if (!rows.length) list.append(Object.assign(element(doc, "p", "opf-shortcuts-none"), { textContent: "No shortcut matches." }));
73
+ status.textContent = search.value ? `${plural(rows.length)} match` : plural(rows.length);
74
+ }
75
+
76
+ function focusables() { return [...dialog.querySelectorAll(FOCUSABLE)].filter((node) => node.getClientRects().length); }
77
+ function onKeydown(event) {
78
+ if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); return; }
79
+ if (event.key !== "Tab") return;
80
+ const nodes = focusables();
81
+ if (!nodes.length) { event.preventDefault(); dialog.focus(); return; }
82
+ const first = nodes[0], last = nodes.at(-1), active = doc.activeElement;
83
+ if (event.shiftKey && (active === first || active === dialog)) { event.preventDefault(); last.focus(); }
84
+ else if (!event.shiftKey && active === last) { event.preventDefault(); first.focus(); }
85
+ else if (!dialog.contains(active)) { event.preventDefault(); first.focus(); }
86
+ }
87
+ function onFocusin(event) { if (!dialog.contains(event.target)) (focusables()[0] ?? dialog).focus(); }
88
+ function close() {
89
+ if (!open.has(view)) return;
90
+ open.delete(view);
91
+ doc.removeEventListener("focusin", onFocusin, true);
92
+ root.remove();
93
+ if (view.dom.isConnected) view.focus(); else if (opener?.isConnected) opener.focus();
94
+ context.say("Keyboard shortcuts closed");
95
+ }
96
+ open.set(view, { close });
97
+ root.addEventListener("keydown", onKeydown);
98
+ root.addEventListener("pointerdown", (event) => { if (event.target === root) close(); });
99
+ closeButton.addEventListener("click", close);
100
+ search.addEventListener("input", render);
101
+ doc.addEventListener("focusin", onFocusin, true);
102
+ render();
103
+ search.focus();
104
+ context.say(`Keyboard shortcuts, ${plural(shortcuts.length)}. Type to search, Escape to close.`);
105
+ return true;
106
+ }
@@ -0,0 +1,156 @@
1
+ // The outline of an OPF document (artifacts/source-editor/SPEC.md, section 8.4): the symbols the picker (Cmd/Ctrl+Shift+O) and the outline panel list, for
2
+ // JSON, YAML and Markdown, read from text that may be broken. A symbol is `{ id, kind, label, detail, depth, from, to, line }` with source offsets:
3
+ // the slides in order ("3. Highlights"), the top-level sections (design, organization, speaker), the assets, datasets, variables and references the
4
+ // document declares, and the catalog records it embeds. Plain functions, no DOM.
5
+ import { parseTree } from "jsonc-parser";
6
+ import { isMap, parseDocument } from "yaml";
7
+ import { scanMarkdown } from "@openpresentation/opf/source";
8
+
9
+ const SECTIONS = ["design", "organization", "speaker"];
10
+ const DECLARED = [["assets", "asset"], ["datasets", "dataset"], ["variables", "variable"]];
11
+ const CATALOG_KINDS = { layouts: "layout", themes: "theme", colorSchemes: "color scheme", fontSchemes: "font scheme", narratives: "narrative", audiences: "audience", purposes: "purpose", tones: "tone" };
12
+
13
+ // ---- one tree for the three readers ------------------------------------------------------------------------------------------------------
14
+ // `{ type: "object" | "array" | "scalar", value?, from, to, entries?: [{ key, from, to, node }], items?: [node] }`, offsets in the text.
15
+ function fromJson(text) {
16
+ const root = parseTree(text, [], { allowTrailingComma: true, disallowComments: false });
17
+ const convert = (node) => {
18
+ if (!node) return null;
19
+ const base = { from: node.offset, to: node.offset + node.length };
20
+ if (node.type === "object") return { ...base, type: "object", entries: (node.children ?? []).filter((property) => property.children?.[0]).map((property) => ({ key: String(property.children[0].value), from: property.offset, to: property.offset + property.length, node: convert(property.children[1]) })) };
21
+ if (node.type === "array") return { ...base, type: "array", items: (node.children ?? []).map(convert) };
22
+ return { ...base, type: "scalar", value: node.value };
23
+ };
24
+ return convert(root);
25
+ }
26
+
27
+ function fromYamlNode(node, shift = 0) {
28
+ if (!node) return null;
29
+ const range = node.range ?? [0, 0, 0];
30
+ const base = { from: range[0] + shift, to: range[1] + shift };
31
+ if (isMap(node)) {
32
+ return { ...base, type: "object", entries: node.items.filter((pair) => pair.key).map((pair) => ({ key: String(pair.key.value ?? pair.key), from: (pair.key.range?.[0] ?? range[0]) + shift, to: (pair.value?.range?.[1] ?? pair.key.range?.[1] ?? range[1]) + shift, node: fromYamlNode(pair.value, shift) })) };
33
+ }
34
+ if (node.items) return { ...base, type: "array", items: node.items.map((item) => fromYamlNode(item, shift)) };
35
+ return { ...base, type: "scalar", value: node.value };
36
+ }
37
+ function fromYaml(text, shift = 0) {
38
+ try { return fromYamlNode(parseDocument(text, { strict: false, uniqueKeys: false }).contents, shift); } catch { return null; }
39
+ }
40
+
41
+ const lineStarts = (text) => { const starts = [0]; for (let i = 0; i < text.length; i++) if (text[i] === "\n") starts.push(i + 1); return starts; };
42
+ const lineOf = (starts, offset) => { let low = 0, high = starts.length - 1; while (low < high) { const mid = (low + high + 1) >> 1; if (starts[mid] <= offset) low = mid; else high = mid - 1; } return low + 1; };
43
+
44
+ // ---- helpers -----------------------------------------------------------------------------------------------------------------------------
45
+ const entry = (node, key) => (node?.type === "object" ? node.entries.find((candidate) => candidate.key === key) : undefined);
46
+ const scalar = (node) => (node?.type === "scalar" && typeof node.value === "string" ? node.value : undefined);
47
+ /** A title as text: a string, or the text of a list of runs. */
48
+ function titleOf(node) {
49
+ if (!node) return "";
50
+ if (node.type === "scalar") return typeof node.value === "string" ? node.value : "";
51
+ if (node.type === "array") return node.items.map((item) => (item?.type === "scalar" ? String(item.value ?? "") : titleOf(entry(item, "text")?.node))).join("");
52
+ return "";
53
+ }
54
+ const clip = (text, max = 80) => { const flat = text.replace(/\s+/g, " ").trim(); return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat; };
55
+
56
+ function symbolsOfTree(root, starts, { slides = true } = {}) {
57
+ const out = [];
58
+ const add = (kind, label, detail, depth, from, to) => out.push({ id: `${kind}:${out.length}`, kind, label, detail, depth, from, to, line: lineOf(starts, from) });
59
+ if (root?.type !== "object") return out;
60
+ if (slides) {
61
+ const list = entry(root, "slides")?.node;
62
+ if (list?.type === "array") {
63
+ list.items.forEach((item, index) => {
64
+ if (!item) return;
65
+ const title = clip(titleOf(entry(item, "title")?.node));
66
+ const layout = scalar(entry(item, "layout")?.node), type = scalar(entry(item, "type")?.node);
67
+ add("slide", `${index + 1}. ${title || "Untitled slide"}`, [layout && `layout ${layout}`, !layout && type].filter(Boolean).join(" "), 0, item.from, item.to);
68
+ });
69
+ }
70
+ }
71
+ for (const key of SECTIONS) {
72
+ const found = entry(root, key);
73
+ if (found?.node?.type === "object") add("section", key, "", 0, found.from, found.to);
74
+ }
75
+ for (const [key, kind] of DECLARED) {
76
+ const found = entry(root, key);
77
+ if (found?.node?.type !== "object" || !found.node.entries.length) continue;
78
+ add("section", key, `${found.node.entries.length}`, 0, found.from, found.to);
79
+ for (const child of found.node.entries) add(kind, child.key, kind === "dataset" ? scalar(entry(child.node, "title")?.node) ?? "" : "", 1, child.from, child.to);
80
+ }
81
+ const references = entry(root, "references")?.node;
82
+ if (references?.type === "array" && references.items.length) {
83
+ add("section", "references", `${references.items.length}`, 0, entry(root, "references").from, entry(root, "references").to);
84
+ for (const item of references.items) { const id = scalar(entry(item, "id")?.node); if (id && item) add("reference", id, clip(scalar(entry(item, "text")?.node) ?? "", 50), 1, item.from, item.to); }
85
+ }
86
+ const catalogs = entry(root, "catalogs");
87
+ if (catalogs?.node?.type === "object") {
88
+ for (const group of catalogs.node.entries) {
89
+ if (group.node?.type !== "object") continue;
90
+ for (const [kindKey, kindLabel] of Object.entries(CATALOG_KINDS)) {
91
+ const records = entry(group.node, kindKey)?.node;
92
+ if (records?.type === "object") for (const record of records.entries) add("record", `${kindLabel} ${record.key}`, group.key === "custom" ? "this document" : group.key, 0, record.from, record.to);
93
+ else if (records?.type === "array") for (const record of records.items) { const id = scalar(entry(record, "id")?.node); if (id && record) add("record", `${kindLabel} ${id}`, group.key === "custom" ? "this document" : group.key, 0, record.from, record.to); }
94
+ }
95
+ }
96
+ }
97
+ // Slides first, as the document reads: sort the rest by position inside their kind order is not needed; the list keeps slides, then the sections in
98
+ // the order of the text so the picker's order is the document's order.
99
+ return out.sort((a, b) => a.from - b.from).map((symbol, index) => ({ ...symbol, id: `${symbol.kind}:${index}` }));
100
+ }
101
+
102
+ // ---- Markdown ----------------------------------------------------------------------------------------------------------------------------
103
+ function markdownSymbols(text, starts) {
104
+ const scan = scanMarkdown(text);
105
+ const out = [];
106
+ if (scan.frontMatter?.closed) {
107
+ const tree = fromYaml(text.slice(scan.frontMatter.yamlFrom, scan.frontMatter.yamlTo), scan.frontMatter.yamlFrom);
108
+ out.push(...symbolsOfTree(tree, starts, { slides: false }));
109
+ }
110
+ const skipped = [...scan.fences, ...scan.comments].map((range) => [range.from, range.to]);
111
+ for (const slide of scan.slides) {
112
+ let title = "", layout;
113
+ const slideComment = scan.comments.find((comment) => comment.kind === "slide" && comment.slide === slide.index);
114
+ if (slideComment) layout = /\blayout=("?)([A-Za-z0-9_:-]+)\1/.exec(text.slice(slideComment.innerFrom, slideComment.innerTo))?.[2];
115
+ const body = text.slice(slide.from, slide.to);
116
+ let offset = 0;
117
+ for (const line of body.split("\n")) {
118
+ const at = slide.from + offset;
119
+ offset += line.length + 1;
120
+ if (skipped.some(([from, to]) => at >= from && at < to)) continue;
121
+ const heading = /^#\s+(.*?)\s*#*\s*$/.exec(line);
122
+ if (heading) { title = heading[1].replace(/[*_`[\]]/g, ""); break; }
123
+ }
124
+ out.push({ id: "", kind: "slide", label: `${slide.index + 1}. ${clip(title) || "Untitled slide"}`, detail: layout ? `layout ${layout}` : "", depth: 0, from: slide.from, to: slide.to, line: slide.line });
125
+ }
126
+ return out.sort((a, b) => a.from - b.from).map((symbol, index) => ({ ...symbol, id: `${symbol.kind}:${index}` }));
127
+ }
128
+
129
+ /** The symbols of `text` in `format` ("json", "yaml" or "md"), in document order. Never throws. */
130
+ export function documentSymbols(format, text) {
131
+ try {
132
+ const starts = lineStarts(text);
133
+ const symbols = format === "md" ? markdownSymbols(text, starts) : symbolsOfTree(format === "yaml" ? fromYaml(text) : fromJson(text), starts);
134
+ // A YAML block ends with its line break: the range of a symbol stops at its last visible character.
135
+ return symbols.map((symbol) => { let to = Math.min(symbol.to, text.length); while (to > symbol.from + 1 && /\s/.test(text[to - 1])) to--; return { ...symbol, to }; });
136
+ } catch { return []; }
137
+ }
138
+
139
+ /** The innermost symbol that contains `offset` (the slide the caret is in), or null. */
140
+ export function symbolAt(symbols, offset) {
141
+ let found = null;
142
+ for (const symbol of symbols) if (symbol.from <= offset && offset <= symbol.to && (!found || symbol.to - symbol.from <= found.to - found.from)) found = symbol;
143
+ return found;
144
+ }
145
+
146
+ /** The symbols that match what was typed: every word is in the label, detail or kind; failing that, the letters in order (a fuzzy match). */
147
+ export function filterSymbols(symbols, query) {
148
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
149
+ if (!words.length) return symbols;
150
+ const text = (symbol) => `${symbol.label} ${symbol.detail} ${symbol.kind}`.toLowerCase();
151
+ const exact = symbols.filter((symbol) => words.every((word) => text(symbol).includes(word)));
152
+ if (exact.length) return exact;
153
+ const letters = words.join("");
154
+ return symbols.filter((symbol) => { let at = 0; for (const character of text(symbol)) if (character === letters[at]) at++; return at === letters.length; });
155
+ }
156
+
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The worker script of the source pane's text checks. It exports nothing: bundle it as a module worker script and pass the worker to
3
+ * `createSourcePane(container, { worker })`, for example
4
+ * `worker: () => new Worker(new URL("@openpresentation/opf-editor/source-worker", import.meta.url), { type: "module" })`
5
+ * with a bundler that supports that form, or build `dist/source-worker.js` as its own entry point and serve it next to the page.
6
+ */
7
+ export {};
@@ -0,0 +1,30 @@
1
+ // The worker entry of the source pane's text checks (`createSourcePane(el, { worker })`). A host bundles this file as its own worker script
2
+ // (`@openpresentation/opf-editor/source-worker`), for example `new Worker(new URL("@openpresentation/opf-editor/source-worker", import.meta.url), { type: "module" })`
3
+ // with a bundler that understands that form, or builds it as an extra entry point and serves it next to the page. It does the same work as the pane's
4
+ // own inline path (`./source-check.js`), off the main thread. Requests run in order, but when several checks of one kind are waiting only the newest runs
5
+ // (the older ones are answered with `skipped`): the pane drops stale replies anyway.
6
+ import { createCheckService } from "./source-check.js";
7
+
8
+ const service = createCheckService();
9
+ let queue = [];
10
+ let scheduled = false;
11
+
12
+ function drain() {
13
+ scheduled = false;
14
+ const work = queue;
15
+ queue = [];
16
+ // The last check and the last syntax request win; catalogs messages always run, in order, and before the checks that follow them.
17
+ const newest = new Map();
18
+ work.forEach((message, index) => { if (message.type === "check" || message.type === "syntax") newest.set(message.type, index); });
19
+ work.forEach((message, index) => {
20
+ if ((message.type === "check" || message.type === "syntax") && newest.get(message.type) !== index) { self.postMessage({ id: message.id, ok: true, skipped: true }); return; }
21
+ const reply = service.handle(message);
22
+ try { self.postMessage(reply); } catch (error) { self.postMessage({ id: message.id, ok: false, error: `The result could not be sent: ${error?.message ?? error}` }); }
23
+ });
24
+ }
25
+
26
+ self.onmessage = (event) => {
27
+ queue.push(event.data);
28
+ // Messages that arrive while a check runs queue behind it; draining on the next task lets everything already waiting be seen together.
29
+ if (!scheduled) { scheduled = true; setTimeout(drain, 0); }
30
+ };
@@ -0,0 +1,55 @@
1
+ import { RangeSetBuilder, countColumn } from "@codemirror/state";
2
+ import { Decoration, ViewPlugin } from "@codemirror/view";
3
+
4
+ // A wrapped line continues under its own indentation, as VS Code's `wrappingIndent: "indent"` does (JSON, YAML and Markdown).
5
+ // Each line carries a class `opf-hang-<n>`; the stylesheet (panels.css) turns n columns into a left padding and the same negative
6
+ // text-indent, so the first line starts where it always did and every continuation line starts n columns in. n is the line's leading
7
+ // whitespace (a tab counts as the editor's tab size) plus the width of a list marker ("- ", "1. ") when the line starts one, so wrapped
8
+ // list items line up under their text, or plus two columns (one indent step) for any other line, so a continuation never looks like a new line.
9
+
10
+ /** The most columns a line can hang. The stylesheet defines `opf-hang-0` through `opf-hang-${MAX_HANG}`, and caps the indent at 40% of the line. */
11
+ export const MAX_HANG = 32;
12
+ const LIST_MARKER = /^([-*+]|\d{1,9}[.)])[ \t]+/;
13
+ const OFFSET = 2;
14
+
15
+ /** How many columns the continuation lines of `text` are indented. */
16
+ export function hangColumns(text, tabSize = 4) {
17
+ let at = 0;
18
+ while (at < text.length && (text[at] === " " || text[at] === "\t")) at++;
19
+ const indent = countColumn(text.slice(0, at), tabSize);
20
+ const marker = LIST_MARKER.exec(text.slice(at));
21
+ const columns = marker ? indent + countColumn(marker[0], tabSize) : indent + OFFSET;
22
+ return Math.min(columns, MAX_HANG);
23
+ }
24
+
25
+ const decorations = Array.from({ length: MAX_HANG + 1 }, (_, columns) => Decoration.line({ class: `opf-hang-${columns}` }));
26
+
27
+ function build(view) {
28
+ const builder = new RangeSetBuilder();
29
+ const { doc, tabSize } = view.state;
30
+ let last = -1;
31
+ for (const { from, to } of view.visibleRanges) {
32
+ for (let position = from; position <= to;) {
33
+ const line = doc.lineAt(position);
34
+ if (line.from > last) {
35
+ builder.add(line.from, line.from, decorations[hangColumns(line.text, tabSize)]);
36
+ last = line.from;
37
+ }
38
+ position = line.to + 1;
39
+ }
40
+ }
41
+ return builder.finish();
42
+ }
43
+
44
+ /** The extension: line decorations for the visible lines (a line is decorated again when the document, the viewport or the tab size changes). */
45
+ export const hangingIndent = ViewPlugin.fromClass(
46
+ class {
47
+ constructor(view) {
48
+ this.decorations = build(view);
49
+ }
50
+ update(update) {
51
+ if (update.docChanged || update.viewportChanged || update.startState.tabSize !== update.state.tabSize) this.decorations = build(update.view);
52
+ }
53
+ },
54
+ { decorations: (plugin) => plugin.decorations },
55
+ );