@openpresentation/opf-editor 0.26.4 → 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,369 @@
1
+ // The one table of key bindings of the source editor (artifacts/source-editor/SPEC.md, sections 8.6 and 11). Every binding the editor installs is
2
+ // declared here with its keys per platform, a title and a group; the editor installs the keys it reads from this table (`bind`, `bindDom`), and
3
+ // `getShortcuts()` and the shortcuts dialog list what was installed, so the list cannot drift from the keymap. The key names are CodeMirror's
4
+ // (`Mod-Shift-o`, `Alt-F12`; `Mod` is Cmd on macOS and Ctrl elsewhere; a space separates the strokes of a chord: `Mod-k Mod-0`).
5
+ //
6
+ // A binding object is CodeMirror's own shape: `key` for every platform, and `mac`, `win` or `linux` where it differs.
7
+ import {
8
+ addCursorAbove, addCursorBelow, copyLineDown, copyLineUp, cursorCharLeft, cursorCharRight, cursorDocEnd, cursorDocStart, cursorGroupLeft, cursorGroupRight,
9
+ cursorLineBoundaryBackward, cursorLineBoundaryForward, cursorLineBoundaryLeft, cursorLineBoundaryRight, cursorLineDown, cursorLineEnd, cursorLineStart,
10
+ cursorLineUp, cursorMatchingBracket, cursorPageDown, cursorPageUp, cursorSyntaxLeft, cursorSyntaxRight, deleteCharBackward, deleteCharForward, deleteGroupBackward,
11
+ deleteGroupForward, deleteLine, deleteLineBoundaryBackward, deleteLineBoundaryForward, deleteToLineEnd, indentLess, indentMore, indentSelection, insertBlankLine,
12
+ moveLineDown, moveLineUp, redo, redoSelection, selectAll, selectLine, selectParentSyntax, simplifySelection, splitLine, toggleBlockComment, toggleTabFocusMode,
13
+ insertNewlineAndIndent, transposeChars, undo, undoSelection, toggleComment,
14
+ } from "@codemirror/commands";
15
+ import { closeBracketsKeymap, closeCompletion } from "@codemirror/autocomplete";
16
+ import { EditorView } from "@codemirror/view";
17
+ import { closeSearchPanel } from "@codemirror/search";
18
+ import { findNext, findPrevious, gotoLine, openSearchPanel, selectNextOccurrence, selectSelectionMatches } from "@codemirror/search";
19
+
20
+ // ---- groups ---------------------------------------------------------------------------------------------------------------------------
21
+ export const GROUPS = Object.freeze([
22
+ "Suggestions", "Problems and fixes", "Hover and hints", "Go to", "Folding", "Lines", "Selection and cursors", "Find", "Format", "Undo and redo", "Editing", "Focus", "Help", "Cursor movement",
23
+ ]);
24
+
25
+ // ---- the table ------------------------------------------------------------------------------------------------------------------------
26
+ // id: the name the command is bound under; title and group: what the shortcuts list shows; keys: bindings; `when`: what has to be true (shown as a
27
+ // note); `dom`: read from the keydown event (chords and keys whose character depends on the modifiers) instead of CodeMirror's keymap.
28
+ const entry = (id, title, group, keys, extra = {}) => Object.freeze({ id, title, group, keys: Object.freeze(keys), ...extra });
29
+ const FOLD_LEVELS = [1, 2, 3, 4, 5, 6, 7];
30
+
31
+ export const TABLE = Object.freeze([
32
+ // Suggestions (the list is CodeMirror's autocomplete)
33
+ entry("suggest.trigger", "Trigger suggestions", "Suggestions", [{ key: "Ctrl-Space" }, { mac: "Mod-i" }]),
34
+ entry("suggest.details", "Show or hide the details of the selected suggestion", "Suggestions", [{ key: "Ctrl-Space" }], { when: "the list is open", installedWith: "suggest.trigger" }),
35
+ entry("suggest.accept", "Accept the selected suggestion", "Suggestions", [{ key: "Tab" }, { key: "Enter" }], { when: "the list is open" }),
36
+ entry("suggest.acceptReplace", "Accept and replace the whole word", "Suggestions", [{ key: "Shift-Tab" }, { key: "Shift-Enter" }], { when: "the list is open" }),
37
+ entry("suggest.dismiss", "Close the list", "Suggestions", [{ key: "Escape" }], { when: "the list is open" }),
38
+ entry("suggest.next", "Select the next suggestion", "Suggestions", [{ key: "ArrowDown" }, { mac: "Ctrl-n" }], { when: "the list is open" }),
39
+ entry("suggest.previous", "Select the previous suggestion", "Suggestions", [{ key: "ArrowUp" }, { mac: "Ctrl-p" }], { when: "the list is open" }),
40
+ entry("suggest.nextPage", "Select the suggestion a page down", "Suggestions", [{ key: "PageDown" }], { when: "the list is open" }),
41
+ entry("suggest.previousPage", "Select the suggestion a page up", "Suggestions", [{ key: "PageUp" }], { when: "the list is open" }),
42
+ entry("snippet.next", "Next tab stop of a snippet", "Suggestions", [{ key: "Tab" }], { when: "a snippet is being filled", installedWith: "suggest.accept" }),
43
+ entry("snippet.previous", "Previous tab stop of a snippet", "Suggestions", [{ key: "Shift-Tab" }], { when: "a snippet is being filled", installedWith: "suggest.acceptReplace" }),
44
+ // Problems
45
+ entry("problem.next", "Go to the next problem", "Problems and fixes", [{ key: "F8" }]),
46
+ entry("problem.previous", "Go to the previous problem", "Problems and fixes", [{ key: "Shift-F8" }]),
47
+ entry("problem.quickFix", "Quick fix", "Problems and fixes", [{ key: "Mod-." }]),
48
+ entry("problem.panel", "Move focus to the problems panel", "Problems and fixes", [{ key: "Mod-Shift-m" }], { dom: true }),
49
+ // Hover and hints
50
+ entry("hover.show", "Show the hover at the caret", "Hover and hints", [{ key: "Mod-k Mod-i" }], { dom: true }),
51
+ entry("hint.close", "Close the hint", "Hover and hints", [{ key: "Escape" }], { when: "a hint is shown", installedWith: "hint.shape" }),
52
+ entry("hint.shape", "Show the shape of the slide (parameter hints)", "Hover and hints", [{ key: "Mod-Shift-Space" }]),
53
+ // Go to
54
+ entry("nav.definition", "Go to definition", "Go to", [{ key: "F12" }, { key: "Mod-Click" }], { clickOnly: ["Mod-Click"] }),
55
+ entry("nav.peek", "Peek definition", "Go to", [{ key: "Alt-F12" }]),
56
+ entry("nav.references", "Find references", "Go to", [{ key: "Shift-F12" }]),
57
+ entry("nav.rename", "Rename symbol", "Go to", [{ key: "F2" }]),
58
+ entry("nav.close", "Close the peek, the references or the rename input", "Go to", [{ key: "Escape" }], { when: "one of them is open", installedWith: "nav.definition" }),
59
+ entry("nav.symbol", "Go to symbol in the document (outline)", "Go to", [{ key: "Mod-Shift-o" }], { dom: true }),
60
+ entry("nav.line", "Go to line", "Go to", [{ key: "Ctrl-g" }, { key: "Mod-Alt-g" }]),
61
+ entry("nav.bracket", "Jump to the matching bracket", "Go to", [{ key: "Mod-Shift-\\" }]),
62
+ // Folding
63
+ entry("fold.fold", "Fold", "Folding", [{ key: "Ctrl-Shift-[", mac: "Cmd-Alt-[" }]),
64
+ entry("fold.unfold", "Unfold", "Folding", [{ key: "Ctrl-Shift-]", mac: "Cmd-Alt-]" }]),
65
+ entry("fold.toggle", "Toggle fold", "Folding", [{ key: "Mod-k Mod-l" }], { dom: true }),
66
+ entry("fold.foldRecursively", "Fold recursively", "Folding", [{ key: "Mod-k Mod-[" }], { dom: true }),
67
+ entry("fold.unfoldRecursively", "Unfold recursively", "Folding", [{ key: "Mod-k Mod-]" }], { dom: true }),
68
+ entry("fold.all", "Fold all", "Folding", [{ key: "Mod-k Mod-0" }], { dom: true }),
69
+ entry("fold.unfoldAll", "Unfold all", "Folding", [{ key: "Mod-k Mod-j" }], { dom: true }),
70
+ ...FOLD_LEVELS.map((level) => entry(`fold.level${level}`, `Fold level ${level}`, "Folding", [{ key: `Mod-k Mod-${level}` }], { dom: true })),
71
+ // Lines
72
+ entry("lines.moveUp", "Move line up", "Lines", [{ key: "Alt-ArrowUp" }]),
73
+ entry("lines.moveDown", "Move line down", "Lines", [{ key: "Alt-ArrowDown" }]),
74
+ entry("lines.copyUp", "Copy line up", "Lines", [{ key: "Shift-Alt-ArrowUp" }]),
75
+ entry("lines.copyDown", "Copy line down", "Lines", [{ key: "Shift-Alt-ArrowDown" }]),
76
+ entry("lines.delete", "Delete line", "Lines", [{ key: "Mod-Shift-k" }]),
77
+ entry("lines.insertBelow", "Insert line below", "Lines", [{ key: "Mod-Enter" }]),
78
+ entry("lines.insertAbove", "Insert line above", "Lines", [{ key: "Mod-Shift-Enter" }]),
79
+ entry("lines.indent", "Indent line", "Lines", [{ key: "Mod-]" }]),
80
+ entry("lines.outdent", "Outdent line", "Lines", [{ key: "Mod-[" }]),
81
+ entry("lines.comment", "Toggle line comment", "Lines", [{ key: "Mod-/" }]),
82
+ entry("lines.blockComment", "Toggle block comment", "Lines", [{ key: "Shift-Alt-a" }]),
83
+ entry("lines.join", "Join lines", "Lines", [{ mac: "Ctrl-j" }]),
84
+ entry("lines.deleteToEnd", "Delete to the end of the line", "Lines", [{ mac: "Ctrl-k" }]),
85
+ entry("lines.split", "Split the line at the caret", "Lines", [{ mac: "Ctrl-o" }]),
86
+ entry("lines.transpose", "Transpose the characters around the caret", "Lines", [{ mac: "Ctrl-t" }]),
87
+ // Selection and cursors
88
+ entry("select.all", "Select all", "Selection and cursors", [{ key: "Mod-a" }]),
89
+ entry("select.line", "Expand the line selection", "Selection and cursors", [{ key: "Mod-l" }, { mac: "Ctrl-l" }], { dom: true }),
90
+ entry("select.expand", "Expand selection", "Selection and cursors", [{ key: "Shift-Alt-ArrowRight", mac: "Ctrl-Shift-Cmd-ArrowRight" }]),
91
+ entry("select.shrink", "Shrink selection", "Selection and cursors", [{ key: "Shift-Alt-ArrowLeft", mac: "Ctrl-Shift-Cmd-ArrowLeft" }]),
92
+ entry("select.nextOccurrence", "Add the next occurrence to the selection", "Selection and cursors", [{ key: "Mod-d" }]),
93
+ entry("select.allOccurrences", "Select all occurrences of the selection", "Selection and cursors", [{ key: "Mod-Shift-l" }]),
94
+ entry("cursor.above", "Add a cursor above", "Selection and cursors", [{ key: "Mod-Alt-ArrowUp" }]),
95
+ entry("cursor.below", "Add a cursor below", "Selection and cursors", [{ key: "Mod-Alt-ArrowDown" }]),
96
+ entry("cursor.click", "Add a cursor", "Selection and cursors", [{ key: "Alt-Click" }], { clickOnly: ["Alt-Click"] }),
97
+ entry("cursor.column", "Select a column", "Selection and cursors", [{ key: "Shift-Alt-Drag" }], { clickOnly: ["Shift-Alt-Drag"] }),
98
+ entry("cursor.collapse", "Collapse to one cursor", "Selection and cursors", [{ key: "Escape" }], { when: "there are several cursors" }),
99
+ entry("select.undo", "Undo the last cursor movement", "Selection and cursors", [{ key: "Mod-u" }]),
100
+ entry("select.redo", "Redo the last cursor movement", "Selection and cursors", [{ key: "Alt-u", mac: "Mod-Shift-u" }]),
101
+ // Find
102
+ entry("find.open", "Find", "Find", [{ key: "Mod-f" }]),
103
+ entry("find.replace", "Replace", "Find", [{ mac: "Mod-Alt-f" }, { win: "Mod-h", linux: "Mod-h" }]),
104
+ entry("find.next", "Find next", "Find", [{ key: "F3", mac: "Mod-g" }], { when: "the find panel is open or the editor has focus" }),
105
+ entry("find.previous", "Find previous", "Find", [{ key: "Shift-F3", mac: "Mod-Shift-g" }]),
106
+ entry("find.close", "Close the find panel", "Find", [{ key: "Escape" }], { when: "the find panel is open" }),
107
+ // Format
108
+ entry("format.document", "Format the document", "Format", [{ key: "Alt-Shift-f" }, { key: "Mod-Shift-f" }], { dom: true }),
109
+ // Undo and redo
110
+ entry("edit.undo", "Undo", "Undo and redo", [{ key: "Mod-z" }]),
111
+ entry("edit.redo", "Redo", "Undo and redo", [{ key: "Mod-Shift-z" }, { win: "Mod-y", linux: "Mod-y" }], { dom: true }),
112
+ // Editing
113
+ entry("edit.enter", "New line (continues a list, keeps the indentation)", "Editing", [{ key: "Enter" }]),
114
+ entry("edit.tab", "Indent (Tab inserts whitespace)", "Editing", [{ key: "Tab" }], { when: "no list is open" }),
115
+ entry("edit.shiftTab", "Outdent", "Editing", [{ key: "Shift-Tab" }], { when: "no list is open" }),
116
+ entry("edit.fieldMenu", "Open the options of the field under the caret (dialog)", "Editing", [{ key: "Alt-Shift-o" }], { dom: true }),
117
+ // Focus
118
+ entry("focus.toggleTab", "Toggle Tab moves focus", "Focus", [{ key: "Ctrl-m", mac: "Ctrl-Shift-m" }]),
119
+ entry("focus.escapeTab", "Leave the editor", "Focus", [{ key: "Escape Tab" }], { documentedOnly: true }),
120
+ // Help
121
+ entry("help.shortcuts", "Show the keyboard shortcuts", "Help", [{ key: "Mod-k Mod-s" }, { key: "F1" }], { dom: true }),
122
+ ]);
123
+
124
+ const BY_ID = new Map(TABLE.map((item) => [item.id, item]));
125
+ export const shortcutEntry = (id) => BY_ID.get(id);
126
+
127
+ // ---- the commands CodeMirror binds, with the titles the list shows ----------------------------------------------------------------------
128
+ // Keyed by the command function, so the title does not depend on how a bundler renames functions. A binding whose command is not here is a bug
129
+ // the test finds (`unlisted`): nothing is installed without a place in the list.
130
+ const BASIC = new Map();
131
+ const basic = (command, id, title, group) => { if (command) BASIC.set(command, { id, title, group }); };
132
+ basic(moveLineUp, "lines.moveUp"); basic(moveLineDown, "lines.moveDown"); basic(copyLineUp, "lines.copyUp"); basic(copyLineDown, "lines.copyDown");
133
+ basic(deleteLine, "lines.delete"); basic(insertBlankLine, "lines.insertBelow"); basic(indentMore, "lines.indent"); basic(indentLess, "lines.outdent");
134
+ basic(toggleBlockComment, "lines.blockComment"); basic(deleteToLineEnd, "lines.deleteToEnd"); basic(splitLine, "lines.split"); basic(transposeChars, "lines.transpose");
135
+ basic(selectAll, "select.all"); basic(selectLine, "select.line"); basic(selectNextOccurrence, "select.nextOccurrence"); basic(selectSelectionMatches, "select.allOccurrences");
136
+ basic(addCursorAbove, "cursor.above"); basic(addCursorBelow, "cursor.below"); basic(simplifySelection, "cursor.collapse");
137
+ basic(undoSelection, "select.undo"); basic(redoSelection, "select.redo"); basic(undo, "edit.undo"); basic(redo, "edit.redo");
138
+ basic(cursorMatchingBracket, "nav.bracket"); basic(gotoLine, "nav.line"); basic(openSearchPanel, "find.open"); basic(findNext, "find.next"); basic(findPrevious, "find.previous");
139
+ basic(toggleTabFocusMode, "focus.toggleTab"); basic(closeSearchPanel, "find.close"); basic(closeCompletion, "suggest.dismiss"); basic(toggleComment, "lines.comment");
140
+ basic(indentSelection, "edit.indentSelection", "Auto-indent the selection", "Editing");
141
+ basic(selectParentSyntax, "select.expand"); basic(insertNewlineAndIndent, "edit.enter");
142
+ const MOVES = [
143
+ [cursorCharLeft, "left", "Move left"], [cursorCharRight, "right", "Move right"], [cursorLineUp, "up", "Move up"], [cursorLineDown, "down", "Move down"],
144
+ [cursorGroupLeft, "wordLeft", "Move a word left"], [cursorGroupRight, "wordRight", "Move a word right"],
145
+ [cursorSyntaxLeft, "syntaxLeft", "Move to the previous syntax boundary"], [cursorSyntaxRight, "syntaxRight", "Move to the next syntax boundary"],
146
+ [cursorLineBoundaryBackward, "lineStart", "Go to the start of the line"], [cursorLineBoundaryForward, "lineEnd", "Go to the end of the line"],
147
+ [cursorLineBoundaryLeft, "lineLeft", "Go to the left edge of the line"], [cursorLineBoundaryRight, "lineRight", "Go to the right edge of the line"],
148
+ [cursorLineStart, "lineStartLogical", "Go to the start of the line"], [cursorLineEnd, "lineEndLogical", "Go to the end of the line"],
149
+ [cursorDocStart, "docStart", "Go to the start of the document"], [cursorDocEnd, "docEnd", "Go to the end of the document"], [cursorPageUp, "pageUp", "Page up"], [cursorPageDown, "pageDown", "Page down"],
150
+ [deleteCharBackward, "deleteBack", "Delete the character before the caret"], [deleteCharForward, "deleteForward", "Delete the character after the caret"],
151
+ [deleteGroupBackward, "deleteWordBack", "Delete the word before the caret"], [deleteGroupForward, "deleteWordForward", "Delete the word after the caret"],
152
+ [deleteLineBoundaryBackward, "deleteToStart", "Delete to the start of the line"], [deleteLineBoundaryForward, "deleteToEnd", "Delete to the end of the line"],
153
+ ];
154
+ for (const [command, id, title] of MOVES) basic(command, `move.${id}`, title, "Cursor movement");
155
+
156
+ // ---- strokes ---------------------------------------------------------------------------------------------------------------------------
157
+ /** The platform CodeMirror uses for `navigator.platform`: "mac", "win" or "linux" (anything else is "linux"). */
158
+ export function platformOf(navigatorPlatform = typeof navigator === "undefined" ? "" : navigator.platform) {
159
+ return /Mac|iPhone|iPad/.test(navigatorPlatform) ? "mac" : /Win/.test(navigatorPlatform) ? "win" : "linux";
160
+ }
161
+
162
+ /** The stroke(s) of a binding on `platform`, as CodeMirror reads them (`binding[platform] || binding.key`), or undefined. */
163
+ export const strokeOf = (binding, platform) => binding[platform] || binding.key;
164
+
165
+ const SYMBOLS = { Mod: "⌘", Cmd: "⌘", Meta: "⌘", Ctrl: "⌃", Alt: "⌥", Shift: "⇧" };
166
+ const KEY_WORDS = { ArrowUp: "Up", ArrowDown: "Down", ArrowLeft: "Left", ArrowRight: "Right", Escape: "Esc", " ": "Space" };
167
+ const KEY_SYMBOLS = { ArrowUp: "↑", ArrowDown: "↓", ArrowLeft: "←", ArrowRight: "→", Escape: "⎋", Enter: "↩", Tab: "⇥", Backspace: "⌫", Delete: "⌦", Space: "␣" };
168
+
169
+ /** Split one stroke (`Mod-Shift-o`, `Mod-Alt--`) into its modifiers and key. */
170
+ export function parseStroke(stroke, platform) {
171
+ const parts = stroke.split(/-(?!$)/);
172
+ let key = parts.pop();
173
+ if (key === "Space") key = " ";
174
+ const mods = { ctrl: false, meta: false, alt: false, shift: false };
175
+ for (const part of parts) {
176
+ if (part === "Mod") mods[platform === "mac" ? "meta" : "ctrl"] = true;
177
+ else if (part === "Ctrl" || part === "Control") mods.ctrl = true;
178
+ else if (part === "Cmd" || part === "Meta") mods.meta = true;
179
+ else if (part === "Alt") mods.alt = true;
180
+ else if (part === "Shift") mods.shift = true;
181
+ else throw new Error(`Unknown modifier ${part} in key ${stroke}`);
182
+ }
183
+ // An upper-case letter in a CodeMirror key name means Shift is held (`Alt-A` is Shift+Alt+A).
184
+ if (key.length === 1 && key !== key.toLowerCase()) mods.shift = true;
185
+ return { ...mods, key: key.length === 1 ? key.toLowerCase() : key };
186
+ }
187
+
188
+ /** A stroke as words for a person: `Cmd+Shift+O` on macOS, `Ctrl+Shift+O` elsewhere (`symbols`: `⌘⇧O`). */
189
+ export function strokeText(stroke, platform, { symbols = false } = {}) {
190
+ return stroke.split(" ").map((one) => {
191
+ const parsed = parseStroke(one, platform);
192
+ const key = parsed.key === " " ? "Space" : parsed.key.length === 1 ? parsed.key.toUpperCase() : KEY_WORDS[parsed.key] ?? parsed.key;
193
+ if (symbols && platform === "mac") {
194
+ const letters = [parsed.ctrl && "Ctrl", parsed.alt && "Alt", parsed.shift && "Shift", parsed.meta && "Cmd"].filter(Boolean).map((name) => SYMBOLS[name]).join("");
195
+ return `${letters}${KEY_SYMBOLS[parsed.key === " " ? "Space" : parsed.key] ?? key}`;
196
+ }
197
+ const words = [parsed.ctrl && "Ctrl", parsed.meta && (platform === "mac" ? "Cmd" : "Win"), parsed.alt && "Alt", parsed.shift && "Shift"].filter(Boolean);
198
+ return [...words, key].join("+");
199
+ }).join(" ");
200
+ }
201
+
202
+ // ---- reading key events (the DOM handler) ---------------------------------------------------------------------------------------------
203
+ const CODES = { "[": "BracketLeft", "]": "BracketRight", "\\": "Backslash", "/": "Slash", ".": "Period", ",": "Comma", "-": "Minus", "=": "Equal", " ": "Space" };
204
+ const codeOf = (key) => (/^[a-z]$/.test(key) ? `Key${key.toUpperCase()}` : /^\d$/.test(key) ? `Digit${key}` : CODES[key]);
205
+
206
+ /**
207
+ * Whether a keydown `event` is the stroke `want` (from `parseStroke`). The modifiers must be exactly those; the key is read as the character it types
208
+ * (a lowercase one even with Shift held, as some platforms report it) and, where Alt changes the character (macOS), by its physical key.
209
+ */
210
+ export function eventMatches(event, want) {
211
+ if (event.ctrlKey !== want.ctrl || event.metaKey !== want.meta || event.altKey !== want.alt || event.shiftKey !== want.shift) return false;
212
+ if (want.key.length > 1 && want.key !== " ") return event.key === want.key;
213
+ if (event.key.toLowerCase() === want.key) return true;
214
+ return (event.altKey || event.shiftKey || event.ctrlKey || event.metaKey) && event.code === codeOf(want.key);
215
+ }
216
+
217
+ // ---- the builder ------------------------------------------------------------------------------------------------------------------------
218
+ /**
219
+ * The bindings of one editor. `bind(id, run)` returns CodeMirror bindings for the keys the table gives `id` (and records that they are installed);
220
+ * `bindDom(id, run)` records a command read from the keydown event; `spread(list)` records a list of CodeMirror's own bindings, with the titles above.
221
+ * `shortcuts(platform)` is what the editor installed, for the list. `platform` is the one CodeMirror reads from `navigator.platform` unless given.
222
+ */
223
+ export function createKeymap({ platform = platformOf() } = {}) {
224
+ const installed = new Map(); // id -> { entry, bindings: [binding], extra: [binding] }
225
+ const dom = []; // { id, run, strokes: [[parsed per chord stroke]] }
226
+ const unlisted = [];
227
+ const rowFor = (id, titleInfo) => {
228
+ let row = installed.get(id);
229
+ if (!row) {
230
+ const item = BY_ID.get(id);
231
+ row = { id, title: item?.title ?? titleInfo?.title ?? id, group: item?.group ?? titleInfo?.group ?? "Editing", when: item?.when, bindings: [], clicks: item?.clickOnly ? item.keys.filter((k) => item.clickOnly.includes(k.key)) : [] };
232
+ installed.set(id, row);
233
+ }
234
+ return row;
235
+ };
236
+ const bindingsOf = (item) => item.keys.filter((binding) => !(item.clickOnly ?? []).includes(binding.key));
237
+ return {
238
+ platform,
239
+ /** CodeMirror bindings for the keys of `id`, running `run`. `shift` gives the command of the same keys with Shift held. */
240
+ bind(id, run, extra = {}) {
241
+ const item = BY_ID.get(id);
242
+ if (!item) throw new Error(`No key binding named ${id}`);
243
+ if (item.dom) throw new Error(`${id} is read from the keydown event: use bindDom`);
244
+ const row = rowFor(id);
245
+ const own = bindingsOf(item);
246
+ row.bindings.push(...own);
247
+ // `run` may be one command or one per binding of the table entry, in its order.
248
+ return own.map((binding, index) => ({ ...binding, run: Array.isArray(run) ? run[index] : run, ...extra }));
249
+ },
250
+ /** Record that `id`'s keys are handled elsewhere in the editor (an alias of a command registered under another id, a menu's own keys). */
251
+ note(id) {
252
+ const item = BY_ID.get(id);
253
+ if (!item) throw new Error(`No key binding named ${id}`);
254
+ const row = rowFor(id);
255
+ row.bindings.push(...bindingsOf(item));
256
+ },
257
+ /** A command read from the keydown event: `run(view, event)` returns true when it handled the key. */
258
+ bindDom(id, run) {
259
+ const item = BY_ID.get(id);
260
+ if (!item?.dom) throw new Error(`${id} is not a keydown binding`);
261
+ const row = rowFor(id);
262
+ row.bindings.push(...item.keys);
263
+ const strokes = [];
264
+ for (const binding of item.keys) {
265
+ const stroke = strokeOf(binding, platform);
266
+ if (stroke) strokes.push(stroke.split(" ").map((one) => parseStroke(one, platform)));
267
+ }
268
+ dom.push({ id, run, strokes });
269
+ },
270
+ /** Record CodeMirror's own bindings and return them unchanged (those whose command has a title in the list). */
271
+ spread(list) {
272
+ for (const binding of list) {
273
+ for (const command of [binding.run, binding.shift]) {
274
+ if (!command) continue;
275
+ const info = BASIC.get(command);
276
+ // A Shift variant that has no title is the same command extending the selection: not a row of its own.
277
+ if (!info) { if (command === binding.run) unlisted.push(strokeOf(binding, platform) ?? binding.key ?? binding.mac ?? binding.linux ?? binding.win); continue; }
278
+ const row = rowFor(info.id, info);
279
+ if (command === binding.shift && command === binding.run) continue;
280
+ if (command === binding.shift) row.bindings.push({ ...binding, key: binding.key && `Shift-${binding.key}`, mac: binding.mac && `Shift-${binding.mac}`, win: binding.win && `Shift-${binding.win}`, linux: binding.linux && `Shift-${binding.linux}` });
281
+ else row.bindings.push(binding);
282
+ }
283
+ }
284
+ return list;
285
+ },
286
+ /** Bindings the editor handles itself and does not list (single characters it skips over, Backspace in a Markdown list, the menu's own keys). */
287
+ hidden: (list) => list,
288
+ dom,
289
+ /** Bindings recorded without a title (a bug: every installed binding has a place in the list). */
290
+ unlisted: () => [...unlisted],
291
+ /** `{ id, title, group, keys, symbols, when }` for every installed binding that has keys on `platform` (default: this editor's). */
292
+ shortcuts(requested) {
293
+ const on = requested || platform;
294
+ const rows = [];
295
+ for (const row of installed.values()) {
296
+ const keys = [];
297
+ for (const binding of row.bindings) {
298
+ const stroke = strokeOf(binding, on);
299
+ if (stroke && !keys.includes(stroke)) keys.push(stroke);
300
+ }
301
+ for (const click of row.clicks) if (!keys.includes(click.key)) keys.push(click.key);
302
+ if (!keys.length) continue;
303
+ const shown = (stroke) => (/Click|Drag$/.test(stroke) ? stroke.replace(/^Mod/, on === "mac" ? "Cmd" : "Ctrl").replaceAll("-", "+") : strokeText(stroke, on));
304
+ const symbol = (stroke) => (/Click|Drag$/.test(stroke) ? stroke.replace(/^Mod/, on === "mac" ? "Cmd" : "Ctrl").replaceAll("-", "+") : strokeText(stroke, on, { symbols: on === "mac" }));
305
+ rows.push({ id: row.id, title: row.title, group: row.group, keys: keys.map(shown), symbols: keys.map(symbol), strokes: keys, ...(row.when ? { when: row.when } : {}) });
306
+ }
307
+ const order = (row) => GROUPS.indexOf(row.group);
308
+ return rows.sort((a, b) => order(a) - order(b) || tableIndex(a.id) - tableIndex(b.id));
309
+ },
310
+ };
311
+ }
312
+
313
+ const TABLE_INDEX = new Map(TABLE.map((item, index) => [item.id, index]));
314
+ const tableIndex = (id) => TABLE_INDEX.get(id) ?? 10000 + [...BASIC.values()].findIndex((info) => info.id === id);
315
+
316
+ /** `list` without the bindings of `drop` (a function of the binding) and with `add` after them. */
317
+ export const without = (list, drop) => list.filter((binding) => !drop(binding));
318
+
319
+ /** CodeMirror's close-brackets bindings (Backspace removes a pair), kept as they are. */
320
+ export const CLOSE_BRACKETS = closeBracketsKeymap;
321
+
322
+ // ---- the keydown handler --------------------------------------------------------------------------------------------------------------
323
+ const MODIFIER_KEYS = new Set(["Shift", "Control", "Alt", "Meta", "AltGraph", "CapsLock"]);
324
+ const CHORD_TIMEOUT = 4000;
325
+ const signature = (stroke) => `${stroke.ctrl ? "c" : ""}${stroke.meta ? "m" : ""}${stroke.alt ? "a" : ""}${stroke.shift ? "s" : ""}:${stroke.key}`;
326
+
327
+ /**
328
+ * The handler for the bindings `km.bindDom` recorded. A binding of one stroke runs when the event is that stroke exactly; a chord (`Mod-k Mod-i`) waits for
329
+ * its second stroke for four seconds, and a key that is not the second stroke of any chord is handled as if the first had not been pressed. `run(view, event)`
330
+ * returns false to leave the key alone. The first stroke of a chord is taken, so Cmd/Ctrl+K alone does nothing else; Cmd/Ctrl+Shift+K (Delete line) is another stroke.
331
+ */
332
+ export function domKeymap(km, say) {
333
+ let pending = null;
334
+ const chords = km.dom.flatMap((command) => command.strokes.filter((strokes) => strokes.length === 2).map((strokes) => ({ command, first: strokes[0], second: strokes[1], sig: signature(strokes[0]) })));
335
+ return EditorView.domEventHandlers({
336
+ keydown(event, view) {
337
+ if (event.isComposing || MODIFIER_KEYS.has(event.key)) return false;
338
+ if (pending) {
339
+ const waiting = pending;
340
+ pending = null;
341
+ if (Date.now() - waiting.at < CHORD_TIMEOUT) {
342
+ const hit = chords.find((chord) => chord.sig === waiting.sig && eventMatches(event, chord.second));
343
+ if (hit) {
344
+ event.preventDefault();
345
+ if (hit.command.run(view, event) === false) return false;
346
+ return true;
347
+ }
348
+ }
349
+ }
350
+ for (const command of km.dom) {
351
+ for (const strokes of command.strokes) {
352
+ if (strokes.length === 1 && eventMatches(event, strokes[0])) {
353
+ if (command.run(view, event) === false) continue;
354
+ event.preventDefault();
355
+ return true;
356
+ }
357
+ }
358
+ }
359
+ const first = chords.find((chord) => eventMatches(event, chord.first));
360
+ if (first) {
361
+ pending = { sig: first.sig, at: Date.now() };
362
+ event.preventDefault();
363
+ say?.("Chord started, waiting for the second key");
364
+ return true;
365
+ }
366
+ return false;
367
+ },
368
+ });
369
+ }
@@ -10,6 +10,7 @@ import { fromMarkdown } from "@openpresentation/opf/markdown";
10
10
  import { yamlContextAt } from "./source-yaml.js";
11
11
  import { snippetMarkdown, snippetsForMarkdown } from "./source-snippets.js";
12
12
  import { fieldDoc } from "./source-docs.js";
13
+ import { regionNames } from "./source-references.js";
13
14
 
14
15
  const OPTION_KEY = /^[A-Za-z][A-Za-z0-9_-]*$/;
15
16
  const BARE = /^[A-Za-z0-9_-]+$/;
@@ -257,6 +258,21 @@ function metricContext(text, position, fence) {
257
258
  }
258
259
 
259
260
  // ---- the body ----------------------------------------------------------------------------------------------------------------------
261
+ // `![alt](` and what is typed of the target after it (no space or closing parenthesis yet).
262
+ const IMAGE_TARGET = /!\[(?:[^\]\\\n]|\\.)*\]\(([^)\s]*)$/;
263
+ const VIDEO_EXTENSIONS = /\.(?:mp4|m4v|mov|webm|ogv|mkv)(?:[?#].*)?$/i;
264
+
265
+ /** Whether the nearest line above `lineStart` that is not blank is a block options comment with `as=video` (the image line below it is a video). */
266
+ function videoLineAbove(text, start) {
267
+ for (let at = start; at > 0;) {
268
+ const line = lineAt(text, at - 1);
269
+ at = line.start;
270
+ if (line.text.trim() === "") continue;
271
+ return /^\s*<!--\s*block\s*:/.test(line.text) && /\bas=("video"|video)(?=\s|-->)/.test(line.text);
272
+ }
273
+ return false;
274
+ }
275
+
260
276
  function bodyContext(text, position, where, explicit) {
261
277
  const { scan } = where;
262
278
  const line = lineAt(text, position);
@@ -270,6 +286,20 @@ function bodyContext(text, position, where, explicit) {
270
286
  const start = position - span[2].length;
271
287
  return { format: "md", region: "span", token: { quoted: false, closed: false, start, end, from: start, to: end }, existing: used, document: {}, path: [], explicit, opened: span[2] === "" };
272
288
  }
289
+ // `![alt](` in a line of text: the target of an image (an asset of the document, or an image variable).
290
+ const image = IMAGE_TARGET.exec(before);
291
+ if (image) {
292
+ const start = position - image[1].length;
293
+ let end = position;
294
+ while (end < line.end && !/[\s)]/.test(text[end])) end++;
295
+ return { format: "md", region: "image", video: videoLineAbove(text, line.start), closed: text[end] === ")", token: { quoted: false, closed: false, start, end, from: start, to: end }, document: {}, path: [] };
296
+ }
297
+ // `{{` in a line of text: the variables of the document.
298
+ const variable = /\{\{([A-Za-z0-9._-]*)$/.exec(before);
299
+ if (variable) {
300
+ const start = position - variable[1].length;
301
+ return { format: "md", region: "variable", token: { quoted: false, closed: false, start, end: position, from: start, to: position }, document: {}, path: [] };
302
+ }
273
303
  if (!/^[A-Za-z][A-Za-z-]*$|^$/.test(before)) return null;
274
304
  if (before === "" && !explicit) return null;
275
305
  // Typing prose is not interrupted: a list opens by itself from the third letter on.
@@ -289,20 +319,8 @@ function bodyContext(text, position, where, explicit) {
289
319
  // ---- the options -------------------------------------------------------------------------------------------------------------------
290
320
  const kindOption = (label, type, detail, doc, boost, kind, extra = {}) => ({ label, type, detail, boost, opfDoc: doc, opf: { kind, ...extra } });
291
321
 
292
- function regionNames(record) {
293
- const names = [];
294
- const walk = (regions) => {
295
- for (const region of Array.isArray(regions) ? regions : []) {
296
- if (typeof region?.name === "string" && region.name !== "title" && region.name !== "subtitle") names.push(region.name);
297
- if (region?.regions) walk(region.regions);
298
- }
299
- };
300
- walk(record?.regions);
301
- return names;
302
- }
303
-
304
322
  /** The completion options of a Markdown context: `{ options, validFor? }`, or null. `makers` are the option builders of the completion engine. */
305
- export function markdownOptions(context, { catalogs, text, explicit, makers }) {
323
+ export function markdownOptions(context, { catalogs, text, explicit, makers, scope }) {
306
324
  const { literalOption, boostAt, docNode } = makers;
307
325
  const options = [];
308
326
  if (context.region === "comment-start") {
@@ -310,7 +328,7 @@ export function markdownOptions(context, { catalogs, text, explicit, makers }) {
310
328
  options.push(kindOption("block:", "keyword", "options", "Options of the next block: id, type, as, region.", boostAt(1), "comment", { word: "block:" }));
311
329
  return { options, validFor: /^[a-z]*$/ };
312
330
  }
313
- if (context.region === "options") return optionsOptions(context, { catalogs, makers });
331
+ if (context.region === "options") return optionsOptions(context, { catalogs, makers, scope });
314
332
  if (context.region === "fence-info") {
315
333
  if (context.mode === "kind") {
316
334
  let order = 0;
@@ -352,6 +370,23 @@ export function markdownOptions(context, { catalogs, text, explicit, makers }) {
352
370
  marks.forEach(([mark, word, doc], index) => options.push(kindOption(mark, "keyword", word, doc, boostAt(index), "status", { mark })));
353
371
  return { options, validFor: /^(?:\[[x> ]?\]?)?$/ };
354
372
  }
373
+ if (context.region === "image") {
374
+ // The assets of the document first (the videos first for a video line), then the image variables.
375
+ const facts = scope?.facts() ?? { assets: [], variables: [] };
376
+ const isVideo = (asset) => asset.mediaType.startsWith("video/") || VIDEO_EXTENSIONS.test(asset.src);
377
+ const assets = [...facts.assets].sort((a, b) => (context.video ? Number(isVideo(b)) - Number(isVideo(a)) : 0));
378
+ for (const asset of assets) {
379
+ const value = `asset:${asset.id}`;
380
+ const detail = asset.detail || "asset";
381
+ options.push({ label: value, type: "reference", detail, boost: boostAt(options.length), opfDoc: asset.detail, opf: { kind: "imageTarget", value }, info: () => docNode({ title: value, type: "asset", description: asset.detail || undefined, notes: [context.video && isVideo(asset) ? "A video" : ""].filter(Boolean) }) });
382
+ }
383
+ for (const variable of facts.variables) {
384
+ if (variable.type !== "image") continue;
385
+ const value = `var:${variable.id}`;
386
+ options.push({ label: value, type: "reference", detail: "image variable", boost: boostAt(options.length), opfDoc: `The image variable ${variable.id}`, opf: { kind: "imageTarget", value }, info: () => docNode({ title: value, type: "image variable" }) });
387
+ }
388
+ return { options, validFor: /^[A-Za-z0-9_.:+-]*$/ };
389
+ }
355
390
  if (context.region === "span") {
356
391
  // Right after `{` or a space the list opens by itself (the characters that begin a new attribute); Ctrl+Space opens it anywhere in the braces.
357
392
  const docs = { bold: "Bold text", italic: "Italic text", underline: "Underlined text", strike: "Struck-through text", sup: "Superscript", sub: "Subscript", color: "Text color: a hex color, a scheme slot or var:name", size: "Font size in points", font: "Font family", lang: "Language of the text (a BCP-47 tag)" };
@@ -368,7 +403,7 @@ export function markdownOptions(context, { catalogs, text, explicit, makers }) {
368
403
  return null;
369
404
  }
370
405
 
371
- function optionsOptions(context, { catalogs, makers }) {
406
+ function optionsOptions(context, { catalogs, makers, scope }) {
372
407
  const { literalOption, boostAt, docNode } = makers;
373
408
  const options = [];
374
409
  if (context.mode === "key") {
@@ -399,7 +434,7 @@ function optionsOptions(context, { catalogs, makers }) {
399
434
  }
400
435
  if (!SLIDE_OPTION_KEYS.includes(key)) return null;
401
436
  const pseudo = { format: "md", mode: "value", path: ["slides", context.slide, key], document: context.document, key, token: context.token };
402
- const built = makers.valueOptions(pseudo, catalogs);
437
+ const built = makers.valueOptions(pseudo, catalogs, scope);
403
438
  options.push(...built.options);
404
439
  return { options, validFor: /^[A-Za-z0-9_:+-]*$/ };
405
440
  }
@@ -443,6 +478,7 @@ export function markdownPlan(context, completion, text, end) {
443
478
  return { template: `${completion.opf.key}: `, fields: 0, chain: Boolean(completion.opf.chain) };
444
479
  }
445
480
  if (context.region === "metric" && kind === "literal") return { template: `${context.needsSpace ? " " : ""}${escapeTemplate(String(completion.opf.value))}`, fields: 0, chain: false };
481
+ if (kind === "imageTarget") return { template: `${escapeTemplate(completion.opf.value)}${context.closed ? "" : ")"}`, fields: 0, chain: false };
446
482
  if (kind === "status") return { template: `${completion.opf.mark} `, fields: 0, chain: false };
447
483
  if (kind === "spanAttribute") return { template: `${completion.opf.name}${completion.opf.takesValue ? "=" : ""}`, fields: 0, chain: false };
448
484
  if (kind === "keyword") return { template: `${escapeTemplate(completion.opf.word)} `, fields: 0, chain: false };
@@ -459,7 +495,7 @@ export function markdownPlan(context, completion, text, end) {
459
495
 
460
496
  // ---- hover -------------------------------------------------------------------------------------------------------------------------
461
497
  // What a piece of the dialect's syntax stands for, as a card: the schema's words for the field it writes, and how it is written.
462
- const SYNTAX_FIELDS = {
498
+ export const SYNTAX_FIELDS = {
463
499
  title: { key: "title", written: "# Title" },
464
500
  subtitle: { key: "subtitle", written: "## Subtitle" },
465
501
  notes: { key: "notes", written: "Note: text, to the end of the slide" },