@openpresentation/opf-editor 0.29.0 → 0.30.0

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,230 @@
1
+ // CE-29, the help item: a button (and the "?" key) that opens the keyboard shortcuts sheet, a modal dialog listing every shortcut the host passes
2
+ // in, grouped and filterable by typing. The rows, the classes and the look are the source pane's shortcuts dialog (source-shortcuts.js,
3
+ // styles/shortcuts.css); the key names come from the studio kit's `formatKey` (⌘ on a Mac, Ctrl elsewhere) so the three sheets agree. Plain DOM,
4
+ // no framework, no network. The icon is the Lucide CircleHelp path (ISC license), inlined.
5
+ import { normalizeIcons, resolveIcon } from "./icons.js";
6
+ import { matches } from "./source-shortcuts.js";
7
+ import { formatKey, isHandled, markHandled } from "./studio-kit/commands.js";
8
+ import { isMac, isTypingTarget } from "./studio-kit/dom.js";
9
+
10
+ const ICON_NAMES = ["help", "close"];
11
+ // The help glyph is Lucide CircleHelp: a ring, a question mark and a dot.
12
+ const HELP_ICON = [["circle", { cx: 12, cy: 12, r: 10 }], ["path", { d: "M9.09 9a3 3 0 0 1 5.82 1c0 2-3 3-3 3" }], ["path", { d: "M12 17h.01" }]];
13
+ const FOCUSABLE = 'button, input, [tabindex]:not([tabindex="-1"])';
14
+ let counter = 0;
15
+
16
+ const DEFAULT_LABELS = {
17
+ /** The accessible name of the toolbar button. */
18
+ button: "Keyboard shortcuts",
19
+ /** The tooltip of the toolbar button (the key is added when it is bound). */
20
+ title: "Keyboard shortcuts",
21
+ /** The heading of the dialog. */
22
+ heading: "Keyboard shortcuts",
23
+ close: "Close",
24
+ /** The accessible name of the filter field. */
25
+ search: "Filter the keyboard shortcuts",
26
+ searchPlaceholder: "Filter shortcuts",
27
+ /** The group of a shortcut that names none. */
28
+ defaultGroup: "General",
29
+ none: "No shortcut matches.",
30
+ empty: "No keyboard shortcuts are available.",
31
+ /** The sentence under the heading. `platform` is "macOS" or "Windows or Linux". */
32
+ description: (platform) => `Keys for ${platform}. Type to filter; Escape closes.`,
33
+ /** The live status line: how many shortcuts show. */
34
+ status: (shown, total) => `${shown === total ? "" : `${shown} of `}${total} ${total === 1 ? "shortcut" : "shortcuts"}${shown === total ? "" : " match"}`,
35
+ };
36
+
37
+ /**
38
+ * The strokes of one shortcut as people read them, one string per alternative: `["Mod-z", "Shift-Mod-z"]` becomes `["⌘Z", "⇧⌘Z"]` on a Mac and
39
+ * `["Ctrl+Z", "Ctrl+Shift+Z"]` elsewhere. A key with several strokes ("Mod-k Mod-s") keeps them separated by a space. A name that does not parse
40
+ * is shown as it is.
41
+ */
42
+ export function formatShortcutKeys(keys, win) {
43
+ return (Array.isArray(keys) ? keys : [keys]).filter((key) => typeof key === "string" && key).map((key) => key.split(" ").map((stroke) => {
44
+ try { return formatKey(stroke, win); } catch { return stroke; }
45
+ }).join(" "));
46
+ }
47
+
48
+ /** The sheet's rows from the host's `getShortcuts()`: `{ id, title, group, keys }`, in the order given, with the keys formatted for `win`. */
49
+ export function shortcutRows(shortcuts, win, defaultGroup = DEFAULT_LABELS.defaultGroup) {
50
+ return (Array.isArray(shortcuts) ? shortcuts : []).filter((item) => item && typeof item.label === "string").map((item, index) => ({
51
+ id: item.id ?? `shortcut-${index}`,
52
+ title: item.label,
53
+ group: item.group || defaultGroup,
54
+ keys: formatShortcutKeys(item.keys, win),
55
+ }));
56
+ }
57
+
58
+ /** Rows that match `query` (every word, in the label, group or keys; the source pane's rule), in their original order. */
59
+ export function filterShortcutRows(rows, query) {
60
+ return query.trim() ? rows.filter((row) => matches(row, query)) : rows;
61
+ }
62
+
63
+ /** Whether a "?" key press should open the sheet: a bare "?" (with or without Shift), not typed into a field, an editor, a menu or a dialog, not handled by another part. */
64
+ export function opensOnKey(event) {
65
+ if (event.key !== "?" || event.ctrlKey || event.metaKey || event.altKey || event.isComposing || event.defaultPrevented || isHandled(event)) return false;
66
+ const target = event.target;
67
+ if (isTypingTarget(target)) return false;
68
+ if (target && typeof target.closest === "function" && target.closest('.cm-editor,[contenteditable=""],[contenteditable="true"],[role="textbox"],[role="combobox"],[role="menu"],[role="dialog"],dialog[open]')) return false;
69
+ return true;
70
+ }
71
+
72
+ function node(doc, tag, className, attributes = {}, text) {
73
+ const made = doc.createElement(tag);
74
+ if (className) made.className = className;
75
+ for (const [key, value] of Object.entries(attributes)) made.setAttribute(key, value);
76
+ if (text !== undefined) made.textContent = text;
77
+ return made;
78
+ }
79
+
80
+ /**
81
+ * Mount the help button into `container`. Pressing it (or "?" / "Shift-?" while focus is not in a field) opens the shortcuts sheet in `overlay`.
82
+ *
83
+ * Options: `overlay` (the element the dialog is added to; default the closest `.opf-ui` ancestor, else the document body), `getShortcuts()` (called on
84
+ * every open; returns `[{ label, keys: ["Mod-z", "Shift-Mod-z"], group? }]` with CodeMirror-style key names), `density`, `labels`, `bindKey`
85
+ * (default true: bind "?" on the document).
86
+ *
87
+ * Returns `{ element, open(), close(), setDensity(density), setDisabled(disabled), menuItems(), destroy() }`.
88
+ */
89
+ export function createHelpButton(container, options = {}) {
90
+ if (!container?.ownerDocument) throw new TypeError("A DOM container is required.");
91
+ const doc = container.ownerDocument;
92
+ const win = doc.defaultView;
93
+ const labels = { ...DEFAULT_LABELS, ...(options.labels ?? {}) };
94
+ const bindKey = options.bindKey !== false;
95
+ const icons = normalizeIcons(options.icons, ICON_NAMES, "createHelpButton icons");
96
+ let density = options.density === "touch" ? "touch" : "comfortable";
97
+ let disabled = false;
98
+ let destroyed = false;
99
+ let sheet = null;
100
+
101
+ const element = node(doc, "button", "opf-help-button", { type: "button", "aria-label": labels.button, "aria-haspopup": "dialog", "aria-expanded": "false" });
102
+ element.title = bindKey ? `${labels.title} (?)` : labels.title;
103
+ element.dataset.density = density;
104
+ element.append(resolveIcon(doc, icons.help, HELP_ICON, { name: "help", className: "opf-help-icon" }));
105
+ container.append(element);
106
+
107
+ const overlayHost = () => options.overlay ?? container.closest?.(".opf-ui") ?? doc.body;
108
+
109
+ function open() {
110
+ if (destroyed || disabled) return false;
111
+ if (sheet) { sheet.focus(); return true; }
112
+ const host = overlayHost();
113
+ const opener = doc.activeElement && doc.activeElement !== doc.body ? doc.activeElement : element;
114
+ const rows = shortcutRows(options.getShortcuts?.(), win, labels.defaultGroup);
115
+ const id = `opf-help-${++counter}`;
116
+ const root = node(doc, "div", `opf-shortcuts opf-help-sheet${host === doc.body || !host.closest?.(".opf-ui") ? " opf-ui" : ""}`);
117
+ root.dataset.density = density;
118
+ const dialog = node(doc, "div", "opf-shortcuts-dialog", { role: "dialog", "aria-modal": "true", "aria-labelledby": `${id}-title`, "aria-describedby": `${id}-help`, tabindex: "-1" });
119
+ const head = node(doc, "div", "opf-shortcuts-head");
120
+ const closeButton = node(doc, "button", "opf-shortcuts-close", { type: "button", "aria-label": labels.close }, "×");
121
+ // The close button reads "×" unless the host gives a `close` icon.
122
+ const closeIcon = resolveIcon(doc, icons.close, null, { name: "close", className: "opf-help-close-icon", size: 16 });
123
+ if (closeIcon) closeButton.replaceChildren(closeIcon);
124
+ head.append(node(doc, "h2", "opf-shortcuts-title", { id: `${id}-title` }, labels.heading), closeButton);
125
+ const help = node(doc, "p", "opf-shortcuts-help", { id: `${id}-help` }, labels.description(isMac(win) ? "macOS" : "Windows or Linux"));
126
+ const search = node(doc, "input", "opf-shortcuts-search", { type: "search", "aria-label": labels.search, placeholder: labels.searchPlaceholder, autocomplete: "off", spellcheck: "false" });
127
+ const status = node(doc, "div", "opf-shortcuts-status", { role: "status", "aria-live": "polite" });
128
+ const list = node(doc, "div", "opf-shortcuts-list", { tabindex: "0", role: "region", "aria-label": labels.heading });
129
+ dialog.append(head, help, search, status, list);
130
+ root.append(dialog);
131
+ host.append(root);
132
+
133
+ function render() {
134
+ const shown = filterShortcutRows(rows, search.value);
135
+ list.replaceChildren();
136
+ let group = null, section = null;
137
+ for (const row of shown) {
138
+ if (row.group !== group) {
139
+ group = row.group;
140
+ section = node(doc, "section", "opf-shortcuts-group", { "aria-label": group });
141
+ section.append(node(doc, "h3", "opf-shortcuts-group-title", {}, group));
142
+ list.append(section);
143
+ }
144
+ const item = node(doc, "div", "opf-shortcuts-row", { "data-id": row.id });
145
+ const keys = node(doc, "span", "opf-shortcuts-keys");
146
+ row.keys.forEach((text, index) => {
147
+ if (index) keys.append(node(doc, "span", "opf-shortcuts-or", {}, "or"));
148
+ for (const stroke of text.split(" ")) keys.append(node(doc, "kbd", "opf-shortcuts-key", {}, stroke));
149
+ });
150
+ item.append(node(doc, "span", "opf-shortcuts-name", {}, row.title), keys);
151
+ section.append(item);
152
+ }
153
+ if (!shown.length) list.append(node(doc, "p", "opf-shortcuts-none", {}, rows.length ? labels.none : labels.empty));
154
+ status.textContent = labels.status(shown.length, rows.length);
155
+ }
156
+ const focusables = () => [...dialog.querySelectorAll(FOCUSABLE)].filter((item) => item.getClientRects().length);
157
+ function onKeydown(event) {
158
+ if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); return; }
159
+ if (event.key !== "Tab") return;
160
+ const items = focusables();
161
+ if (!items.length) { event.preventDefault(); dialog.focus(); return; }
162
+ const first = items[0], last = items.at(-1), active = doc.activeElement;
163
+ if (event.shiftKey && (active === first || active === dialog)) { event.preventDefault(); last.focus(); }
164
+ else if (!event.shiftKey && active === last) { event.preventDefault(); first.focus(); }
165
+ else if (!dialog.contains(active)) { event.preventDefault(); first.focus(); }
166
+ }
167
+ const onFocusin = (event) => { if (!dialog.contains(event.target)) (focusables()[0] ?? dialog).focus(); };
168
+ const onPointerdown = (event) => { if (event.target === root) close(); };
169
+ function close() {
170
+ if (!sheet) return;
171
+ sheet = null;
172
+ element.setAttribute("aria-expanded", "false");
173
+ doc.removeEventListener("focusin", onFocusin, true);
174
+ root.removeEventListener("keydown", onKeydown);
175
+ root.removeEventListener("pointerdown", onPointerdown);
176
+ root.remove();
177
+ if (opener?.isConnected) opener.focus({ preventScroll: true }); else if (element.isConnected) element.focus({ preventScroll: true });
178
+ }
179
+ sheet = { root, close, focus: () => search.focus() };
180
+ root.addEventListener("keydown", onKeydown);
181
+ root.addEventListener("pointerdown", onPointerdown);
182
+ closeButton.addEventListener("click", close);
183
+ search.addEventListener("input", render);
184
+ doc.addEventListener("focusin", onFocusin, true);
185
+ element.setAttribute("aria-expanded", "true");
186
+ render();
187
+ search.focus();
188
+ return true;
189
+ }
190
+ const close = () => sheet?.close();
191
+
192
+ const onKey = (event) => {
193
+ if (destroyed || disabled || sheet || !opensOnKey(event)) return;
194
+ event.preventDefault();
195
+ markHandled(event);
196
+ open();
197
+ };
198
+ const onClick = () => { open(); };
199
+ element.addEventListener("click", onClick);
200
+ if (bindKey) doc.addEventListener("keydown", onKey);
201
+
202
+ return {
203
+ element,
204
+ /** Open the sheet. Returns false when the button is disabled or destroyed. */
205
+ open,
206
+ /** Close the sheet and return the focus to where it was. */
207
+ close,
208
+ setDensity(next) {
209
+ density = next === "touch" ? "touch" : "comfortable";
210
+ element.dataset.density = density;
211
+ if (sheet) sheet.root.dataset.density = density;
212
+ },
213
+ setDisabled(next) {
214
+ disabled = Boolean(next);
215
+ element.disabled = disabled;
216
+ if (disabled) close();
217
+ },
218
+ menuItems() {
219
+ return [{ id: "help", label: labels.button, disabled, run: open }];
220
+ },
221
+ destroy() {
222
+ if (destroyed) return;
223
+ close();
224
+ destroyed = true;
225
+ element.removeEventListener("click", onClick);
226
+ doc.removeEventListener("keydown", onKey);
227
+ element.remove();
228
+ },
229
+ };
230
+ }
@@ -1,3 +1,4 @@
1
+ import type { OpfIcons } from "./icons.js";
1
2
  import type { EditorSession } from "./index.js";
2
3
 
3
4
  export interface SlideNavigatorLabels {
@@ -22,6 +23,8 @@ export interface SlideNavigatorOptions {
22
23
  /** `"comfortable"` (32px controls, default) or `"touch"` (40px). */
23
24
  density?: "comfortable" | "touch";
24
25
  labels?: SlideNavigatorLabels;
26
+ /** Replace the arrows: `previous` and `next` (`.opf-nav-icon`). See `OpfIcon`. */
27
+ icons?: OpfIcons<"previous" | "next">;
25
28
  /** False to stop following the editor on its own; call `refresh()` yourself. Default true. */
26
29
  autoRefresh?: boolean;
27
30
  }
@@ -1,7 +1,8 @@
1
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
2
  // shows; the slide count follows the editor session (or a presentation the host hands in). Plain DOM, no framework, no network.
3
3
  // Icons are the Lucide ChevronLeft and ChevronRight paths (ISC license), inlined.
4
- const SVG_NS = "http://www.w3.org/2000/svg";
4
+ import { normalizeIcons, resolveIcon } from "./icons.js";
5
+
5
6
  const CHEVRONS = { previous: "m15 18-6-6 6-6", next: "m9 18 6-6-6-6" };
6
7
 
7
8
  const DEFAULT_LABELS = {
@@ -19,21 +20,8 @@ export function formatSlideCounter(index, count) {
19
20
  return `${pad(count === 0 ? 0 : index + 1)} / ${pad(count)}`;
20
21
  }
21
22
 
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;
23
+ function chevron(doc, name, given) {
24
+ return resolveIcon(doc, given, CHEVRONS[name], { name, className: "opf-nav-icon" });
37
25
  }
38
26
 
39
27
  /** True when a page-level key press belongs to something else: typing, an editor, a menu or a dialog. */
@@ -72,6 +60,7 @@ export function createSlideNavigator(container, options = {}) {
72
60
  return Array.isArray(deck?.slides) ? deck.slides.length : 0;
73
61
  };
74
62
 
63
+ const icons = normalizeIcons(options.icons, ["previous", "next"], "createSlideNavigator icons");
75
64
  const element = doc.createElement("div");
76
65
  element.className = "opf-nav";
77
66
  element.dataset.density = density;
@@ -84,7 +73,7 @@ export function createSlideNavigator(container, options = {}) {
84
73
  node.dataset.action = name;
85
74
  node.setAttribute("aria-label", labels[name]);
86
75
  node.title = labels[name];
87
- node.append(chevron(name));
76
+ node.append(chevron(doc, name, icons[name]));
88
77
  return node;
89
78
  };
90
79
  const previous = button("previous");
package/dist/studio.js CHANGED
@@ -68,7 +68,8 @@ export function buildStudioShell(root, { parts, layout, labels = {}, studio: con
68
68
  // The bar.
69
69
  const bar = el("div", `${PREFIX}-bar`, { role: "toolbar", "aria-label": labels.studio ?? "Studio" });
70
70
  const file = dom.button(`${PREFIX}-barbutton`, { text: "File", iconName: "Menu", size: 16, attributes: { "aria-haspopup": "menu", "data-studio": "file" } });
71
- const name = el("input", `${PREFIX}-name`, { type: "text", "aria-label": "Presentation name", spellcheck: "false", autocomplete: "off", "data-studio": "name" });
71
+ // CE-28 wave 2: the deck name is the workspace's title part (src/deck-title.js), mounted in this slot; the expand button sits before topbarEnd.
72
+ const name = slot("title", "div", `${PREFIX}-title`);
72
73
  const tools = el("div", `${PREFIX}-tools`, { role: "group", "aria-label": "Tools" });
73
74
  const toolButtons = {};
74
75
  for (const [id, text, icon, key] of [["select", "Select", "MousePointer2", "V"], ["hand", "Hand", "Hand", "H"], ["text", "Text", "Type", "T"]]) {
@@ -97,6 +98,7 @@ export function buildStudioShell(root, { parts, layout, labels = {}, studio: con
97
98
  bar.append(file, name, tools, insert, addSlideButton, history, views, spacer, zoom);
98
99
  if (parts.source) bar.append(source);
99
100
  if (parts.present) bar.append(slot("present", "div", `${PREFIX}-present`));
101
+ bar.append(slot("expand", "div", `${PREFIX}-expand`));
100
102
  const topbarEnd = el("div", `${PREFIX}-topbar-end`, { "data-opf-slot": "topbarEnd" });
101
103
  if (given.topbarEnd) topbarEnd.append(given.topbarEnd);
102
104
  bar.append(topbarEnd);
@@ -1304,7 +1306,9 @@ export function createStudio(ctx) {
1304
1306
  dialog.append(head, search, list);
1305
1307
  const draw = () => {
1306
1308
  const query = search.value.trim().toLowerCase();
1307
- const entries = commands.list(s, { win }).filter((entry) => (entry.keys.length || query) && (!query || `${entry.label} ${entry.group} ${entry.id}`.toLowerCase().includes(query)));
1309
+ // The workspace's own keys (save, the title, expand) are listed with the commands, as the help item lists them (CE-28).
1310
+ const extra = (ctx.extraShortcuts?.() ?? []).map((entry) => ({ id: entry.id, label: entry.label, group: entry.group ?? "General", keys: entry.keys, enabled: true, plain: true }));
1311
+ const entries = [...commands.list(s, { win }), ...extra].filter((entry) => (entry.keys.length || query) && (!query || `${entry.label} ${entry.group} ${entry.id}`.toLowerCase().includes(query)));
1308
1312
  const groups = new Map();
1309
1313
  for (const entry of entries) { if (!groups.has(entry.group)) groups.set(entry.group, []); groups.get(entry.group).push(entry); }
1310
1314
  list.replaceChildren(...[...groups].map(([group, rows]) => {
@@ -1314,9 +1318,12 @@ export function createStudio(ctx) {
1314
1318
  const keys = el("dd", "");
1315
1319
  for (const key of row.keys.slice(0, 2)) keys.append(el("kbd", "", { text: formatKey(key, win) }));
1316
1320
  const term = el("dt", "");
1317
- const run = dom.button(`${PREFIX}-linkbutton`, { text: row.label, attributes: { "aria-disabled": row.enabled ? undefined : "true", title: row.reason || undefined } });
1318
- run.addEventListener("click", () => { if (!row.enabled) { announce(row.reason); return; } closeSheetDialog(); runCommand(row.id); });
1319
- term.append(run);
1321
+ if (row.plain) term.append(el("span", "", { text: row.label }));
1322
+ else {
1323
+ const run = dom.button(`${PREFIX}-linkbutton`, { text: row.label, attributes: { "aria-disabled": row.enabled ? undefined : "true", title: row.reason || undefined } });
1324
+ run.addEventListener("click", () => { if (!row.enabled) { announce(row.reason); return; } closeSheetDialog(); runCommand(row.id); });
1325
+ term.append(run);
1326
+ }
1320
1327
  table.append(term, keys);
1321
1328
  }
1322
1329
  section.append(el("h3", `${PREFIX}-section-title`, { text: group }), table);
@@ -1434,15 +1441,7 @@ export function createStudio(ctx) {
1434
1441
  if (!ctx.inSync() && ctx.pane) { if (sourceMode === "off") s.toggleSource(); ctx.pane.editor?.api?.focus?.(); return; }
1435
1442
  if (shell.tabs.review) { if (root.dataset.width === "narrow") openSheet("review"); else showTab("review", { focus: true }); }
1436
1443
  });
1437
- // The name: the presentation's `name`, one undoable change (the source pane keeps its own file name).
1438
- const deckName = () => { const value = ui.get("/name"); return typeof value === "string" ? value : ""; };
1439
- listen(shell.name, "change", () => {
1440
- const value = shell.name.value.trim();
1441
- if (value === deckName()) return;
1442
- if (s.writeValue("/name", value || undefined, value ? `Renamed to ${value}` : "Name removed")) return;
1443
- shell.name.value = deckName();
1444
- });
1445
- listen(shell.name, "keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); shell.name.blur(); } if (event.key === "Escape") { shell.name.value = deckName(); shell.name.blur(); } });
1444
+ // The name: the workspace's title part in `shell.name` (CE-28), one undoable change through the session (the source pane keeps its own file name).
1446
1445
  function syncBar() {
1447
1446
  const list = commands.list(s, { win });
1448
1447
  const state = (id) => list.find((entry) => entry.id === id);
@@ -1452,8 +1451,6 @@ export function createStudio(ctx) {
1452
1451
  const slideState = state("studio.slide.new");
1453
1452
  shell.addSlide.disabled = !slideState?.enabled;
1454
1453
  shell.addSlide.title = slideState?.enabled ? "Add a slide" : slideState?.reason ?? "";
1455
- if (doc.activeElement !== shell.name) shell.name.value = deckName();
1456
- shell.name.readOnly = s.readOnly;
1457
1454
  }
1458
1455
 
1459
1456
  // ---- the status line ----------------------------------------------------------------------------------------------------------------