@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,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
+ }
@@ -258,6 +258,21 @@ function metricContext(text, position, fence) {
258
258
  }
259
259
 
260
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
+
261
276
  function bodyContext(text, position, where, explicit) {
262
277
  const { scan } = where;
263
278
  const line = lineAt(text, position);
@@ -271,6 +286,14 @@ function bodyContext(text, position, where, explicit) {
271
286
  const start = position - span[2].length;
272
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] === "" };
273
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
+ }
274
297
  // `{{` in a line of text: the variables of the document.
275
298
  const variable = /\{\{([A-Za-z0-9._-]*)$/.exec(before);
276
299
  if (variable) {
@@ -347,6 +370,23 @@ export function markdownOptions(context, { catalogs, text, explicit, makers, sco
347
370
  marks.forEach(([mark, word, doc], index) => options.push(kindOption(mark, "keyword", word, doc, boostAt(index), "status", { mark })));
348
371
  return { options, validFor: /^(?:\[[x> ]?\]?)?$/ };
349
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
+ }
350
390
  if (context.region === "span") {
351
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.
352
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)" };
@@ -438,6 +478,7 @@ export function markdownPlan(context, completion, text, end) {
438
478
  return { template: `${completion.opf.key}: `, fields: 0, chain: Boolean(completion.opf.chain) };
439
479
  }
440
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 };
441
482
  if (kind === "status") return { template: `${completion.opf.mark} `, fields: 0, chain: false };
442
483
  if (kind === "spanAttribute") return { template: `${completion.opf.name}${completion.opf.takesValue ? "=" : ""}`, fields: 0, chain: false };
443
484
  if (kind === "keyword") return { template: `${escapeTemplate(completion.opf.word)} `, fields: 0, chain: false };
@@ -454,7 +495,7 @@ export function markdownPlan(context, completion, text, end) {
454
495
 
455
496
  // ---- hover -------------------------------------------------------------------------------------------------------------------------
456
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.
457
- const SYNTAX_FIELDS = {
498
+ export const SYNTAX_FIELDS = {
458
499
  title: { key: "title", written: "# Title" },
459
500
  subtitle: { key: "subtitle", written: "## Subtitle" },
460
501
  notes: { key: "notes", written: "Note: text, to the end of the slide" },