@openpresentation/opf-editor 0.26.5 → 0.26.6

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
+ };
package/dist/styles.css CHANGED
@@ -3707,6 +3707,26 @@ input.opf-lx-input[data-field="max"] { font-family: var(--opf-ui-mono); font-siz
3707
3707
  .opf-fix[aria-selected="true"] { background: var(--opf-ui-accent); color: var(--opf-ui-accent-fg); }
3708
3708
  :root :is(.opf-ui, .opf-json, .opf-code) .cm-tooltip .cm-completionInfo { white-space: normal; }
3709
3709
 
3710
+ /* The shape hint (Cmd/Ctrl+Shift+Space): the fields of the object around the caret, each set, next or optional. The state is a word and a mark, not only a color. */
3711
+ .cm-tooltip.opf-hint { display: flex; flex-direction: column; min-width: 22em; max-width: 38em; padding: 4px 0; font: 12.5px var(--opf-ui-font); white-space: normal; }
3712
+ .opf-hint-head { display: flex; justify-content: space-between; gap: 24px; padding: 2px 12px 5px; border-bottom: 1px solid var(--opf-ui-border); }
3713
+ .opf-hint-title { font: 600 12px var(--opf-ui-mono); }
3714
+ .opf-hint-aside { color: var(--opf-ui-muted-fg); font-size: 11.5px; white-space: nowrap; }
3715
+ .opf-hint-rows { display: flex; flex-direction: column; margin: 0; padding: 3px 0 0; list-style: none; }
3716
+ .opf-hint-row { display: grid; grid-template-columns: 8em minmax(0, 1fr) 5.5em; gap: 12px; align-items: baseline; padding: 1px 12px; }
3717
+ .opf-hint-key { font: 12px var(--opf-ui-mono); overflow-wrap: anywhere; }
3718
+ .opf-hint-description { color: var(--opf-ui-muted-fg); }
3719
+ .opf-hint-state { text-align: right; color: var(--opf-ui-muted-fg); font-size: 11.5px; }
3720
+ .opf-hint-row[data-state="set"] .opf-hint-state::before { content: "\2713\00a0"; }
3721
+ .opf-hint-row[data-state="next"] { background: color-mix(in srgb, var(--opf-ui-accent) 12%, transparent); box-shadow: inset 2px 0 0 var(--opf-ui-accent); }
3722
+ .opf-hint-row[data-state="next"] .opf-hint-key,
3723
+ .opf-hint-row[data-state="next"] .opf-hint-state { color: var(--opf-ui-accent-strong); font-weight: 600; }
3724
+ .opf-hint-row[data-state="next"] .opf-hint-state::before { content: "\25B6\00a0"; }
3725
+ /* No animation either way; the rule is explicit so a later one cannot add motion for someone who asked for less. */
3726
+ @media (prefers-reduced-motion: reduce) {
3727
+ .cm-tooltip.opf-hint, .cm-tooltip.opf-hint * { animation: none !important; transition: none !important; }
3728
+ }
3729
+
3710
3730
  /* Ctrl+Space with the list open hides the documentation panel (and shows it again). */
3711
3731
  .cm-editor.opf-docs-off .cm-completionInfo { display: none; }
3712
3732
 
@@ -4014,6 +4034,208 @@ input.opf-lx-input[data-field="max"] { font-family: var(--opf-ui-mono); font-siz
4014
4034
  .opf-present-button { transition: none; }
4015
4035
  }
4016
4036
 
4037
+ /* shortcuts.css */
4038
+ /* The keyboard shortcuts dialog (src/source-shortcuts.js). Only --opf-ui-* tokens, no color literals (test/styles.mjs). */
4039
+ .opf-shortcuts,
4040
+ .opf-shortcuts *,
4041
+ .opf-shortcuts *::before,
4042
+ .opf-shortcuts *::after { box-sizing: border-box; }
4043
+ .opf-shortcuts [hidden] { display: none !important; }
4044
+ .opf-shortcuts {
4045
+ position: fixed;
4046
+ inset: 0;
4047
+ z-index: 70;
4048
+ display: grid;
4049
+ grid-template-columns: minmax(0, 640px);
4050
+ justify-content: center;
4051
+ align-content: center;
4052
+ padding: 16px;
4053
+ background: color-mix(in srgb, var(--opf-ui-fg) 32%, transparent);
4054
+ font-family: var(--opf-ui-font);
4055
+ font-size: 13px;
4056
+ line-height: 1.5;
4057
+ color: var(--opf-ui-fg);
4058
+ }
4059
+ .opf-shortcuts .opf-shortcuts-dialog {
4060
+ display: flex;
4061
+ flex-direction: column;
4062
+ gap: 8px;
4063
+ width: 100%;
4064
+ max-height: min(720px, calc(100vh - 32px));
4065
+ padding: 16px 18px 12px;
4066
+ overflow: hidden;
4067
+ background: var(--opf-ui-surface);
4068
+ border: 1px solid var(--opf-ui-border);
4069
+ border-radius: var(--opf-ui-radius-lg);
4070
+ box-shadow: var(--opf-ui-shadow-pop);
4071
+ }
4072
+ .opf-shortcuts .opf-shortcuts-dialog:focus { outline: none; }
4073
+ .opf-shortcuts .opf-shortcuts-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
4074
+ .opf-shortcuts .opf-shortcuts-title { margin: 0; font-size: 16px; font-weight: 600; letter-spacing: -0.01em; }
4075
+ .opf-shortcuts .opf-shortcuts-help { margin: 0; color: var(--opf-ui-muted-fg); }
4076
+ .opf-shortcuts .opf-shortcuts-close {
4077
+ display: grid;
4078
+ place-items: center;
4079
+ width: 30px;
4080
+ height: 30px;
4081
+ border: 0;
4082
+ border-radius: var(--opf-ui-radius);
4083
+ background: transparent;
4084
+ color: var(--opf-ui-muted-fg);
4085
+ font: inherit;
4086
+ font-size: 20px;
4087
+ line-height: 1;
4088
+ cursor: pointer;
4089
+ }
4090
+ .opf-shortcuts .opf-shortcuts-close:hover { background: var(--opf-ui-muted); color: var(--opf-ui-fg); }
4091
+ .opf-shortcuts .opf-shortcuts-search {
4092
+ width: 100%;
4093
+ min-height: 34px;
4094
+ padding: 4px 10px;
4095
+ border: 1px solid var(--opf-ui-border);
4096
+ border-radius: var(--opf-ui-radius-md);
4097
+ background: var(--opf-ui-bg);
4098
+ color: inherit;
4099
+ font: inherit;
4100
+ }
4101
+ .opf-shortcuts button:focus-visible,
4102
+ .opf-shortcuts input:focus-visible,
4103
+ .opf-shortcuts .opf-shortcuts-list:focus-visible { outline: 2px solid var(--opf-ui-ring); outline-offset: 2px; }
4104
+ .opf-shortcuts .opf-shortcuts-status { min-height: 18px; color: var(--opf-ui-muted-fg); font-size: 12px; }
4105
+ .opf-shortcuts .opf-shortcuts-list { flex: 1; min-height: 120px; overflow: auto; padding-right: 4px; }
4106
+ .opf-shortcuts .opf-shortcuts-group { margin: 0 0 12px; }
4107
+ .opf-shortcuts .opf-shortcuts-group-title { margin: 8px 0 4px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--opf-ui-muted-fg); }
4108
+ .opf-shortcuts .opf-shortcuts-row { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; padding: 4px 0; border-bottom: 1px solid var(--opf-ui-border); }
4109
+ .opf-shortcuts .opf-shortcuts-name { min-width: 0; }
4110
+ .opf-shortcuts .opf-shortcuts-when { color: var(--opf-ui-muted-fg); font-size: 12px; }
4111
+ .opf-shortcuts .opf-shortcuts-keys { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px; flex: none; max-width: 55%; }
4112
+ .opf-shortcuts .opf-shortcuts-or { color: var(--opf-ui-muted-fg); font-size: 11px; padding: 0 2px; align-self: center; }
4113
+ .opf-shortcuts .opf-shortcuts-key {
4114
+ display: inline-block;
4115
+ min-width: 22px;
4116
+ padding: 1px 6px;
4117
+ border: 1px solid var(--opf-ui-border-strong);
4118
+ border-bottom-width: 2px;
4119
+ border-radius: var(--opf-ui-radius-sm);
4120
+ background: var(--opf-ui-muted);
4121
+ font: 12px var(--opf-ui-mono);
4122
+ text-align: center;
4123
+ white-space: nowrap;
4124
+ }
4125
+ .opf-shortcuts .opf-shortcuts-none { color: var(--opf-ui-muted-fg); }
4126
+ @media (forced-colors: active) {
4127
+ .opf-shortcuts .opf-shortcuts-dialog,
4128
+ .opf-shortcuts .opf-shortcuts-key { border-color: CanvasText; }
4129
+ }
4130
+
4131
+ /* The symbol picker (src/source-outline.js) */
4132
+ .opf-quickpick {
4133
+ position: absolute;
4134
+ top: 6px;
4135
+ left: 50%;
4136
+ z-index: 40;
4137
+ width: min(480px, calc(100% - 24px));
4138
+ transform: translateX(-50%);
4139
+ display: flex;
4140
+ flex-direction: column;
4141
+ gap: 2px;
4142
+ padding: 6px;
4143
+ background: var(--opf-ui-surface);
4144
+ color: var(--opf-ui-fg);
4145
+ border: 1px solid var(--opf-ui-border);
4146
+ border-radius: var(--opf-ui-radius-lg);
4147
+ box-shadow: var(--opf-ui-shadow-pop);
4148
+ font: 13px var(--opf-ui-font);
4149
+ }
4150
+ .opf-quickpick-input { width: 100%; min-height: 30px; padding: 3px 8px; border: 1px solid var(--opf-ui-border); border-radius: var(--opf-ui-radius-md); background: var(--opf-ui-bg); color: inherit; font: inherit; }
4151
+ .opf-quickpick-input:focus-visible { outline: 2px solid var(--opf-ui-ring); outline-offset: 1px; }
4152
+ .opf-quickpick-list { margin: 0; padding: 2px 0 0; list-style: none; max-height: 280px; overflow: auto; }
4153
+ .opf-quickpick-item { display: flex; align-items: baseline; gap: 8px; min-height: 26px; padding: 3px 10px; border-radius: var(--opf-ui-radius-sm); cursor: pointer; }
4154
+ .opf-quickpick-item.is-selected { background: var(--opf-ui-accent); color: var(--opf-ui-accent-fg); }
4155
+ .opf-quickpick-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4156
+ .opf-quickpick-detail { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; opacity: 0.75; }
4157
+ .opf-quickpick-kind { margin-left: auto; font-size: 11px; opacity: 0.75; flex: none; }
4158
+ .opf-quickpick-more { padding: 4px 10px; color: var(--opf-ui-muted-fg); font-size: 12px; }
4159
+ .opf-quickpick-status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
4160
+ @media (forced-colors: active) { .opf-quickpick-item.is-selected { outline: 2px solid Highlight; } }
4161
+
4162
+ /* source-navigation.css */
4163
+ /*
4164
+ * Go to definition, peek, references and rename (source-navigation.js): one block widget under the line of the symbol. Tokens only. The declaration is
4165
+ * marked by a word, an arrow and a heavier line as well as by color, and nothing animates.
4166
+ */
4167
+ .opf-sym {
4168
+ box-sizing: border-box;
4169
+ margin: 4px 0 6px 12px;
4170
+ padding: 6px 10px 8px;
4171
+ border: 1px solid var(--opf-ui-border-strong);
4172
+ border-left: 3px solid var(--opf-ui-accent);
4173
+ border-radius: var(--opf-ui-radius);
4174
+ background: var(--opf-ui-surface);
4175
+ color: var(--opf-ui-fg);
4176
+ box-shadow: var(--opf-ui-shadow-pop);
4177
+ font: 12px/1.45 var(--opf-ui-font);
4178
+ text-align: start;
4179
+ white-space: normal;
4180
+ cursor: default;
4181
+ }
4182
+ .opf-sym:focus-visible,
4183
+ .opf-sym-list:focus-visible { outline: 2px solid var(--opf-ui-ring); outline-offset: 1px; }
4184
+ .opf-sym-head { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
4185
+ .opf-sym-title { flex: 1 1 auto; min-width: 0; font-weight: 600; overflow-wrap: anywhere; }
4186
+ .opf-sym-where { color: var(--opf-ui-muted-fg); }
4187
+ .opf-sym-close {
4188
+ flex: none;
4189
+ min-height: 24px;
4190
+ padding: 0 8px;
4191
+ border: 1px solid var(--opf-ui-border);
4192
+ border-radius: var(--opf-ui-radius-sm);
4193
+ background: var(--opf-ui-muted-2);
4194
+ color: var(--opf-ui-fg);
4195
+ font: inherit;
4196
+ cursor: pointer;
4197
+ }
4198
+ .opf-sym-close:hover { background: var(--opf-ui-muted); }
4199
+ .opf-sym-close:focus-visible,
4200
+ .opf-sym-input:focus-visible { outline: 2px solid var(--opf-ui-ring); outline-offset: 1px; }
4201
+ .opf-sym-lines, .opf-sym-list { margin: 6px 0 0; padding: 0; list-style: none; max-height: 260px; overflow: auto; }
4202
+ .opf-sym-line, .opf-sym-ref { display: flex; align-items: baseline; gap: 8px; min-width: 0; padding: 1px 4px; border-radius: var(--opf-ui-radius-sm); }
4203
+ .opf-sym-no { flex: none; min-width: 3ch; color: var(--opf-ui-muted-fg); font-family: var(--opf-ui-mono); text-align: end; }
4204
+ .opf-sym-mark { flex: none; width: 1ch; color: var(--opf-ui-accent-strong); }
4205
+ .opf-sym-text { flex: 1 1 auto; min-width: 0; font-family: var(--opf-ui-mono); white-space: pre-wrap; overflow-wrap: anywhere; }
4206
+ .opf-sym-line.is-target { background: var(--opf-ui-muted-2); font-weight: 600; }
4207
+ .opf-sym-ref { cursor: pointer; }
4208
+ .opf-sym-ref:hover { background: var(--opf-ui-muted-2); }
4209
+ .opf-sym-ref[aria-selected="true"] { background: var(--opf-ui-accent); color: var(--opf-ui-accent-fg); }
4210
+ .opf-sym-ref[aria-selected="true"] .opf-sym-no,
4211
+ .opf-sym-ref[aria-selected="true"] .opf-sym-badge { color: inherit; }
4212
+ .opf-sym-badge { flex: none; min-width: 6.5em; color: var(--opf-ui-muted-fg); font-size: 11px; }
4213
+ .opf-sym-ref.is-declaration .opf-sym-badge { font-weight: 600; }
4214
+ .opf-sym-hint { margin: 6px 0 0; color: var(--opf-ui-muted-fg); font-size: 11px; }
4215
+ .opf-sym-field { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
4216
+ .opf-sym-label { flex: none; color: var(--opf-ui-muted-fg); }
4217
+ .opf-sym-input {
4218
+ flex: 1 1 auto;
4219
+ min-width: 0;
4220
+ min-height: var(--opf-ui-control-h);
4221
+ padding: 0 8px;
4222
+ border: 1px solid var(--opf-ui-border-strong);
4223
+ border-radius: var(--opf-ui-radius-sm);
4224
+ background: var(--opf-ui-bg);
4225
+ color: var(--opf-ui-fg);
4226
+ font: 13px var(--opf-ui-mono);
4227
+ }
4228
+ .opf-sym-input[aria-invalid="true"] { border-color: var(--opf-ui-danger); }
4229
+ .opf-sym-message { min-height: 1.45em; margin-top: 4px; }
4230
+ .opf-sym-message.is-error { color: var(--opf-ui-danger); font-weight: 600; }
4231
+ .opf-sym-message.is-error::before { content: "Error: "; }
4232
+ .opf-sym-preview { margin-top: 6px; }
4233
+ .opf-sym-image { display: block; max-width: 100%; max-height: 160px; border: 1px solid var(--opf-ui-border); border-radius: var(--opf-ui-radius-sm); }
4234
+ @media (forced-colors: active) {
4235
+ .opf-sym { border: 1px solid CanvasText; }
4236
+ .opf-sym-ref[aria-selected="true"] { outline: 2px solid Highlight; }
4237
+ }
4238
+
4017
4239
  /* source-pane.css */
4018
4240
  /* Source pane (RR-98). Only --opf-ui-* tokens (src/styles/tokens.css). */
4019
4241
  .opf-source {
@@ -4326,6 +4548,46 @@ input.opf-lx-input[data-field="max"] { font-family: var(--opf-ui-mono); font-siz
4326
4548
  .opf-source-confirm button:hover { background: var(--opf-ui-muted); }
4327
4549
  .opf-source-confirm-accept { font-weight: 600; }
4328
4550
 
4551
+ /* Fold gutter (src/source-folding.js) */
4552
+ .opf-source-code .cm-foldGutter { width: 16px; }
4553
+ .opf-source-code .cm-foldGutter .cm-gutterElement { display: flex; align-items: center; justify-content: center; padding: 0; cursor: pointer; }
4554
+ .opf-source-code .cm-foldGutter .opf-fold-marker { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 100%; color: var(--opf-ui-muted-fg); opacity: 0; transition: opacity var(--opf-ui-ease); }
4555
+ .opf-source-code .cm-foldGutter .opf-fold-marker.is-folded,
4556
+ .opf-source-code:hover .cm-foldGutter .opf-fold-marker,
4557
+ .opf-source-code .cm-foldGutter .opf-fold-marker:hover { opacity: 1; }
4558
+ .opf-source-code .cm-foldGutter .opf-fold-marker:hover { color: var(--opf-ui-fg); }
4559
+ .opf-source-code .cm-foldPlaceholder { border: 0; background: var(--opf-ui-muted); color: var(--opf-ui-muted-fg); border-radius: var(--opf-ui-radius-sm); padding: 0 5px; margin: 0 2px; cursor: pointer; }
4560
+ @media (prefers-reduced-motion: reduce) { .opf-source-code .cm-foldGutter .opf-fold-marker { transition: none; } }
4561
+ @media (forced-colors: active) { .opf-source-code .cm-foldGutter .opf-fold-marker { opacity: 1; } }
4562
+
4563
+ /* Outline panel (opt-in `outline` option) */
4564
+ .opf-source-body { display: flex; flex: 1; min-height: 0; min-width: 0; }
4565
+ .opf-source-body > .opf-source-code { flex: 1; min-width: 0; }
4566
+ .opf-source-outline { flex: none; width: 220px; max-width: 40%; overflow: auto; border-right: 1px solid var(--opf-ui-code-border); padding: 4px 0; }
4567
+ .opf-source-outline-list { margin: 0; padding: 0; list-style: none; }
4568
+ .opf-source-outline-item { display: flex; align-items: baseline; gap: 6px; width: 100%; min-height: 26px; padding: 2px 8px; border: 0; background: transparent; color: var(--opf-ui-fg); text-align: left; font: inherit; cursor: pointer; }
4569
+ .opf-source-outline-item:hover { background: var(--opf-ui-muted); }
4570
+ .opf-source-outline-item:focus-visible { outline: 2px solid var(--opf-ui-ring); outline-offset: -2px; }
4571
+ .opf-source-outline-item[aria-current="true"] { background: var(--opf-ui-muted); font-weight: 600; }
4572
+ .opf-source-outline-item.is-section { color: var(--opf-ui-muted-fg); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
4573
+ .opf-source-outline-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4574
+ .opf-source-outline-detail { margin-left: auto; flex: none; max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: var(--opf-ui-muted-fg); }
4575
+ .opf-source-outline-empty { padding: 8px 10px; color: var(--opf-ui-muted-fg); }
4576
+ @media (forced-colors: active) { .opf-source-outline-item[aria-current="true"] { outline: 1px solid Highlight; } }
4577
+
4578
+ /* Forced colors (Windows high contrast): the selection of a list is an outline and a problem keeps its underline and a different shape per severity. */
4579
+ @media (forced-colors: active) {
4580
+ .opf-source-code .cm-tooltip-autocomplete > ul > li[aria-selected],
4581
+ .cm-tooltip .opf-fix[aria-selected="true"] { outline: 2px solid Highlight; outline-offset: -2px; forced-color-adjust: none; background: Canvas; color: CanvasText; }
4582
+ .opf-source-code .cm-lintRange-error { text-decoration: underline wavy CanvasText; }
4583
+ .opf-source-code .cm-lintRange-warning { text-decoration: underline dashed CanvasText; }
4584
+ .opf-source-code .cm-lintRange-info { text-decoration: underline dotted CanvasText; }
4585
+ .opf-source-finding { border: 1px solid CanvasText; }
4586
+ }
4587
+ @media (prefers-reduced-motion: reduce) {
4588
+ .cm-tooltip, .cm-tooltip * { animation: none !important; transition: none !important; }
4589
+ }
4590
+
4329
4591
  /* thumbnails.css */
4330
4592
  /* Thumbnail strip (RR-98). Uses only --opf-ui-* tokens. */
4331
4593
  .opf-thumbs {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-editor",
3
- "version": "0.26.5",
3
+ "version": "0.26.6",
4
4
  "description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -231,6 +231,10 @@
231
231
  "types": "./dist/annotations.d.ts",
232
232
  "default": "./dist/annotations.js"
233
233
  },
234
+ "./source-worker": {
235
+ "types": "./dist/source-worker.d.ts",
236
+ "default": "./dist/source-worker.js"
237
+ },
234
238
  "./source-pane": {
235
239
  "types": "./dist/source-pane.d.ts",
236
240
  "default": "./dist/source-pane.js"
@@ -283,10 +287,10 @@
283
287
  "@lezer/highlight": "1.2.5",
284
288
  "jsonc-parser": "3.3.1",
285
289
  "yaml": "^2.9.1",
286
- "@openpresentation/opf": "^0.26.5"
290
+ "@openpresentation/opf": "^0.26.6"
287
291
  },
288
292
  "peerDependencies": {
289
- "@openpresentation/opf-render": "^0.26.5"
293
+ "@openpresentation/opf-render": "^0.26.6"
290
294
  },
291
295
  "peerDependenciesMeta": {
292
296
  "@openpresentation/opf-render": {
@@ -345,9 +349,9 @@
345
349
  "pdf-lib": "1.17.1",
346
350
  "pdfjs-dist": "6.4.299",
347
351
  "playwright": "1.64.0",
348
- "@openpresentation/gallery": "0.26.5",
349
- "@openpresentation/opf-render": "0.26.5",
350
- "@openpresentation/opf-pptx": "0.26.5"
352
+ "@openpresentation/opf-pptx": "0.26.6",
353
+ "@openpresentation/opf-render": "0.26.6",
354
+ "@openpresentation/gallery": "0.26.6"
351
355
  },
352
356
  "scripts": {
353
357
  "build": "node scripts/build.mjs",
@@ -398,5 +402,5 @@
398
402
  "test:export-menu-browser": "node test/export-menu-browser.mjs",
399
403
  "test:agent-handoff-browser": "node test/agent-handoff-browser.mjs"
400
404
  },
401
- "gitHead": "750d120e715b6c4930906aa71322d092d057c58d"
405
+ "gitHead": "66937576155a3c10ec86ee3327921b51f0c141ae"
402
406
  }