@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,326 @@
1
+ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
2
+ import { createRef } from "react";
3
+ import { afterEach, expect, test, vi } from "vitest";
4
+
5
+ import type { SlashCommand } from "../markdown-editor/slash";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Monaco mock — extended with revealLine / revealLineInCenter / getLineCount
9
+ // for the #273 MarkdownWorkspaceHandle tests, and addAction / onDidBlurEditorText
10
+ // / getPosition / getScrolledVisiblePosition for the #271 source slash tests.
11
+ // ---------------------------------------------------------------------------
12
+ const revealLine = vi.fn();
13
+ const revealLineInCenter = vi.fn();
14
+ // Default model has 20 lines so range checks pass.
15
+ const getLineCount = vi.fn(() => 20);
16
+ const addAction = vi.fn(() => ({ dispose: vi.fn() }));
17
+
18
+ vi.mock("monaco-editor", () => ({
19
+ editor: {
20
+ create: vi.fn(() => ({
21
+ onDidChangeModelContent: vi.fn(() => ({ dispose: vi.fn() })),
22
+ onDidScrollChange: vi.fn(() => ({ dispose: vi.fn() })),
23
+ onDidChangeCursorPosition: vi.fn(() => ({ dispose: vi.fn() })),
24
+ onDidChangeCursorSelection: vi.fn(() => ({ dispose: vi.fn() })),
25
+ onDidBlurEditorText: vi.fn(() => ({ dispose: vi.fn() })),
26
+ getValue: vi.fn(() => ""),
27
+ setValue: vi.fn(),
28
+ getSelection: vi.fn(() => null),
29
+ getModel: vi.fn(() => ({ dispose: vi.fn(), getLineCount })),
30
+ updateOptions: vi.fn(),
31
+ dispose: vi.fn(),
32
+ getDomNode: vi.fn(() => null),
33
+ getVisibleRanges: vi.fn(() => []),
34
+ getTopForLineNumber: vi.fn(() => 0),
35
+ setScrollTop: vi.fn(),
36
+ getPosition: vi.fn(() => null),
37
+ getScrolledVisiblePosition: vi.fn(() => null),
38
+ revealLine,
39
+ revealLineInCenter,
40
+ addAction,
41
+ focus: vi.fn(),
42
+ })),
43
+ createModel: vi.fn(() => ({ dispose: vi.fn() })),
44
+ setModelLanguage: vi.fn(),
45
+ defineTheme: vi.fn(),
46
+ setTheme: vi.fn(),
47
+ },
48
+ // KeyMod / KeyCode are used by parseShortcut inside the sourceActions useMemo.
49
+ KeyMod: { CtrlCmd: 2048, Shift: 1024, Alt: 512, WinCtrl: 256 },
50
+ KeyCode: {
51
+ Slash: 85,
52
+ KeyA: 31,
53
+ KeyB: 32,
54
+ KeyC: 33,
55
+ KeyD: 34,
56
+ KeyE: 35,
57
+ KeyF: 36,
58
+ KeyG: 37,
59
+ KeyH: 38,
60
+ KeyI: 39,
61
+ KeyJ: 40,
62
+ KeyK: 41,
63
+ KeyL: 42,
64
+ KeyM: 43,
65
+ KeyN: 44,
66
+ KeyO: 45,
67
+ KeyP: 46,
68
+ KeyQ: 47,
69
+ KeyR: 48,
70
+ KeyS: 49,
71
+ KeyT: 50,
72
+ KeyU: 51,
73
+ KeyV: 52,
74
+ KeyW: 53,
75
+ KeyX: 54,
76
+ KeyY: 55,
77
+ KeyZ: 56,
78
+ },
79
+ Uri: { parse: (s: string) => ({ toString: () => s }) },
80
+ Range: class {},
81
+ Selection: class {},
82
+ }));
83
+
84
+ import { MarkdownWorkspace, type MarkdownWorkspaceHandle } from "./markdown-workspace";
85
+
86
+ afterEach(() => {
87
+ cleanup();
88
+ vi.clearAllMocks();
89
+ });
90
+
91
+ test("renders the mode switcher and the WYSIWYG editor", async () => {
92
+ render(<MarkdownWorkspace defaultMode="wysiwyg" defaultValue="# Hello" />);
93
+ expect(screen.getByTestId("markdown-workspace")).toBeInTheDocument();
94
+ // Three modes are offered.
95
+ expect(screen.getByRole("radio", { name: "Source" })).toBeInTheDocument();
96
+ expect(screen.getByRole("radio", { name: "Split" })).toBeInTheDocument();
97
+ // Milkdown mounts the heading.
98
+ await waitFor(() => expect(screen.getByText("Hello")).toBeInTheDocument());
99
+ });
100
+
101
+ // #270 — focusWriting opt-out
102
+ test("focusWriting={false} hides the Focus toggle in wysiwyg mode", () => {
103
+ render(<MarkdownWorkspace defaultMode="wysiwyg" focusWriting={false} />);
104
+ expect(screen.queryByRole("button", { name: /focus writing/i })).toBeNull();
105
+ });
106
+
107
+ test("Focus toggle is present by default in wysiwyg mode (regression guard)", () => {
108
+ render(<MarkdownWorkspace defaultMode="wysiwyg" />);
109
+ expect(screen.getByRole("button", { name: /focus writing/i })).toBeInTheDocument();
110
+ });
111
+
112
+ // #272 — modeSwitch opt-out
113
+ test("modeSwitch={false} hides the built-in mode items", () => {
114
+ render(<MarkdownWorkspace modeSwitch={false} mode="split" />);
115
+ expect(screen.queryByRole("radio", { name: "Source" })).toBeNull();
116
+ expect(screen.queryByRole("radio", { name: "Split" })).toBeNull();
117
+ expect(screen.queryByRole("radio", { name: "Preview-edit" })).toBeNull();
118
+ // The formatting toolbar (Bold button) is still present in source/split.
119
+ expect(screen.getByRole("button", { name: "Bold" })).toBeInTheDocument();
120
+ });
121
+
122
+ test("all three mode items present by default (regression guard)", () => {
123
+ render(<MarkdownWorkspace />);
124
+ expect(screen.getByRole("radio", { name: "Source" })).toBeInTheDocument();
125
+ expect(screen.getByRole("radio", { name: "Split" })).toBeInTheDocument();
126
+ expect(screen.getByRole("radio", { name: "Preview-edit" })).toBeInTheDocument();
127
+ });
128
+
129
+ // #272 — toolbarActions slot
130
+ test("toolbarActions renders in the trailing slot when modeSwitch={false}", () => {
131
+ render(<MarkdownWorkspace modeSwitch={false} mode="split" toolbarActions={<button>X</button>} />);
132
+ expect(screen.getByRole("button", { name: "X" })).toBeInTheDocument();
133
+ });
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // #273 — MarkdownWorkspaceHandle
137
+ // ---------------------------------------------------------------------------
138
+
139
+ // Helper: render in source mode and wait for the Monaco mock's onMount to fire
140
+ // (the mock's `create` is synchronous, so the setMonaco state update fires in
141
+ // the same act cycle).
142
+ async function renderWithHandle(markdown = "# Section One\n\nParagraph.\n") {
143
+ const ref = createRef<MarkdownWorkspaceHandle>();
144
+ render(<MarkdownWorkspace ref={ref} defaultValue={markdown} defaultMode="source" />);
145
+ // The mock's editor.create fires synchronously, but setMonaco is a state
146
+ // update — wait one tick for it to flush.
147
+ await act(async () => {});
148
+ return ref;
149
+ }
150
+
151
+ test("#273 getElement() returns the root DOM div", async () => {
152
+ const ref = await renderWithHandle();
153
+ const el = ref.current?.getElement();
154
+ expect(el).toBeInstanceOf(HTMLDivElement);
155
+ expect(el).toBe(screen.getByTestId("markdown-workspace"));
156
+ });
157
+
158
+ test("#273 getEditor() returns the mocked Monaco instance after mount", async () => {
159
+ const ref = await renderWithHandle();
160
+ const editor = ref.current?.getEditor();
161
+ expect(editor).toBeTruthy();
162
+ // The mocked editor has revealLineInCenter as a spy.
163
+ expect(typeof editor?.revealLineInCenter).toBe("function");
164
+ });
165
+
166
+ test("#273 revealLine(n) calls revealLineInCenter for in-range line (center default)", async () => {
167
+ const ref = await renderWithHandle();
168
+ act(() => {
169
+ ref.current?.revealLine(5);
170
+ });
171
+ expect(revealLineInCenter).toHaveBeenCalledWith(5);
172
+ expect(revealLine).not.toHaveBeenCalled();
173
+ });
174
+
175
+ test("#273 revealLine(n, { center: false }) calls revealLine (not center)", async () => {
176
+ const ref = await renderWithHandle();
177
+ act(() => {
178
+ ref.current?.revealLine(3, { center: false });
179
+ });
180
+ expect(revealLine).toHaveBeenCalledWith(3);
181
+ expect(revealLineInCenter).not.toHaveBeenCalled();
182
+ });
183
+
184
+ test("#273 revealLine(0) is a no-op (out of range — below 1)", async () => {
185
+ const ref = await renderWithHandle();
186
+ act(() => {
187
+ ref.current?.revealLine(0);
188
+ });
189
+ expect(revealLineInCenter).not.toHaveBeenCalled();
190
+ expect(revealLine).not.toHaveBeenCalled();
191
+ });
192
+
193
+ test("#273 revealLine(999) is a no-op (out of range — above getLineCount)", async () => {
194
+ const ref = await renderWithHandle();
195
+ act(() => {
196
+ ref.current?.revealLine(999);
197
+ });
198
+ expect(revealLineInCenter).not.toHaveBeenCalled();
199
+ expect(revealLine).not.toHaveBeenCalled();
200
+ });
201
+
202
+ test("#273 scrollToHeading resolves slug → line → revealLineInCenter in source mode", async () => {
203
+ // The markdown has "# Section One" as line 1 of the stripped body (no frontmatter).
204
+ // fmOffset = 0, so full-source line = 1.
205
+ const ref = await renderWithHandle("# Section One\n\nParagraph.\n");
206
+ act(() => {
207
+ ref.current?.scrollToHeading("section-one");
208
+ });
209
+ expect(revealLineInCenter).toHaveBeenCalledWith(1);
210
+ });
211
+
212
+ test("#273 scrollToHeading with missing slug is a no-op", async () => {
213
+ const ref = await renderWithHandle("# Actual Heading\n");
214
+ act(() => {
215
+ ref.current?.scrollToHeading("nonexistent-slug");
216
+ });
217
+ expect(revealLineInCenter).not.toHaveBeenCalled();
218
+ expect(revealLine).not.toHaveBeenCalled();
219
+ });
220
+
221
+ test("#273 getEditor() returns null in wysiwyg mode (source pane not mounted)", () => {
222
+ const ref = createRef<MarkdownWorkspaceHandle>();
223
+ render(<MarkdownWorkspace ref={ref} defaultValue="# Hello" defaultMode="wysiwyg" />);
224
+ // In wysiwyg mode the CodeEditor is never mounted so setMonaco never fires.
225
+ expect(ref.current?.getEditor()).toBeNull();
226
+ });
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // #271 — source-pane slash menu: CodeEditor action registration
230
+ // ---------------------------------------------------------------------------
231
+
232
+ test("#271 CodeEditor receives a brand.openSlashMenu action when slashMenu is enabled (default)", async () => {
233
+ render(<MarkdownWorkspace defaultMode="source" />);
234
+ // The mock's editor.create fires synchronously; setMonaco is a state update —
235
+ // wait one tick for it to flush so the sourceActions effect fires.
236
+ await act(async () => {});
237
+ expect(addAction).toHaveBeenCalledWith(expect.objectContaining({ id: "brand.openSlashMenu" }));
238
+ });
239
+
240
+ type ActionCall = [{ id: string; label: string; keybindings?: number[] }];
241
+
242
+ function findActionCall(id: string): ActionCall | undefined {
243
+ const calls = addAction.mock.calls as unknown as ActionCall[];
244
+ return calls.find(([a]) => a.id === id);
245
+ }
246
+
247
+ test("#271 brand.openSlashMenu action label is 'Insert block…'", async () => {
248
+ render(<MarkdownWorkspace defaultMode="source" />);
249
+ await act(async () => {});
250
+ const call = findActionCall("brand.openSlashMenu");
251
+ expect(call?.[0]).toMatchObject({ label: "Insert block…" });
252
+ });
253
+
254
+ test("#271 brand.openSlashMenu action has a keybinding array", async () => {
255
+ render(<MarkdownWorkspace defaultMode="source" />);
256
+ await act(async () => {});
257
+ const call = findActionCall("brand.openSlashMenu");
258
+ expect(Array.isArray(call?.[0].keybindings)).toBe(true);
259
+ expect((call?.[0].keybindings ?? []).length).toBeGreaterThan(0);
260
+ });
261
+
262
+ test("#271 brand.openSlashMenu action is NOT registered when slashMenu={false}", async () => {
263
+ addAction.mockClear();
264
+ render(<MarkdownWorkspace defaultMode="source" slashMenu={false} />);
265
+ await act(async () => {});
266
+ const call = findActionCall("brand.openSlashMenu");
267
+ expect(call).toBeUndefined();
268
+ });
269
+
270
+ test("#271/#299 the source command filter doesn't crash with the default command list", async () => {
271
+ // Regression guard: a run-only WYSIWYG-only command (neither `snippet` NOR
272
+ // `runInSource`) must not reach the source popup. We assert indirectly here
273
+ // (renders without error); the broadened `snippet != null || runInSource`
274
+ // filter is asserted directly below (#299).
275
+ render(<MarkdownWorkspace defaultMode="source" />);
276
+ await act(async () => {});
277
+ expect(screen.getByTestId("markdown-workspace")).toBeInTheDocument();
278
+ });
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // #299 — run-only commands (runInSource) in the source-pane slash menu
282
+ // ---------------------------------------------------------------------------
283
+
284
+ test("#299 a run-only command (runInSource, no snippet) appears in the source slash menu", async () => {
285
+ const runInSource = vi.fn();
286
+ const commands: SlashCommand[] = [{ id: "ask-ai", label: "Ask AI", run: () => {}, runInSource }];
287
+ const ref = createRef<MarkdownWorkspaceHandle>();
288
+ render(<MarkdownWorkspace ref={ref} defaultMode="source" slashMenu={{ commands }} />);
289
+ await act(async () => {});
290
+
291
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
292
+ const editor = ref.current?.getEditor() as any;
293
+ editor.getPosition.mockReturnValue({ lineNumber: 1, column: 1 });
294
+ editor.getScrolledVisiblePosition.mockReturnValue({ top: 10, left: 10, height: 20 });
295
+ editor.getDomNode.mockReturnValue(document.createElement("div"));
296
+
297
+ act(() => {
298
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
299
+ (findActionCall("brand.openSlashMenu")?.[0] as any).run();
300
+ });
301
+
302
+ expect(screen.getByText("Ask AI")).toBeInTheDocument();
303
+ });
304
+
305
+ test("#299 selecting the run-only command strips the trigger and calls runInSource", async () => {
306
+ const runInSource = vi.fn();
307
+ const commands: SlashCommand[] = [{ id: "ask-ai", label: "Ask AI", run: () => {}, runInSource }];
308
+ const ref = createRef<MarkdownWorkspaceHandle>();
309
+ render(<MarkdownWorkspace ref={ref} defaultMode="source" slashMenu={{ commands }} />);
310
+ await act(async () => {});
311
+
312
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
313
+ const editor = ref.current?.getEditor() as any;
314
+ editor.getPosition.mockReturnValue({ lineNumber: 1, column: 1 });
315
+ editor.getScrolledVisiblePosition.mockReturnValue({ top: 10, left: 10, height: 20 });
316
+ editor.getDomNode.mockReturnValue(document.createElement("div"));
317
+
318
+ act(() => {
319
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
320
+ (findActionCall("brand.openSlashMenu")?.[0] as any).run();
321
+ });
322
+
323
+ fireEvent.keyDown(editor.getDomNode(), { key: "Enter" });
324
+ expect(runInSource).toHaveBeenCalledTimes(1);
325
+ expect(runInSource.mock.calls[0]?.[0]?.editor).toBe(editor);
326
+ });