@k2b/editor 0.0.0 → 0.1.1

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.
Files changed (83) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +32 -1
  3. package/package.json +116 -4
  4. package/src/BubbleMenu.tsx +126 -0
  5. package/src/Editor.tsx +32 -0
  6. package/src/SlashMenu.tsx +13 -0
  7. package/src/Toolbar.tsx +205 -0
  8. package/src/actions.ts +102 -0
  9. package/src/chart-schema.ts +274 -0
  10. package/src/charts.ts +710 -0
  11. package/src/collab/history.ts +39 -0
  12. package/src/collab/index.ts +32 -0
  13. package/src/collab/relative-cursor.ts +18 -0
  14. package/src/collab/remote-cursors.ts +358 -0
  15. package/src/editor-state.ts +199 -0
  16. package/src/formula.ts +1283 -0
  17. package/src/index.ts +16 -0
  18. package/src/markdown/_lib/cursor-zone-field.ts +338 -0
  19. package/src/markdown/_lib/inline-syntax.ts +9 -0
  20. package/src/markdown/_lib/link-opener.ts +50 -0
  21. package/src/markdown/_lib/list-item-line.ts +34 -0
  22. package/src/markdown/_lib/list-structure.ts +227 -0
  23. package/src/markdown/_lib/math-syntax.ts +3 -0
  24. package/src/markdown/_lib/table-cell.ts +74 -0
  25. package/src/markdown/basic.ts +37 -0
  26. package/src/markdown/block-previews.tsx +453 -0
  27. package/src/markdown/chart-block-messages.ts +5 -0
  28. package/src/markdown/chart-copy.ts +20 -0
  29. package/src/markdown/code-fence-snippets.ts +167 -0
  30. package/src/markdown/code-font.ts +54 -0
  31. package/src/markdown/completion-icon.ts +9 -0
  32. package/src/markdown/data-block-template.ts +11 -0
  33. package/src/markdown/data-blocks.ts +214 -0
  34. package/src/markdown/editor-scope.ts +92 -0
  35. package/src/markdown/fade-scroll-margins.browser-harness.ts +78 -0
  36. package/src/markdown/fade-scroll-margins.ts +25 -0
  37. package/src/markdown/image-syntax.ts +26 -0
  38. package/src/markdown/images.ts +192 -0
  39. package/src/markdown/index.ts +54 -0
  40. package/src/markdown/info-block-snippets.ts +159 -0
  41. package/src/markdown/info-blocks.ts +156 -0
  42. package/src/markdown/katex.ts +303 -0
  43. package/src/markdown/lib/chart-blocks.ts +511 -0
  44. package/src/markdown/lib/heading-anchors.ts +40 -0
  45. package/src/markdown/lib/ligatures.ts +107 -0
  46. package/src/markdown/lib/markdown-context.ts +72 -0
  47. package/src/markdown/lib/markdown-fences.ts +22 -0
  48. package/src/markdown/lib/markdown-image.fixtures.ts +20 -0
  49. package/src/markdown/lib/markdown-image.ts +36 -0
  50. package/src/markdown/lib/named-blocks.ts +403 -0
  51. package/src/markdown/lib/query-blocks.ts +145 -0
  52. package/src/markdown/ligatures.ts +223 -0
  53. package/src/markdown/links.ts +158 -0
  54. package/src/markdown/list-commands.ts +1124 -0
  55. package/src/markdown/lists.ts +290 -0
  56. package/src/markdown/mark.ts +103 -0
  57. package/src/markdown/markdown.ts +78 -0
  58. package/src/markdown/markup.ts +385 -0
  59. package/src/markdown/mermaid-export.ts +78 -0
  60. package/src/markdown/mermaid-preview.tsx +76 -0
  61. package/src/markdown/mermaid-render.ts +22 -0
  62. package/src/markdown/mermaid-viewport.tsx +78 -0
  63. package/src/markdown/mermaid.ts +242 -0
  64. package/src/markdown/messages.ts +17 -0
  65. package/src/markdown/named-blocks.ts +84 -0
  66. package/src/markdown/note-scroller.browser-harness.ts +84 -0
  67. package/src/markdown/note-scroller.ts +57 -0
  68. package/src/markdown/notebook-charts.tsx +142 -0
  69. package/src/markdown/pretty-table-messages.ts +33 -0
  70. package/src/markdown/pretty-table.ts +220 -0
  71. package/src/markdown/search-theme.ts +98 -0
  72. package/src/markdown/shared-tables.ts +286 -0
  73. package/src/markdown/sub-sup.ts +115 -0
  74. package/src/markdown/surface-press.ts +17 -0
  75. package/src/markdown/tab-indent.ts +104 -0
  76. package/src/markdown/table-columns.ts +188 -0
  77. package/src/markdown/table-formulas.ts +178 -0
  78. package/src/markdown/tables.ts +703 -0
  79. package/src/markdown/theme.ts +243 -0
  80. package/src/markdown/urls.ts +29 -0
  81. package/src/messages.ts +45 -0
  82. package/src/slash-menu.ts +220 -0
  83. package/src/styles.css +376 -0
@@ -0,0 +1,243 @@
1
+ import { EditorView } from "@codemirror/view";
2
+ import { tags as t } from "@lezer/highlight";
3
+ import { type CreateThemeOptions, createTheme } from "@uiw/codemirror-themes";
4
+
5
+ /**
6
+ * Layout / chrome styles — shared between rich and raw modes. Deliberately
7
+ * font-free so each mode can pick its own type system (rich = 16px sans
8
+ * with code-mark mono override, raw = 14px mono).
9
+ */
10
+ const baseEditorCSS = EditorView.theme({
11
+ "&": {
12
+ overflow: "hidden",
13
+ flex: "1",
14
+ minHeight: "0",
15
+ height: "100%",
16
+ },
17
+ ".cm-line": {
18
+ cursor: "text",
19
+ maxWidth: "100%",
20
+ overflow: "visible",
21
+ padding: "0",
22
+ },
23
+ "&.cm-editor.cm-focused": { outline: "none" },
24
+ // The scroller, not the content, clips sideways: a Markdown table widget
25
+ // reaches past the text into the scroller's inline padding, so its lines and
26
+ // hover extend past the text as in the Book. The content keeps the text's
27
+ // edges, which CodeMirror draws selections from.
28
+ ".cm-scroller": {
29
+ overflow: "auto",
30
+ overflowX: "hidden",
31
+ maxWidth: "100%",
32
+ margin: "0 auto",
33
+ },
34
+ // The reading column of the app's stylesheet (`--editor-measure`), the
35
+ // same width as the Book's text. Each mode centers it in the free space.
36
+ ".cm-content": {
37
+ maxWidth: "min(100%, var(--editor-measure, 100%))",
38
+ },
39
+ ".cm-gutters": {
40
+ backgroundColor: "transparent",
41
+ border: "none",
42
+ marginRight: "12px",
43
+ },
44
+ // Gutter / line numbers always mono for tabular alignment, regardless
45
+ // of the rich/raw body font.
46
+ ".cm-gutterElement": {
47
+ color: "var(--color-gray-500)",
48
+ fontFamily: "var(--font-mono) !important",
49
+ fontSize: "12px",
50
+ paddingRight: "8px",
51
+ },
52
+ ".cm-lineNumbers .cm-gutterElement": {
53
+ display: "flex",
54
+ alignItems: "center",
55
+ justifyContent: "center",
56
+ paddingLeft: "8px",
57
+ },
58
+ ".cm-foldPlaceholder": { backgroundColor: "transparent", border: "none" },
59
+ ".cm-cursor": { borderLeftWidth: "2px" },
60
+ ".cm-cursor-primary": {
61
+ borderLeftWidth: "2px",
62
+ borderLeftColor: "oklch(62.3% 0.214 259.815)",
63
+ },
64
+ });
65
+
66
+ /**
67
+ * Rich-mode type: prose in Sans at the Book's size and line height, so a
68
+ * paragraph breaks into the same lines while writing and reading; code in Mono.
69
+ *
70
+ * `.cm-md-code` is applied per-range by `codeFontExtension` against the
71
+ * markdown syntax tree (FencedCode / InlineCode / CodeBlock nodes).
72
+ */
73
+ const richFontCSS = EditorView.theme({
74
+ "&": { fontFamily: "var(--font-sans)", fontSize: "16px" },
75
+ ".cm-scroller": { lineHeight: "1.6" },
76
+ ".cm-line": { fontFamily: "var(--font-sans)" },
77
+ // The Book's code sizes (`.notebook-book-content pre` and inline `code`).
78
+ ".cm-md-code": { fontFamily: "var(--font-mono)", fontSize: "0.9em" },
79
+ ".cm-md-code.cm-md-code-block": { fontSize: "0.85em" },
80
+ ".cm-content": { marginInline: "auto" },
81
+ // CodeMirror wraps with `break-spaces`, where a space at the end of a line
82
+ // takes room and pushes the last word down. The Book lets it hang, so the
83
+ // editor does too and both break a paragraph at the same words. A run of
84
+ // spaces typed at a wrap point hangs past the column with the caret until
85
+ // the next word wraps; the text itself stays in place.
86
+ "&.cm-editor .cm-lineWrapping": { whiteSpace: "pre-wrap" },
87
+ });
88
+
89
+ /**
90
+ * Raw-mode type: full mono — raw mode is a literal source view, sans
91
+ * would obscure the markdown structure. The line numbers travel with the
92
+ * column, so numbers and text center together.
93
+ */
94
+ const rawFontCSS = EditorView.theme({
95
+ "&": { fontFamily: "var(--font-mono)", fontSize: "14px" },
96
+ ".cm-line": { fontFamily: "var(--font-mono)" },
97
+ ".cm-gutters": { marginLeft: "auto" },
98
+ ".cm-content": { marginRight: "auto" },
99
+ });
100
+
101
+ /** The Book's text colors (`.notebook-book-shell`), so writing and reading look alike. */
102
+ const lightText = "var(--color-zinc-950)";
103
+ const darkText = "var(--color-zinc-100)";
104
+
105
+ /**
106
+ * The heading scale of the Book (`.notebook-book-content` in the app's
107
+ * stylesheet). Headings, bold and italic keep the text color in both themes;
108
+ * only code is highlighted.
109
+ */
110
+ const headingStyles: CreateThemeOptions["styles"] = [
111
+ { tag: t.heading1, fontSize: "1.875em", fontWeight: "650", lineHeight: "1.25" },
112
+ { tag: t.heading2, fontSize: "1.5em", fontWeight: "650", lineHeight: "1.3" },
113
+ { tag: t.heading3, fontSize: "1.25em", fontWeight: "650", lineHeight: "1.4" },
114
+ { tag: t.heading4, fontSize: "1.125em", fontWeight: "650" },
115
+ { tag: [t.heading5, t.heading6], fontSize: "1em", fontWeight: "650" },
116
+ ];
117
+
118
+ const customLightStyle: CreateThemeOptions["styles"] = [
119
+ { tag: [t.standard(t.tagName), t.tagName], color: "#116329" },
120
+ { tag: [t.comment, t.bracket], color: "#6a737d" },
121
+ { tag: [t.className, t.propertyName], color: "#6f42c1" },
122
+ {
123
+ tag: [t.variableName, t.attributeName, t.number, t.operator],
124
+ color: "#005cc5",
125
+ },
126
+ {
127
+ tag: [t.keyword, t.typeName, t.typeOperator],
128
+ color: "#d73a49",
129
+ },
130
+ { tag: [t.string, t.regexp], color: "#032f62" },
131
+ { tag: [t.name, t.quote], color: "#22863a" },
132
+ { tag: [t.strong], fontWeight: "700" },
133
+ { tag: [t.emphasis], fontStyle: "italic" },
134
+ { tag: [t.deleted], color: "#b31d28", backgroundColor: "#ffeef0" },
135
+ { tag: [t.atom, t.bool, t.special(t.variableName)], color: "#e36209" },
136
+ { tag: [t.url, t.escape, t.link], color: "#032f62" },
137
+ { tag: t.strikethrough, textDecoration: "line-through" },
138
+ { tag: t.invalid, color: "#cb2431" },
139
+ ...headingStyles,
140
+ { tag: t.meta, color: "var(--color-gray-400)" },
141
+ ];
142
+
143
+ const customDarkStyle: CreateThemeOptions["styles"] = [
144
+ { tag: [t.standard(t.tagName), t.tagName], color: "#7ee787" },
145
+ { tag: [t.comment, t.bracket], color: "#8b949e" },
146
+ { tag: [t.className, t.propertyName], color: "#d2a8ff" },
147
+ {
148
+ tag: [t.variableName, t.attributeName, t.number, t.operator],
149
+ color: "#79c0ff",
150
+ },
151
+ {
152
+ tag: [t.keyword, t.typeName, t.typeOperator],
153
+ color: "#ff7b72",
154
+ },
155
+ { tag: [t.string, t.regexp], color: "#a5d6ff" },
156
+ { tag: [t.name, t.quote], color: "#7ee787" },
157
+ { tag: [t.strong], fontWeight: "700" },
158
+ { tag: [t.emphasis], fontStyle: "italic" },
159
+ { tag: [t.deleted], color: "#ffdcd7", backgroundColor: "#ffeef0" },
160
+ { tag: [t.atom, t.bool, t.special(t.variableName)], color: "#ffab70" },
161
+ { tag: t.link, textDecoration: "underline" },
162
+ { tag: t.strikethrough, textDecoration: "line-through" },
163
+ { tag: t.invalid, color: "#f97583" },
164
+ ...headingStyles,
165
+ { tag: t.meta, color: "var(--color-gray-500)" },
166
+ ];
167
+
168
+ export const customLightInit = (options?: Partial<CreateThemeOptions>) => {
169
+ const { theme = "light", settings = {}, styles = [] } = options ?? {};
170
+ return [
171
+ createTheme({
172
+ theme,
173
+ settings: {
174
+ background: "transparent",
175
+ foreground: lightText,
176
+ selection: "#BBDFFF",
177
+ selectionMatch: "#e4e4e7",
178
+ gutterBackground: "transparent",
179
+ gutterForeground: "#6e7781",
180
+ ...settings,
181
+ },
182
+ styles: [...customLightStyle, ...styles],
183
+ }),
184
+ baseEditorCSS,
185
+ richFontCSS,
186
+ ];
187
+ };
188
+
189
+ export const customDarkInit = (options?: Partial<CreateThemeOptions>) => {
190
+ const { theme = "dark", settings = {}, styles = [] } = options ?? {};
191
+ return [
192
+ createTheme({
193
+ theme,
194
+ settings: {
195
+ background: "transparent",
196
+ foreground: darkText,
197
+ caret: darkText,
198
+ selection: "#003d73",
199
+ selectionMatch: "#3f3f46",
200
+ gutterBackground: "transparent",
201
+ ...settings,
202
+ },
203
+ styles: [...customDarkStyle, ...styles],
204
+ }),
205
+ baseEditorCSS,
206
+ richFontCSS,
207
+ ];
208
+ };
209
+
210
+ /** Raw mode themes — editor CSS only, no syntax highlighting styles */
211
+ export const rawLightInit = () => [
212
+ createTheme({
213
+ theme: "light",
214
+ settings: {
215
+ background: "transparent",
216
+ foreground: lightText,
217
+ selection: "#BBDFFF",
218
+ selectionMatch: "#e4e4e7",
219
+ gutterBackground: "transparent",
220
+ gutterForeground: "#6e7781",
221
+ },
222
+ styles: [],
223
+ }),
224
+ baseEditorCSS,
225
+ rawFontCSS,
226
+ ];
227
+
228
+ export const rawDarkInit = () => [
229
+ createTheme({
230
+ theme: "dark",
231
+ settings: {
232
+ background: "transparent",
233
+ foreground: darkText,
234
+ caret: darkText,
235
+ selection: "#003d73",
236
+ selectionMatch: "#3f3f46",
237
+ gutterBackground: "transparent",
238
+ },
239
+ styles: [],
240
+ }),
241
+ baseEditorCSS,
242
+ rawFontCSS,
243
+ ];
@@ -0,0 +1,29 @@
1
+ /** A source reference and the host's resolved destination. */
2
+ export type MarkdownUrlKind = "image" | "link";
3
+ export type MarkdownUrlOptions = {
4
+ /**
5
+ * Resolve application-owned schemes; ordinary URLs may be returned unchanged. For an image, the result may also be a
6
+ * `blob:` URL, such as one for an image the browser stores locally; links stay limited to safe protocols.
7
+ */
8
+ resolveUrl?: (kind: MarkdownUrlKind, ref: string) => string;
9
+ /** A reference link opened while keeping the editor selection. */
10
+ openLink?: (url: string, ref: string, label: string) => void;
11
+ /** An image opened by pointer or keyboard. */
12
+ openImage?: (image: HTMLImageElement, url: string, label: string) => void;
13
+ /** Show external image sources as links without fetching them. Defaults to false. */
14
+ foreignImagesAsLinks?: boolean;
15
+ };
16
+
17
+ const SAFE_URL_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:"]);
18
+
19
+ export const isSafeMarkdownUrl = (url: string): boolean => {
20
+ if (url.startsWith("//")) return false;
21
+ if (!/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(url)) return true;
22
+ try {
23
+ return SAFE_URL_PROTOCOLS.has(new URL(url).protocol);
24
+ } catch {
25
+ return false;
26
+ }
27
+ };
28
+
29
+ export const extractAttachmentId = (url: string): string | null => /^attach:\/\/([0-9a-zA-Z]{6})$/.exec(url)?.[1] ?? null;
@@ -0,0 +1,45 @@
1
+ import { i18n } from "@k2b/stdlib";
2
+
3
+ export const editorMessages = i18n.define({
4
+ baseLocale: "en",
5
+ messages: {
6
+ en: {
7
+ formatting: "Formatting",
8
+ selection: "Selection",
9
+ insert: "Insert",
10
+ bold: "Bold",
11
+ italic: "Italic",
12
+ strikethrough: "Strikethrough",
13
+ code: "Inline code",
14
+ link: "Link",
15
+ heading1: "Heading 1",
16
+ heading2: "Heading 2",
17
+ heading3: "Heading 3",
18
+ bulletList: "Bulleted list",
19
+ numberedList: "Numbered list",
20
+ quote: "Quote",
21
+ codeBlock: "Code block",
22
+ table: "Table",
23
+ withShortcut: ({ label, keys }: { label: string; keys: string }) => `${label} (${keys})`,
24
+ },
25
+ de: {
26
+ formatting: "Formatierung",
27
+ selection: "Auswahl",
28
+ insert: "Einfügen",
29
+ bold: "Fett",
30
+ italic: "Kursiv",
31
+ strikethrough: "Durchgestrichen",
32
+ code: "Code im Text",
33
+ link: "Link",
34
+ heading1: "Überschrift 1",
35
+ heading2: "Überschrift 2",
36
+ heading3: "Überschrift 3",
37
+ bulletList: "Aufzählung",
38
+ numberedList: "Nummerierte Liste",
39
+ quote: "Zitat",
40
+ codeBlock: "Codeblock",
41
+ table: "Tabelle",
42
+ withShortcut: ({ label, keys }) => `${label} (${keys})`,
43
+ },
44
+ },
45
+ });
@@ -0,0 +1,220 @@
1
+ import {
2
+ autocompletion,
3
+ type Completion,
4
+ type CompletionContext,
5
+ type CompletionResult,
6
+ type CompletionSource,
7
+ pickedCompletion,
8
+ } from "@codemirror/autocomplete";
9
+ import type { Extension } from "@codemirror/state";
10
+ import { type EditorView, ViewPlugin } from "@codemirror/view";
11
+ import { fuzzy } from "@k2b/stdlib";
12
+ import type { EditorAction } from "./editor-state";
13
+
14
+ /** One entry of the slash menu: an editor action reached by typing `/` and its name. */
15
+ export type SlashAction = Omit<EditorAction, "run"> & {
16
+ /** What a person types after `/`: lowercase, no spaces. */
17
+ name: string;
18
+ /** One line after the label. */
19
+ description?: string;
20
+ /** Further names that match exactly, such as `img` for an image action. */
21
+ aliases?: string[];
22
+ /**
23
+ * A suffix after the name, such as `2x4` in `/table2x4`. Without it, only the bare name matches exactly; with it,
24
+ * `run` receives the match for a typed suffix and `undefined` for the bare name.
25
+ */
26
+ params?: RegExp;
27
+ /** `true` inserts at the caret; otherwise an action typed mid-line starts on a new line. */
28
+ inline?: boolean;
29
+ /** Runs after the typed `/name` left the document. */
30
+ run: (view: EditorView, params?: RegExpMatchArray) => void | Promise<void>;
31
+ };
32
+
33
+ export type SlashMenuOptions = {
34
+ actions: SlashAction[];
35
+ /**
36
+ * Further completion sources that share the popup, such as links or tags. CodeMirror shows one completion popup per
37
+ * editor, so every source the host wants lives in this list; a source that throws only drops its own options.
38
+ */
39
+ sources?: { name: string; source: CompletionSource }[];
40
+ };
41
+
42
+ /** `/name` after the line start or a space; group 1 is the space, group 2 the typed name and suffix. */
43
+ const trigger = /(^|\s)\/(\w*)$/;
44
+ /** stdlib's fuzzy scores run up to about 100; below this, accidental subsequence matches crowd the list. */
45
+ const minimumScore = 25;
46
+
47
+ type SlashCompletion = Completion & { slashAction: SlashAction; parsedParams?: RegExpMatchArray };
48
+
49
+ const matchExact = (action: SlashAction, typed: string): { params?: RegExpMatchArray } | null => {
50
+ const lower = typed.toLowerCase();
51
+ const names = [action.name, ...(action.aliases ?? [])];
52
+ if (names.some((name) => name.toLowerCase() === lower)) return { params: undefined };
53
+ if (!action.params) return null;
54
+ for (const name of names) {
55
+ if (!lower.startsWith(name.toLowerCase())) continue;
56
+ const suffix = typed.slice(name.length);
57
+ const match = suffix ? action.params.exec(suffix) : null;
58
+ if (match) return { params: match };
59
+ }
60
+ return null;
61
+ };
62
+
63
+ // CodeMirror accepts pointer-picked options on mousedown. Opening another popover before mouseup lets that same
64
+ // press light-dismiss it in WebKit, so actions picked with a pointer run after the release.
65
+ const pointerRuns = new WeakMap<EditorView, (() => void)[]>();
66
+ const pointerTiming = ViewPlugin.define((view) => {
67
+ let alive = true;
68
+ const press = (event: MouseEvent) => {
69
+ if (event.button === 0 && event.target instanceof Element && event.target.closest(".cm-tooltip-autocomplete"))
70
+ pointerRuns.set(view, []);
71
+ };
72
+ const release = () => {
73
+ const pending = pointerRuns.get(view);
74
+ pointerRuns.delete(view);
75
+ if (pending?.length)
76
+ queueMicrotask(() => {
77
+ if (alive) for (const run of pending) run();
78
+ });
79
+ };
80
+ view.dom.addEventListener("mousedown", press, true);
81
+ view.dom.ownerDocument.addEventListener("mouseup", release, true);
82
+ return {
83
+ destroy() {
84
+ alive = false;
85
+ pointerRuns.delete(view);
86
+ view.dom.removeEventListener("mousedown", press, true);
87
+ view.dom.ownerDocument.removeEventListener("mouseup", release, true);
88
+ },
89
+ };
90
+ });
91
+
92
+ const completion = (action: SlashAction, parsedParams?: RegExpMatchArray): SlashCompletion => ({
93
+ label: `/${action.name}`,
94
+ displayLabel: action.label,
95
+ detail: action.description,
96
+ slashAction: action,
97
+ parsedParams,
98
+ apply: (view, picked, from, to) => {
99
+ // A block action typed after text starts on a fresh line; the newline replaces the typed `/name`.
100
+ const line = view.state.doc.lineAt(from);
101
+ const midLine = line.text.slice(0, from - line.from).trim().length > 0;
102
+ view.dispatch({
103
+ changes: { from, to, insert: midLine && !action.inline ? "\n" : "" },
104
+ annotations: pickedCompletion.of(picked),
105
+ userEvent: "input.complete",
106
+ });
107
+ // Run after autocomplete updated: an action that opens a dialog inside the same update re-enters CodeMirror.
108
+ const run = () => void action.run(view, parsedParams);
109
+ const pending = pointerRuns.get(view);
110
+ if (pending) pending.push(run);
111
+ else queueMicrotask(run);
112
+ },
113
+ });
114
+
115
+ /** The slash source alone, for hosts that compose their own autocompletion. */
116
+ export const slashCompletionSource = (actions: SlashAction[]) => {
117
+ const haystacks = new Map(actions.map((action) => [action, [action.name, action.label, ...(action.aliases ?? [])].join(" ")]));
118
+ return (context: CompletionContext): CompletionResult | null => {
119
+ const line = context.state.doc.lineAt(context.pos);
120
+ const match = trigger.exec(line.text.slice(0, context.pos - line.from));
121
+ if (!match) return null;
122
+ const typed = match[2] ?? "";
123
+ const from = line.from + match.index + (match[1] ?? "").length;
124
+ const enabled = actions.filter((action) => action.isEnabled?.(context.state) !== false);
125
+ const options: SlashCompletion[] = [];
126
+ if (!typed) for (const action of enabled) options.push(completion(action));
127
+ else {
128
+ // Exact names and parameter matches come first, then fuzzy matches by score.
129
+ const exact = new Set<SlashAction>();
130
+ for (const action of enabled) {
131
+ const found = matchExact(action, typed);
132
+ if (!found) continue;
133
+ options.push(completion(action, found.params));
134
+ exact.add(action);
135
+ }
136
+ for (const hit of fuzzy.filter(
137
+ typed,
138
+ enabled.filter((action) => !exact.has(action)),
139
+ { key: (action) => haystacks.get(action) ?? action.name },
140
+ ))
141
+ if (hit.score >= minimumScore) options.push(completion(hit.item));
142
+ }
143
+ return { from, to: context.pos, filter: false, options };
144
+ };
145
+ };
146
+
147
+ /** Default icons for completions of other sources without one. */
148
+ const typeIcons: Record<string, string> = {
149
+ method: "ti-math-function",
150
+ function: "ti-math-function",
151
+ property: "ti-circle-dot",
152
+ namespace: "ti-category",
153
+ class: "ti-cube",
154
+ keyword: "ti-braces",
155
+ constant: "ti-pennant",
156
+ variable: "ti-variable",
157
+ };
158
+ const iconOf = (option: Completion) =>
159
+ (option as SlashCompletion).slashAction?.icon ??
160
+ (option as Completion & { completionIcon?: string }).completionIcon ??
161
+ (option.type ? typeIcons[option.type] : undefined) ??
162
+ "ti-command";
163
+
164
+ const iconRenderer = {
165
+ position: 20,
166
+ render: (option: Completion): Node => {
167
+ const icon = document.createElement("i");
168
+ icon.className = `ti ${iconOf(option)} cm-slash-icon`;
169
+ icon.setAttribute("aria-hidden", "true");
170
+ return icon;
171
+ },
172
+ };
173
+ const detailRenderer = {
174
+ position: 70,
175
+ render: (option: Completion): Node => {
176
+ const detail = document.createElement("span");
177
+ if (!option.detail) return detail;
178
+ detail.className = "cm-completion-detail";
179
+ detail.textContent = option.detail;
180
+ return detail;
181
+ },
182
+ };
183
+
184
+ /** A source that throws returns nothing instead of ending the whole popup, and the error stays visible in the console. */
185
+ const safe =
186
+ (name: string, source: CompletionSource): CompletionSource =>
187
+ (context) => {
188
+ try {
189
+ const result = source(context);
190
+ if (result instanceof Promise)
191
+ return result.catch((error: unknown) => {
192
+ console.error(`[autocomplete:${name}]`, error);
193
+ return null;
194
+ });
195
+ return result;
196
+ } catch (error) {
197
+ console.error(`[autocomplete:${name}]`, error);
198
+ return null;
199
+ }
200
+ };
201
+
202
+ /**
203
+ * The slash menu as a CodeMirror extension: `/` at the line start or after a space opens the actions, typing filters
204
+ * them by exact name first and then fuzzily, ↑↓ ⏎ ⎋ work as in any completion popup. It owns the editor's one
205
+ * completion popup; pass further sources through `sources`.
206
+ */
207
+ export const slashMenu = (options: SlashMenuOptions): Extension => [
208
+ pointerTiming,
209
+ autocompletion({
210
+ override: [
211
+ safe("slash", slashCompletionSource(options.actions)),
212
+ ...(options.sources ?? []).map((entry) => safe(entry.name, entry.source)),
213
+ ],
214
+ activateOnTyping: true,
215
+ selectOnOpen: true,
216
+ closeOnBlur: true,
217
+ icons: false,
218
+ addToOptions: [iconRenderer, detailRenderer],
219
+ }),
220
+ ];