@elabs-ai/components-editor 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/dist/chunk-LBC5VJBD.js +3748 -0
  4. package/dist/chunk-LBC5VJBD.js.map +1 -0
  5. package/dist/index.css +233 -0
  6. package/dist/index.css.map +1 -0
  7. package/dist/index.d.ts +199 -0
  8. package/dist/index.js +362 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/lib/monaco-environment.d.ts +2 -0
  11. package/dist/lib/monaco-environment.js +44 -0
  12. package/dist/lib/monaco-environment.js.map +1 -0
  13. package/dist/markdown/frontmatter.d.ts +11 -0
  14. package/dist/markdown/frontmatter.js +33 -0
  15. package/dist/markdown/frontmatter.js.map +1 -0
  16. package/dist/markdown/index.css +233 -0
  17. package/dist/markdown/index.css.map +1 -0
  18. package/dist/markdown/index.d.ts +1486 -0
  19. package/dist/markdown/index.js +4882 -0
  20. package/dist/markdown/index.js.map +1 -0
  21. package/dist/markdown/parse.d.ts +25 -0
  22. package/dist/markdown/parse.js +14 -0
  23. package/dist/markdown/parse.js.map +1 -0
  24. package/dist/markdown-editor-DfBZibAn.d.ts +702 -0
  25. package/package.json +101 -0
  26. package/src/ai-objects/ai-objects-integration.test.tsx +128 -0
  27. package/src/ai-objects/ai-objects.stories.tsx +233 -0
  28. package/src/ai-objects/ai-objects.test.tsx +205 -0
  29. package/src/ai-objects/decision-card.tsx +178 -0
  30. package/src/ai-objects/directives.ts +136 -0
  31. package/src/ai-objects/entity.tsx +181 -0
  32. package/src/ai-objects/index.ts +34 -0
  33. package/src/ai-objects/knowledge-card.tsx +143 -0
  34. package/src/calc-block/calc-block.stories.tsx +297 -0
  35. package/src/calc-block/calc-block.test.tsx +164 -0
  36. package/src/calc-block/calc-block.tsx +398 -0
  37. package/src/calc-block/calc-editor-monaco.ts +214 -0
  38. package/src/calc-block/calc-editor-prose.ts +128 -0
  39. package/src/calc-block/calc-editor.css +80 -0
  40. package/src/calc-block/calc-editor.stories.tsx +272 -0
  41. package/src/calc-block/calc-editor.test.ts +228 -0
  42. package/src/calc-block/calc-editor.ts +270 -0
  43. package/src/calc-block/calc-inline.stories.tsx +44 -0
  44. package/src/calc-block/calc-inline.test.tsx +44 -0
  45. package/src/calc-block/calc-inline.tsx +75 -0
  46. package/src/calc-block/index.ts +20 -0
  47. package/src/calc-block/types.ts +164 -0
  48. package/src/code-editor/code-editor.stories.tsx +218 -0
  49. package/src/code-editor/code-editor.test.tsx +134 -0
  50. package/src/code-editor/code-editor.tsx +250 -0
  51. package/src/code-editor/index.ts +6 -0
  52. package/src/code-workspace/code-workspace.stories.tsx +51 -0
  53. package/src/code-workspace/code-workspace.test.tsx +146 -0
  54. package/src/code-workspace/code-workspace.tsx +253 -0
  55. package/src/code-workspace/index.ts +6 -0
  56. package/src/copy-button/copy-button.tsx +51 -0
  57. package/src/copy-button/index.ts +1 -0
  58. package/src/css.d.ts +3 -0
  59. package/src/diff-editor/diff-editor.stories.tsx +44 -0
  60. package/src/diff-editor/diff-editor.test.tsx +68 -0
  61. package/src/diff-editor/diff-editor.tsx +151 -0
  62. package/src/diff-editor/index.ts +1 -0
  63. package/src/editor-context-menu/editor-context-menu.tsx +120 -0
  64. package/src/editor-context-menu/index.ts +1 -0
  65. package/src/editor-toolbar/editor-toolbar.tsx +83 -0
  66. package/src/editor-toolbar/index.ts +1 -0
  67. package/src/index.ts +48 -0
  68. package/src/lib/editor-completions-monaco.test.ts +206 -0
  69. package/src/lib/editor-completions-monaco.ts +127 -0
  70. package/src/lib/editor-completions.test.ts +156 -0
  71. package/src/lib/editor-completions.ts +153 -0
  72. package/src/lib/editor-content-access-prose.test.ts +411 -0
  73. package/src/lib/editor-content-access-prose.ts +172 -0
  74. package/src/lib/editor-content-access.test.ts +182 -0
  75. package/src/lib/editor-content-access.ts +109 -0
  76. package/src/lib/languages.ts +32 -0
  77. package/src/lib/markdown/diff.test.ts +80 -0
  78. package/src/lib/markdown/diff.ts +194 -0
  79. package/src/lib/markdown/directives.ts +342 -0
  80. package/src/lib/markdown/frontmatter.ts +50 -0
  81. package/src/lib/markdown/markdown-scale.test.ts +65 -0
  82. package/src/lib/markdown/markdown-scale.ts +62 -0
  83. package/src/lib/markdown/merge.test.ts +64 -0
  84. package/src/lib/markdown/merge.ts +158 -0
  85. package/src/lib/markdown/slugify.test.ts +156 -0
  86. package/src/lib/markdown/slugify.ts +47 -0
  87. package/src/lib/monaco-environment.ts +103 -0
  88. package/src/lib/monaco-theme-bridge.ts +293 -0
  89. package/src/lib/use-data-theme.ts +61 -0
  90. package/src/markdown/frontmatter.ts +14 -0
  91. package/src/markdown/index.ts +323 -0
  92. package/src/markdown/parse.test.ts +60 -0
  93. package/src/markdown/parse.ts +38 -0
  94. package/src/markdown-academic/citations.test.ts +77 -0
  95. package/src/markdown-academic/citations.tsx +442 -0
  96. package/src/markdown-academic/footnotes.tsx +235 -0
  97. package/src/markdown-academic/index.ts +32 -0
  98. package/src/markdown-academic/math.tsx +163 -0
  99. package/src/markdown-academic/toc.tsx +88 -0
  100. package/src/markdown-editor/completions/completions-menu.tsx +96 -0
  101. package/src/markdown-editor/completions/completions-prose.test.ts +356 -0
  102. package/src/markdown-editor/completions/completions-prose.ts +313 -0
  103. package/src/markdown-editor/completions/completions-widget.tsx +75 -0
  104. package/src/markdown-editor/completions/index.ts +37 -0
  105. package/src/markdown-editor/directive-nodes.ts +151 -0
  106. package/src/markdown-editor/directive-views.test.tsx +189 -0
  107. package/src/markdown-editor/directive-views.tsx +795 -0
  108. package/src/markdown-editor/exit-keymap.test.ts +88 -0
  109. package/src/markdown-editor/exit-keymap.ts +87 -0
  110. package/src/markdown-editor/index.ts +6 -0
  111. package/src/markdown-editor/markdown-editor.css +250 -0
  112. package/src/markdown-editor/markdown-editor.directives.test.tsx +105 -0
  113. package/src/markdown-editor/markdown-editor.fill.test.ts +34 -0
  114. package/src/markdown-editor/markdown-editor.paste-embed.test.tsx +297 -0
  115. package/src/markdown-editor/markdown-editor.stories.tsx +560 -0
  116. package/src/markdown-editor/markdown-editor.strictmode.test.tsx +38 -0
  117. package/src/markdown-editor/markdown-editor.table.test.tsx +287 -0
  118. package/src/markdown-editor/markdown-editor.tsx +560 -0
  119. package/src/markdown-editor/milkdown-react/editor.tsx +33 -0
  120. package/src/markdown-editor/milkdown-react/index.ts +8 -0
  121. package/src/markdown-editor/milkdown-react/types.ts +27 -0
  122. package/src/markdown-editor/milkdown-react/use-editor.ts +27 -0
  123. package/src/markdown-editor/milkdown-react/use-get-editor.ts +56 -0
  124. package/src/markdown-editor/milkdown-react/use-instance.ts +23 -0
  125. package/src/markdown-editor/paste-embed.ts +355 -0
  126. package/src/markdown-editor/slash/brand-slash-commands.test.ts +194 -0
  127. package/src/markdown-editor/slash/brand-slash-commands.ts +324 -0
  128. package/src/markdown-editor/slash/brand-slash-plugin.test.ts +254 -0
  129. package/src/markdown-editor/slash/brand-slash-plugin.ts +385 -0
  130. package/src/markdown-editor/slash/index.ts +107 -0
  131. package/src/markdown-editor/slash/insert-directive.test.ts +263 -0
  132. package/src/markdown-editor/slash/insert-directive.ts +345 -0
  133. package/src/markdown-editor/slash/monaco-slash-menu.test.tsx +198 -0
  134. package/src/markdown-editor/slash/monaco-slash-menu.tsx +356 -0
  135. package/src/markdown-editor/slash/shortcut-monaco.ts +69 -0
  136. package/src/markdown-editor/slash/shortcut.test.ts +157 -0
  137. package/src/markdown-editor/slash/shortcut.ts +57 -0
  138. package/src/markdown-editor/slash/slash-menu.stories.tsx +139 -0
  139. package/src/markdown-editor/slash/slash-menu.tsx +115 -0
  140. package/src/markdown-editor/slash/slash-scroll.test.tsx +52 -0
  141. package/src/markdown-editor/slash/slash-widget.tsx +97 -0
  142. package/src/markdown-editor/slash/source-slash-trigger.test.ts +42 -0
  143. package/src/markdown-editor/slash/source-slash-trigger.ts +46 -0
  144. package/src/markdown-editor/table-view.tsx +249 -0
  145. package/src/markdown-iteration/directive.tsx +92 -0
  146. package/src/markdown-iteration/edit-context.ts +56 -0
  147. package/src/markdown-iteration/index.ts +24 -0
  148. package/src/markdown-iteration/iteration-block.test.tsx +138 -0
  149. package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +293 -0
  150. package/src/markdown-iteration/iteration-builder-dialog.tsx +312 -0
  151. package/src/markdown-iteration/iteration-builder.test.ts +252 -0
  152. package/src/markdown-iteration/iteration-builder.ts +303 -0
  153. package/src/markdown-iteration/iteration.test.ts +56 -0
  154. package/src/markdown-iteration/iteration.tsx +333 -0
  155. package/src/markdown-iteration/template-dialog.stories.tsx +63 -0
  156. package/src/markdown-iteration/template-dialog.test.tsx +104 -0
  157. package/src/markdown-iteration/template-dialog.tsx +116 -0
  158. package/src/markdown-outline/document-outline.stories.tsx +76 -0
  159. package/src/markdown-outline/document-outline.tsx +83 -0
  160. package/src/markdown-outline/index.ts +2 -0
  161. package/src/markdown-outline/markdown-outline.test.ts +49 -0
  162. package/src/markdown-outline/markdown-outline.ts +60 -0
  163. package/src/markdown-preview/code-fence.tsx +193 -0
  164. package/src/markdown-preview/index.ts +8 -0
  165. package/src/markdown-preview/markdown-preview-academic.stories.tsx +203 -0
  166. package/src/markdown-preview/markdown-preview-academic.test.tsx +257 -0
  167. package/src/markdown-preview/markdown-preview-iteration-calc.test.tsx +71 -0
  168. package/src/markdown-preview/markdown-preview-iteration-nested.test.tsx +58 -0
  169. package/src/markdown-preview/markdown-preview-iteration.stories.tsx +187 -0
  170. package/src/markdown-preview/markdown-preview-iteration.test.tsx +90 -0
  171. package/src/markdown-preview/markdown-preview-linking.test.tsx +61 -0
  172. package/src/markdown-preview/markdown-preview-transclusion.test.tsx +33 -0
  173. package/src/markdown-preview/markdown-preview.stories.tsx +108 -0
  174. package/src/markdown-preview/markdown-preview.test.tsx +411 -0
  175. package/src/markdown-preview/markdown-preview.tsx +1541 -0
  176. package/src/markdown-toolbar/index.ts +8 -0
  177. package/src/markdown-toolbar/markdown-commands.test.ts +84 -0
  178. package/src/markdown-toolbar/markdown-commands.ts +104 -0
  179. package/src/markdown-toolbar/markdown-toolbar.stories.tsx +56 -0
  180. package/src/markdown-toolbar/markdown-toolbar.tsx +263 -0
  181. package/src/markdown-workspace/focus-writing.test.ts +52 -0
  182. package/src/markdown-workspace/focus-writing.ts +38 -0
  183. package/src/markdown-workspace/index.ts +6 -0
  184. package/src/markdown-workspace/markdown-workspace.stories.tsx +688 -0
  185. package/src/markdown-workspace/markdown-workspace.test.tsx +326 -0
  186. package/src/markdown-workspace/markdown-workspace.tsx +787 -0
  187. package/src/mermaid-diagram/index.ts +1 -0
  188. package/src/mermaid-diagram/mermaid-diagram.stories.tsx +42 -0
  189. package/src/mermaid-diagram/mermaid-diagram.test.tsx +86 -0
  190. package/src/mermaid-diagram/mermaid-diagram.tsx +318 -0
  191. package/src/mermaid-diagram/mermaid-viewer.test.tsx +89 -0
  192. package/src/mermaid-diagram/mermaid-viewer.tsx +367 -0
  193. package/src/mermaid-diagram/real-parse.test.ts +42 -0
  194. package/src/mermaid-diagram/remediate.test.ts +60 -0
  195. package/src/mermaid-diagram/remediate.ts +124 -0
  196. package/src/mermaid-workspace/index.ts +1 -0
  197. package/src/mermaid-workspace/mermaid-workspace.stories.tsx +28 -0
  198. package/src/mermaid-workspace/mermaid-workspace.tsx +71 -0
  199. package/src/metric-block/index.ts +1 -0
  200. package/src/metric-block/metric-block.stories.tsx +40 -0
  201. package/src/metric-block/metric-block.tsx +14 -0
  202. package/src/monaco-workers.d.ts +10 -0
  203. package/src/prose/index.ts +14 -0
  204. package/src/prose/prose.stories.tsx +44 -0
  205. package/src/prose/prose.test.tsx +44 -0
  206. package/src/prose/prose.tsx +21 -0
  207. package/src/timeline/index.ts +18 -0
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Unit tests for `MonacoSlashMenu`'s run-only command selection (#299) —
3
+ * `runInSource` appears alongside `snippet` in the source-pane menu, wins over
4
+ * `snippet` when both select paths fire (Enter/Tab AND click), strips the
5
+ * typed `/query` trigger first, and a snippet-only command is unaffected.
6
+ *
7
+ * Monaco can't render in jsdom, so `monaco-editor` is mocked (the
8
+ * `markdown-workspace.test.tsx` precedent) and a hand-built editor object
9
+ * stands in for `MonacoCodeEditor` (the `editor-content-access.test.ts`
10
+ * precedent) — `MonacoSlashMenu` only calls a handful of methods on it.
11
+ */
12
+ import { fireEvent, render, screen } from "@testing-library/react";
13
+ import type { IRange } from "monaco-editor";
14
+ import { afterEach, describe, expect, it, vi } from "vitest";
15
+
16
+ vi.mock("monaco-editor", () => ({
17
+ Selection: class {},
18
+ Range: class {},
19
+ }));
20
+
21
+ import { MonacoSlashMenu } from "./monaco-slash-menu";
22
+ import type { SlashCommand } from "./brand-slash-commands";
23
+
24
+ function makeEditor() {
25
+ const domNode = document.createElement("div");
26
+ return {
27
+ getPosition: vi.fn(() => ({ lineNumber: 1, column: 5 })),
28
+ getScrolledVisiblePosition: vi.fn(() => ({ top: 20, left: 10, height: 20 })),
29
+ getDomNode: vi.fn(() => domNode),
30
+ onDidScrollChange: vi.fn(() => ({ dispose: vi.fn() })),
31
+ onDidChangeCursorPosition: vi.fn(() => ({ dispose: vi.fn() })),
32
+ onDidBlurEditorText: vi.fn(() => ({ dispose: vi.fn() })),
33
+ executeEdits: vi.fn(),
34
+ getModel: vi.fn(() => ({ getValue: vi.fn(() => ""), getValueInRange: vi.fn(() => "") })),
35
+ getSelection: vi.fn(() => null),
36
+ focus: vi.fn(),
37
+ domNode,
38
+ };
39
+ }
40
+
41
+ const triggerRange: IRange = {
42
+ startLineNumber: 1,
43
+ startColumn: 1,
44
+ endLineNumber: 1,
45
+ endColumn: 2,
46
+ };
47
+
48
+ afterEach(() => {
49
+ vi.clearAllMocks();
50
+ });
51
+
52
+ describe("MonacoSlashMenu — run-only commands (#299)", () => {
53
+ it("renders a run-only command (no snippet) alongside snippet commands", () => {
54
+ const editor = makeEditor();
55
+ const runOnly: SlashCommand = {
56
+ id: "ask-ai",
57
+ label: "Ask AI",
58
+ run: () => {},
59
+ runInSource: vi.fn(),
60
+ };
61
+ render(
62
+ <MonacoSlashMenu
63
+ editor={editor as never}
64
+ commands={[runOnly]}
65
+ open
66
+ onOpenChange={vi.fn()}
67
+ triggerRange={triggerRange}
68
+ />,
69
+ );
70
+ expect(screen.getByText("Ask AI")).toBeInTheDocument();
71
+ });
72
+
73
+ it("Enter strips the typed trigger, calls runInSource with editor+range+content, closes, and refocuses", () => {
74
+ const editor = makeEditor();
75
+ const runInSource = vi.fn();
76
+ const runOnly: SlashCommand = { id: "ask-ai", label: "Ask AI", run: () => {}, runInSource };
77
+ const onOpenChange = vi.fn();
78
+ render(
79
+ <MonacoSlashMenu
80
+ editor={editor as never}
81
+ commands={[runOnly]}
82
+ open
83
+ onOpenChange={onOpenChange}
84
+ triggerRange={triggerRange}
85
+ />,
86
+ );
87
+
88
+ fireEvent.keyDown(editor.domNode, { key: "Enter" });
89
+
90
+ // The `/query` trigger is stripped BEFORE runInSource fires.
91
+ expect(editor.executeEdits).toHaveBeenCalledWith("brand-slash-typed", [
92
+ { range: triggerRange, text: "" },
93
+ ]);
94
+ expect(runInSource).toHaveBeenCalledTimes(1);
95
+ const ctx = runInSource.mock.calls[0]?.[0];
96
+ expect(ctx?.editor).toBe(editor);
97
+ expect(ctx?.range).toBe(triggerRange);
98
+ expect(typeof ctx?.content.getText).toBe("function");
99
+ expect(onOpenChange).toHaveBeenCalledWith(false);
100
+ expect(editor.focus).toHaveBeenCalledTimes(1);
101
+ });
102
+
103
+ it("clicking the option (the mouse path) calls runInSource identically to Enter", () => {
104
+ const editor = makeEditor();
105
+ const runInSource = vi.fn();
106
+ const runOnly: SlashCommand = { id: "ask-ai", label: "Ask AI", run: () => {}, runInSource };
107
+ render(
108
+ <MonacoSlashMenu
109
+ editor={editor as never}
110
+ commands={[runOnly]}
111
+ open
112
+ onOpenChange={vi.fn()}
113
+ triggerRange={triggerRange}
114
+ />,
115
+ );
116
+
117
+ fireEvent.mouseDown(screen.getByText("Ask AI"));
118
+
119
+ expect(editor.executeEdits).toHaveBeenCalledWith("brand-slash-typed", [
120
+ { range: triggerRange, text: "" },
121
+ ]);
122
+ expect(runInSource).toHaveBeenCalledTimes(1);
123
+ });
124
+
125
+ it("on the hotkey path (no triggerRange), runInSource still fires with range: null and nothing is stripped", () => {
126
+ const editor = makeEditor();
127
+ const runInSource = vi.fn();
128
+ const runOnly: SlashCommand = { id: "ask-ai", label: "Ask AI", run: () => {}, runInSource };
129
+ render(
130
+ <MonacoSlashMenu
131
+ editor={editor as never}
132
+ commands={[runOnly]}
133
+ open
134
+ onOpenChange={vi.fn()}
135
+ triggerRange={null}
136
+ />,
137
+ );
138
+
139
+ fireEvent.keyDown(editor.domNode, { key: "Enter" });
140
+
141
+ expect(editor.executeEdits).not.toHaveBeenCalled();
142
+ expect(runInSource).toHaveBeenCalledTimes(1);
143
+ expect(runInSource.mock.calls[0]?.[0]?.range).toBeNull();
144
+ });
145
+
146
+ it("a snippet-only command is unaffected — Enter still inserts the snippet via executeEdits", () => {
147
+ const editor = makeEditor();
148
+ const snippetOnly: SlashCommand = {
149
+ id: "brand-card",
150
+ label: "Card",
151
+ snippet: ":::card\ntext\n:::",
152
+ run: () => {},
153
+ };
154
+ render(
155
+ <MonacoSlashMenu
156
+ editor={editor as never}
157
+ commands={[snippetOnly]}
158
+ open
159
+ onOpenChange={vi.fn()}
160
+ triggerRange={triggerRange}
161
+ />,
162
+ );
163
+
164
+ fireEvent.keyDown(editor.domNode, { key: "Enter" });
165
+
166
+ expect(editor.executeEdits).toHaveBeenCalledWith("brand-slash-typed", [
167
+ { range: triggerRange, text: ":::card\ntext\n:::", forceMoveMarkers: true },
168
+ ]);
169
+ });
170
+
171
+ it("runInSource wins over snippet when a command carries both", () => {
172
+ const editor = makeEditor();
173
+ const runInSource = vi.fn();
174
+ const both: SlashCommand = {
175
+ id: "both",
176
+ label: "Both",
177
+ snippet: "should not be inserted",
178
+ run: () => {},
179
+ runInSource,
180
+ };
181
+ render(
182
+ <MonacoSlashMenu
183
+ editor={editor as never}
184
+ commands={[both]}
185
+ open
186
+ onOpenChange={vi.fn()}
187
+ triggerRange={triggerRange}
188
+ />,
189
+ );
190
+
191
+ fireEvent.keyDown(editor.domNode, { key: "Enter" });
192
+
193
+ expect(runInSource).toHaveBeenCalledTimes(1);
194
+ expect(editor.executeEdits).toHaveBeenCalledWith("brand-slash-typed", [
195
+ { range: triggerRange, text: "" },
196
+ ]);
197
+ });
198
+ });
@@ -0,0 +1,356 @@
1
+ "use client";
2
+
3
+ /**
4
+ * MonacoSlashMenu — a caret-anchored slash command popup for the Monaco source
5
+ * pane (#271).
6
+ *
7
+ * The workspace owns the open/closed state (via a `CodeEditor` action keybinding
8
+ * registered through `CodeEditor.actions`). This component is the POSITIONING
9
+ * CONTROLLER + KEYBOARD HANDLER that wraps the shared `SlashMenu` body — it does
10
+ * NOT re-implement the listbox (no duplication, tokens only, same look as the
11
+ * WYSIWYG widget).
12
+ *
13
+ * a11y: the `SlashMenu` already provides `role="listbox"` / `role="option"` /
14
+ * `aria-selected`; this wrapper wires `aria-activedescendant` on the editor's
15
+ * textarea so AT can follow the active row. Esc closes and refocuses the editor.
16
+ */
17
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
18
+ import type { IRange } from "monaco-editor";
19
+ import { useEffect, useRef, useState } from "react";
20
+
21
+ import type { MonacoCodeEditor } from "../../code-editor";
22
+ import { monacoContentAccess } from "../../lib/editor-content-access";
23
+ import { insertDirective } from "../../markdown-toolbar/markdown-commands";
24
+ import { filterSlashCommands, type SlashCommand } from "./brand-slash-commands";
25
+ import { SlashMenu, slashOptionId } from "./slash-menu";
26
+
27
+ export interface MonacoSlashMenuProps {
28
+ /** The live Monaco editor instance (source pane). */
29
+ editor: MonacoCodeEditor;
30
+ /**
31
+ * Commands to show in the source pane — filtered by the workspace to
32
+ * `snippet != null || typeof runInSource === "function"` (#299): a command
33
+ * needs a text snippet, a source-pane handler, or both to appear here (a
34
+ * run-only WYSIWYG command with neither is Milkdown-only and stays excluded).
35
+ */
36
+ commands: SlashCommand[];
37
+ /** Controlled open state — the workspace toggles it via the Layer-1 action. */
38
+ open: boolean;
39
+ onOpenChange: (open: boolean) => void;
40
+ /**
41
+ * Insert the selected snippet at the Monaco caret + refocus.
42
+ * Defaults to `insertDirective` from `markdown-commands.ts`.
43
+ */
44
+ onInsert?: (editor: MonacoCodeEditor, snippet: string) => void;
45
+ /**
46
+ * When the menu was opened by typing `/` (not the hotkey), the model range of
47
+ * that `/`. On select the `/` is REPLACED by the inserted block; on cancel
48
+ * (Escape / Backspace past the trigger) it is removed. `null`/omitted = the
49
+ * hotkey path, which inserts via `onInsert`/`insertDirective` and leaves the
50
+ * document otherwise untouched.
51
+ */
52
+ triggerRange?: IRange | null;
53
+ /** Merged onto the positioned popup container (the inline `position:fixed` anchor stays). */
54
+ className?: string;
55
+ }
56
+
57
+ const ID_PREFIX = "brand-monaco-slash";
58
+ const LISTBOX_ID = `${ID_PREFIX}-listbox`;
59
+
60
+ /** Fixed/absolute coords of the popup anchor. */
61
+ interface Coords {
62
+ top: number;
63
+ left: number;
64
+ }
65
+
66
+ function getCaretCoords(editor: MonacoCodeEditor): Coords | null {
67
+ const pos = editor.getPosition();
68
+ if (!pos) return null;
69
+ const scrolled = editor.getScrolledVisiblePosition(pos);
70
+ if (!scrolled) return null;
71
+ const domNode = editor.getDomNode();
72
+ if (!domNode) return null;
73
+ const rect = domNode.getBoundingClientRect();
74
+ return {
75
+ top: rect.top + scrolled.top + (scrolled.height ?? 20),
76
+ left: rect.left + scrolled.left,
77
+ };
78
+ }
79
+
80
+ export function MonacoSlashMenu({
81
+ editor,
82
+ commands,
83
+ open,
84
+ onOpenChange,
85
+ onInsert,
86
+ triggerRange,
87
+ className,
88
+ }: MonacoSlashMenuProps) {
89
+ const [query, setQuery] = useState("");
90
+ const [activeIndex, setActiveIndex] = useState(0);
91
+ const [coords, setCoords] = useState<Coords | null>(null);
92
+ const menuRef = useRef<HTMLDivElement>(null);
93
+
94
+ // Insert the chosen snippet. Typed-`/` mode REPLACES the `/` (triggerRange) with
95
+ // the block; hotkey mode inserts via insertDirective at the caret/line. Always
96
+ // refocus the editor and close.
97
+ const commitSnippet = (snippet: string) => {
98
+ if (triggerRange) {
99
+ editor.executeEdits("brand-slash-typed", [
100
+ { range: triggerRange, text: snippet, forceMoveMarkers: true },
101
+ ]);
102
+ } else {
103
+ (onInsert ?? insertDirective)(editor, snippet);
104
+ }
105
+ onOpenChange(false);
106
+ editor.focus();
107
+ };
108
+
109
+ // Run a source-pane handler (#299): strip the typed `/query` trigger FIRST
110
+ // (the same edit `cancel` uses, so the doc is clean whether the handler
111
+ // opens a dialog, schedules async work, or inserts nothing), then call it
112
+ // with the live editor + the (now-stale) trigger range + content access.
113
+ const commitRunInSource = (command: SlashCommand) => {
114
+ if (triggerRange) {
115
+ editor.executeEdits("brand-slash-typed", [{ range: triggerRange, text: "" }]);
116
+ }
117
+ command.runInSource?.({
118
+ editor,
119
+ range: triggerRange ?? null,
120
+ content: monacoContentAccess(editor),
121
+ });
122
+ onOpenChange(false);
123
+ editor.focus();
124
+ };
125
+
126
+ // Select a command: `runInSource` (when present) wins over `snippet` — it's
127
+ // the more capable handler and is the ONLY option for a run-only command.
128
+ const selectCommand = (command: SlashCommand) => {
129
+ if (typeof command.runInSource === "function") {
130
+ commitRunInSource(command);
131
+ } else if (command.snippet) {
132
+ commitSnippet(command.snippet);
133
+ }
134
+ };
135
+
136
+ // Dismiss without inserting. In typed-`/` mode the stray `/` is removed so the
137
+ // document is left exactly as it was before the trigger.
138
+ const cancel = () => {
139
+ if (triggerRange) {
140
+ editor.executeEdits("brand-slash-cancel", [{ range: triggerRange, text: "" }]);
141
+ }
142
+ onOpenChange(false);
143
+ editor.focus();
144
+ };
145
+
146
+ // Latest select/cancel via refs so the capture-phase keydown listener can stay
147
+ // attached across renders (its effect deps don't need these closures).
148
+ const selectRef = useRef(selectCommand);
149
+ selectRef.current = selectCommand;
150
+ const cancelRef = useRef(cancel);
151
+ cancelRef.current = cancel;
152
+
153
+ // Reset query + active index whenever the menu opens.
154
+ useEffect(() => {
155
+ if (open) {
156
+ setQuery("");
157
+ setActiveIndex(0);
158
+ }
159
+ }, [open]);
160
+
161
+ // Compute and track caret position.
162
+ useEffect(() => {
163
+ if (!open) return;
164
+
165
+ const update = () => {
166
+ setCoords(getCaretCoords(editor));
167
+ };
168
+
169
+ update();
170
+
171
+ const scrollSub = editor.onDidScrollChange(update);
172
+ const cursorSub = editor.onDidChangeCursorPosition(update);
173
+
174
+ const onResize = () => update();
175
+ window.addEventListener("resize", onResize);
176
+
177
+ return () => {
178
+ scrollSub.dispose();
179
+ cursorSub.dispose();
180
+ window.removeEventListener("resize", onResize);
181
+ };
182
+ }, [open, editor]);
183
+
184
+ // Close on editor blur.
185
+ useEffect(() => {
186
+ if (!open) return;
187
+ const blurSub = editor.onDidBlurEditorText(() => {
188
+ // Small delay — if focus moved to the menu itself (mousedown), don't close.
189
+ setTimeout(() => {
190
+ if (!menuRef.current?.contains(document.activeElement)) {
191
+ onOpenChange(false);
192
+ }
193
+ }, 100);
194
+ });
195
+ return () => blurSub.dispose();
196
+ }, [open, editor, onOpenChange]);
197
+
198
+ // Wire keyboard navigation into the editor while the menu is open.
199
+ useEffect(() => {
200
+ if (!open) return;
201
+
202
+ const keydown = (e: KeyboardEvent) => {
203
+ const filtered = filterSlashCommands(commands, query);
204
+
205
+ if (e.key === "Escape") {
206
+ e.preventDefault();
207
+ e.stopPropagation();
208
+ cancelRef.current();
209
+ return;
210
+ }
211
+ if (e.key === "ArrowDown") {
212
+ e.preventDefault();
213
+ e.stopPropagation();
214
+ setActiveIndex((i) => (i + 1) % Math.max(filtered.length, 1));
215
+ return;
216
+ }
217
+ if (e.key === "ArrowUp") {
218
+ e.preventDefault();
219
+ e.stopPropagation();
220
+ setActiveIndex(
221
+ (i) => (i - 1 + Math.max(filtered.length, 1)) % Math.max(filtered.length, 1),
222
+ );
223
+ return;
224
+ }
225
+ // Enter AND Tab select the active command (the cmdk / Notion command-menu
226
+ // convention) — kept identical to the WYSIWYG slash menu so the cross-pane
227
+ // shortcut behaves the same in both panes (the goal of #271). Tab does NOT
228
+ // move browser focus here by design; Esc dismisses without inserting.
229
+ if (e.key === "Enter" || e.key === "Tab") {
230
+ // Always swallow while the popup is open — even with no match — so the key
231
+ // never leaks a newline/tab into the document behind the popup.
232
+ e.preventDefault();
233
+ e.stopPropagation();
234
+ const command = filtered[Math.min(activeIndex, filtered.length - 1)];
235
+ if (command) selectRef.current(command);
236
+ return;
237
+ }
238
+ // Printable characters update the query — intercept so they filter the
239
+ // menu instead of being typed into the Monaco document (the menu was
240
+ // opened by shortcut, so there is no `/query` run in the doc to absorb them).
241
+ if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
242
+ e.preventDefault();
243
+ e.stopPropagation();
244
+ setQuery((q) => q + e.key);
245
+ setActiveIndex(0);
246
+ return;
247
+ }
248
+ // Backspace trims the query (and closes when empty) — intercept so it
249
+ // never deletes document text behind the popup.
250
+ if (e.key === "Backspace") {
251
+ e.preventDefault();
252
+ e.stopPropagation();
253
+ if (query.length === 0) {
254
+ // Backspacing past the trigger dismisses (and removes the typed `/`).
255
+ cancelRef.current();
256
+ return;
257
+ }
258
+ setQuery((q) => q.slice(0, -1));
259
+ setActiveIndex(0);
260
+ }
261
+ };
262
+
263
+ // Capture phase — intercept before Monaco's own key handlers.
264
+ const domNode = editor.getDomNode();
265
+ domNode?.addEventListener("keydown", keydown, true);
266
+ return () => domNode?.removeEventListener("keydown", keydown, true);
267
+ }, [open, editor, commands, query, activeIndex]);
268
+
269
+ // Keep the highlighted option scrolled into view as ↑/↓ moves it — the listbox
270
+ // is overflow-y-auto, so without this the active row can move off-screen while
271
+ // the keyboard selection advances (the same fix slash-widget.tsx applies).
272
+ useEffect(() => {
273
+ if (!open) return;
274
+ const filtered = filterSlashCommands(commands, query);
275
+ const active = filtered[Math.min(activeIndex, Math.max(filtered.length - 1, 0))];
276
+ if (!active) return;
277
+ const el = menuRef.current?.querySelector<HTMLElement>(
278
+ `#${CSS.escape(slashOptionId(ID_PREFIX, active.id))}`,
279
+ );
280
+ el?.scrollIntoView({ block: "nearest" });
281
+ }, [open, commands, query, activeIndex]);
282
+
283
+ // Mirror the listbox relationship onto the Monaco textbox while open, so AT is
284
+ // told a popup appeared (aria-expanded), where it is (aria-controls), and which
285
+ // option is active (aria-activedescendant) — the same wiring the WYSIWYG path
286
+ // applies in slash-widget.tsx. All three are cleaned up on close.
287
+ useEffect(() => {
288
+ if (!open) return;
289
+ const filtered = filterSlashCommands(commands, query);
290
+ const activeCommand = filtered[Math.min(activeIndex, Math.max(filtered.length - 1, 0))];
291
+ const textarea = editor.getDomNode()?.querySelector("textarea");
292
+ if (!textarea) return;
293
+ textarea.setAttribute("aria-expanded", "true");
294
+ textarea.setAttribute("aria-controls", LISTBOX_ID);
295
+ if (activeCommand) {
296
+ textarea.setAttribute("aria-activedescendant", slashOptionId(ID_PREFIX, activeCommand.id));
297
+ } else {
298
+ textarea.removeAttribute("aria-activedescendant");
299
+ }
300
+ return () => {
301
+ textarea.removeAttribute("aria-expanded");
302
+ textarea.removeAttribute("aria-controls");
303
+ textarea.removeAttribute("aria-activedescendant");
304
+ };
305
+ }, [open, editor, commands, query, activeIndex]);
306
+
307
+ if (!open || !coords) return null;
308
+
309
+ const filtered = filterSlashCommands(commands, query);
310
+ const activeCommand = filtered[Math.min(activeIndex, Math.max(filtered.length - 1, 0))];
311
+
312
+ const handleSelect = (command: SlashCommand) => {
313
+ selectCommand(command);
314
+ };
315
+
316
+ const resultCount = filtered.length;
317
+ const statusText =
318
+ resultCount === 0
319
+ ? "No matching blocks"
320
+ : query
321
+ ? `${resultCount} result${resultCount === 1 ? "" : "s"} for “${query}”`
322
+ : `${resultCount} block${resultCount === 1 ? "" : "s"}`;
323
+
324
+ return (
325
+ <div
326
+ ref={menuRef}
327
+ className={cn(className)}
328
+ // Fixed positioning so it overlays the editor regardless of scroll.
329
+ style={{ position: "fixed", top: coords.top, left: coords.left, zIndex: 50 }}
330
+ // Prevent the mousedown from stealing focus from the editor.
331
+ onMouseDown={(e) => e.preventDefault()}
332
+ >
333
+ {/* One polite live region — focus stays in the editor, so AT learns the
334
+ filter result count (and the empty state) only from here. The visual
335
+ chip below is aria-hidden to avoid a double announcement. */}
336
+ <span role="status" aria-live="polite" className="sr-only">
337
+ {statusText}
338
+ </span>
339
+ {query && (
340
+ <div
341
+ aria-hidden="true"
342
+ className="mb-0.5 rounded-sm border border-border bg-popover px-2 py-1 text-caption text-muted-foreground"
343
+ >
344
+ Filter: <span className="font-medium text-foreground">{query}</span>
345
+ </div>
346
+ )}
347
+ <SlashMenu
348
+ id={LISTBOX_ID}
349
+ commands={filtered}
350
+ activeId={activeCommand?.id}
351
+ onSelect={handleSelect}
352
+ idPrefix={ID_PREFIX}
353
+ />
354
+ </div>
355
+ );
356
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Shortcut helpers for the cross-pane slash menu (#271) — the MONACO half.
3
+ *
4
+ * Converts a shortcut string (e.g. `"Mod-/"`) into a Monaco keybinding bitmask.
5
+ * This is the ONLY slash module that imports `monaco-editor`; it is imported only
6
+ * by the Monaco-side surfaces (the source pane action in `MarkdownWorkspace`), so
7
+ * the pure `./shortcut.ts` — imported by the Milkdown plugin — stays Monaco-free.
8
+ *
9
+ * INTERNAL — not exported from package barrels.
10
+ */
11
+ import * as monaco from "monaco-editor";
12
+
13
+ /** Supported letter key names (A-Z, case-insensitive in shortcut strings). */
14
+ const LETTER_KEY_MAP: Record<string, number> = (() => {
15
+ const map: Record<string, number> = {};
16
+ const kc = monaco.KeyCode as unknown as Record<string, number>;
17
+ for (let i = 0; i < 26; i++) {
18
+ const letter = String.fromCharCode(65 + i); // "A".."Z"
19
+ const code = kc[`Key${letter}`];
20
+ if (code !== undefined) map[letter.toLowerCase()] = code;
21
+ }
22
+ return map;
23
+ })();
24
+
25
+ const NAMED_KEY_MAP: Record<string, number> = {
26
+ "/": monaco.KeyCode.Slash,
27
+ backspace: monaco.KeyCode.Backspace,
28
+ delete: monaco.KeyCode.Delete,
29
+ escape: monaco.KeyCode.Escape,
30
+ enter: monaco.KeyCode.Enter,
31
+ tab: monaco.KeyCode.Tab,
32
+ arrowup: monaco.KeyCode.UpArrow,
33
+ arrowdown: monaco.KeyCode.DownArrow,
34
+ arrowleft: monaco.KeyCode.LeftArrow,
35
+ arrowright: monaco.KeyCode.RightArrow,
36
+ };
37
+
38
+ /**
39
+ * Parse a shortcut string (e.g. `"Mod-/"`, `"Mod-Shift-K"`) into a Monaco
40
+ * keybinding bitmask. Supports `Mod` (CtrlCmd), `Shift`, `Alt` modifiers and
41
+ * a final key that is either a letter (A-Z) or one of the named keys in
42
+ * `NAMED_KEY_MAP`. Throws for unrecognized final keys.
43
+ *
44
+ * The shipped default is `"Mod-/"` → `KeyMod.CtrlCmd | KeyCode.Slash`.
45
+ */
46
+ export function parseShortcut(shortcut: string): number {
47
+ const parts = shortcut.split("-");
48
+ let binding = 0;
49
+ const keyPart = parts[parts.length - 1] ?? "";
50
+ const modifiers = parts.slice(0, -1).map((m) => m.toLowerCase());
51
+
52
+ for (const mod of modifiers) {
53
+ if (mod === "mod") binding |= monaco.KeyMod.CtrlCmd;
54
+ else if (mod === "shift") binding |= monaco.KeyMod.Shift;
55
+ else if (mod === "alt") binding |= monaco.KeyMod.Alt;
56
+ // Ctrl/Meta can also be named explicitly
57
+ else if (mod === "ctrl") binding |= monaco.KeyMod.WinCtrl;
58
+ }
59
+
60
+ const key = keyPart.toLowerCase();
61
+ const keyCode =
62
+ NAMED_KEY_MAP[key] ??
63
+ LETTER_KEY_MAP[key] ??
64
+ (() => {
65
+ throw new Error(`[@elabs-ai/components-editor] parseShortcut: unrecognized key "${keyPart}"`);
66
+ })();
67
+
68
+ return binding | keyCode;
69
+ }