@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,688 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, userEvent, waitFor, within } from "storybook/test";
3
+ import { useRef, useState } from "react";
4
+ import { Button, ToggleGroup, ToggleGroupItem } from "@elabs-ai/components-ui";
5
+ import type { EditorCompletionProvider } from "../lib/editor-completions";
6
+ import { BRAND_SLASH_COMMANDS, type SlashCommand } from "../markdown-editor/slash";
7
+ import { MarkdownWorkspace, type MarkdownWorkspaceHandle } from "./markdown-workspace";
8
+
9
+ const SAMPLE = `# Migration Plan
10
+
11
+ Author on the left, branded preview on the right.
12
+
13
+ :::callout{type="warning" title="Heads up"}
14
+ This block renders as an Alert in the preview.
15
+ :::
16
+
17
+ ::metric{label="Scope" value="1,245" description="frozen report candidates"}
18
+ `;
19
+
20
+ const meta = {
21
+ title: "Editor/MarkdownWorkspace",
22
+ component: MarkdownWorkspace,
23
+ tags: ["autodocs"],
24
+ parameters: {
25
+ layout: "fullscreen",
26
+ docs: {
27
+ description: {
28
+ component:
29
+ "The hybrid markdown surface: one value, three modes (Source = Monaco, Split = " +
30
+ "source + branded preview, Preview-edit = Milkdown WYSIWYG), switched via a " +
31
+ "@elabs-ai/components-ui ToggleGroup. The MarkdownToolbar is exercised in Source/Split modes.",
32
+ },
33
+ },
34
+ },
35
+ } satisfies Meta<typeof MarkdownWorkspace>;
36
+
37
+ export default meta;
38
+ type Story = StoryObj<typeof meta>;
39
+
40
+ export const Split: Story = {
41
+ render: function SplitStory() {
42
+ const [value, setValue] = useState(SAMPLE);
43
+ return (
44
+ <div className="h-[520px] border-t border-border">
45
+ <MarkdownWorkspace value={value} onChange={setValue} defaultMode="split" />
46
+ </div>
47
+ );
48
+ },
49
+ play: async ({ canvasElement }) => {
50
+ const canvas = within(canvasElement);
51
+ // The mode switch (radiogroup) and formatting toolbar render immediately.
52
+ await expect(canvas.getByRole("radio", { name: "Split" })).toBeInTheDocument();
53
+ await expect(canvas.getByRole("toolbar", { name: "Markdown formatting" })).toBeInTheDocument();
54
+ },
55
+ };
56
+
57
+ const LONG = `# Focus writing
58
+
59
+ ${Array.from({ length: 12 }, (_, i) => `Paragraph ${i + 1} — keep typing and the caret stays in a vertical band while the page moves underneath; every paragraph except the active one dims.`).join("\n\n")}
60
+ `;
61
+
62
+ /**
63
+ * Focus writing (Ulysses Phase A): typewriter scrolling + paragraph focus in
64
+ * the WYSIWYG mode. The "Focus" toggle in the mode row turns it on; the
65
+ * active paragraph stays at full opacity while the rest dim.
66
+ */
67
+ export const FocusWriting: Story = {
68
+ render: function FocusWritingStory() {
69
+ const [value, setValue] = useState(LONG);
70
+ return (
71
+ <div className="h-[520px] border-t border-border">
72
+ <MarkdownWorkspace
73
+ value={value}
74
+ onChange={setValue}
75
+ defaultMode="wysiwyg"
76
+ defaultFocusWriting
77
+ />
78
+ </div>
79
+ );
80
+ },
81
+ play: async ({ canvasElement }) => {
82
+ const canvas = within(canvasElement);
83
+ const toggle = await canvas.findByRole("button", { name: "Focus writing" });
84
+ await expect(toggle).toHaveAttribute("aria-pressed", "true");
85
+ },
86
+ };
87
+
88
+ /**
89
+ * `focusWriting={false}` — the host opts out of the Focus writing toggle. The
90
+ * toggle is completely hidden (no keyboard path), and focus-writing state is
91
+ * forced off. The mode switch remains and WYSIWYG editing is unaffected. (#270)
92
+ */
93
+ export const NoFocusToggle: Story = {
94
+ tags: ["autodocs"],
95
+ render: function NoFocusToggleStory() {
96
+ const [value, setValue] = useState(LONG);
97
+ return (
98
+ <div className="h-[520px] border-t border-border">
99
+ <MarkdownWorkspace
100
+ value={value}
101
+ onChange={setValue}
102
+ defaultMode="wysiwyg"
103
+ focusWriting={false}
104
+ />
105
+ </div>
106
+ );
107
+ },
108
+ play: async ({ canvasElement }) => {
109
+ const canvas = within(canvasElement);
110
+ // The Focus toggle must NOT be present.
111
+ await expect(canvas.queryByRole("button", { name: /focus writing/i })).toBeNull();
112
+ // The mode switch is still rendered.
113
+ await expect(canvas.getByRole("radio", { name: "Preview-edit" })).toBeInTheDocument();
114
+ },
115
+ };
116
+
117
+ /**
118
+ * `modeSwitch={false}` — the host supplies its own mode switch via
119
+ * `toolbarActions`. The built-in Source / Split / Preview-edit toggle group is
120
+ * hidden in both toolbar branches so the host owns the view switch. Controlled
121
+ * `mode`/`onModeChange` still drive the panes. (#272)
122
+ */
123
+ export const HostOwnedModeSwitch: Story = {
124
+ tags: ["autodocs"],
125
+ render: function HostOwnedModeSwitchStory() {
126
+ const [value, setValue] = useState(SAMPLE);
127
+ const [activeMode, setActiveMode] = useState<"source" | "split">("split");
128
+ return (
129
+ <div className="h-[520px] border-t border-border">
130
+ <MarkdownWorkspace
131
+ value={value}
132
+ onChange={setValue}
133
+ mode={activeMode}
134
+ onModeChange={(m) => {
135
+ if (m === "source" || m === "split") setActiveMode(m);
136
+ }}
137
+ modeSwitch={false}
138
+ toolbarActions={
139
+ <ToggleGroup
140
+ type="single"
141
+ value={activeMode}
142
+ onValueChange={(m) => {
143
+ if (m === "source" || m === "split") setActiveMode(m);
144
+ }}
145
+ variant="segmented"
146
+ size="sm"
147
+ className="rounded-md p-0.5"
148
+ >
149
+ <ToggleGroupItem value="source" aria-label="Source" className="h-6 px-2 text-caption">
150
+ Source
151
+ </ToggleGroupItem>
152
+ <ToggleGroupItem value="split" aria-label="Split" className="h-6 px-2 text-caption">
153
+ Split
154
+ </ToggleGroupItem>
155
+ </ToggleGroup>
156
+ }
157
+ />
158
+ </div>
159
+ );
160
+ },
161
+ play: async ({ canvasElement }) => {
162
+ const canvas = within(canvasElement);
163
+ // Built-in mode items must NOT be present.
164
+ await expect(canvas.queryByRole("radio", { name: "Preview-edit" })).toBeNull();
165
+ // The host-supplied controls render in the trailing slot.
166
+ await expect(canvas.getByRole("radio", { name: "Source" })).toBeInTheDocument();
167
+ await expect(canvas.getByRole("radio", { name: "Split" })).toBeInTheDocument();
168
+ },
169
+ };
170
+
171
+ // A multi-screen document for exercising revealLine / scrollToHeading.
172
+ const MULTI_SCREEN = `# Introduction
173
+
174
+ ${Array.from({ length: 8 }, (_, i) => `Paragraph ${i + 1} in the introduction section.`).join("\n\n")}
175
+
176
+ ## Getting Started
177
+
178
+ ${Array.from({ length: 8 }, (_, i) => `Paragraph ${i + 1} in getting started.`).join("\n\n")}
179
+
180
+ ## Configuration
181
+
182
+ ${Array.from({ length: 8 }, (_, i) => `Paragraph ${i + 1} in configuration.`).join("\n\n")}
183
+
184
+ ## Advanced Usage
185
+
186
+ ${Array.from({ length: 8 }, (_, i) => `Paragraph ${i + 1} in advanced usage.`).join("\n\n")}
187
+
188
+ ## Troubleshooting
189
+
190
+ ${Array.from({ length: 8 }, (_, i) => `Paragraph ${i + 1} in troubleshooting.`).join("\n\n")}
191
+ `;
192
+
193
+ /**
194
+ * `MarkdownWorkspaceHandle` — imperative `revealLine` + `scrollToHeading` (#273).
195
+ *
196
+ * Use the buttons to call `ref.current.revealLine(N)` or
197
+ * `ref.current.scrollToHeading(slug)` programmatically. In Source/Split mode
198
+ * the Monaco editor scrolls to the exact line; in WYSIWYG (Preview-edit) mode
199
+ * the nearest heading scrolls into view (best-effort, heading-anchored).
200
+ *
201
+ * `getElement()` returns the root `<div>` (the old `HTMLDivElement` ref
202
+ * access); `getEditor()` returns the live Monaco instance (null in WYSIWYG).
203
+ */
204
+ export const RevealLine: Story = {
205
+ tags: ["autodocs"],
206
+ render: function RevealLineStory() {
207
+ const ref = useRef<MarkdownWorkspaceHandle>(null);
208
+ const [value, setValue] = useState(MULTI_SCREEN);
209
+ const [log, setLog] = useState("");
210
+
211
+ const logAction = (msg: string) => setLog(msg);
212
+
213
+ return (
214
+ <div className="flex h-[600px] flex-col gap-2 border-t border-border p-2">
215
+ <div className="flex flex-wrap gap-2">
216
+ <Button
217
+ size="sm"
218
+ variant="outline"
219
+ onClick={() => {
220
+ ref.current?.revealLine(1);
221
+ logAction("revealLine(1) — Introduction heading");
222
+ }}
223
+ >
224
+ Line 1 (Introduction)
225
+ </Button>
226
+ <Button
227
+ size="sm"
228
+ variant="outline"
229
+ onClick={() => {
230
+ // "## Getting Started" is around line 12 in the stripped body
231
+ ref.current?.scrollToHeading("getting-started");
232
+ logAction('scrollToHeading("getting-started")');
233
+ }}
234
+ >
235
+ Heading: Getting Started
236
+ </Button>
237
+ <Button
238
+ size="sm"
239
+ variant="outline"
240
+ onClick={() => {
241
+ ref.current?.scrollToHeading("advanced-usage");
242
+ logAction('scrollToHeading("advanced-usage")');
243
+ }}
244
+ >
245
+ Heading: Advanced Usage
246
+ </Button>
247
+ <Button
248
+ size="sm"
249
+ variant="outline"
250
+ onClick={() => {
251
+ ref.current?.scrollToHeading("troubleshooting");
252
+ logAction('scrollToHeading("troubleshooting")');
253
+ }}
254
+ >
255
+ Heading: Troubleshooting
256
+ </Button>
257
+ <Button
258
+ size="sm"
259
+ variant="outline"
260
+ onClick={() => {
261
+ const el = ref.current?.getElement();
262
+ logAction(`getElement() → ${el ? el.tagName + "#" + el.dataset.testid : "null"}`);
263
+ }}
264
+ >
265
+ getElement()
266
+ </Button>
267
+ <Button
268
+ size="sm"
269
+ variant="outline"
270
+ onClick={() => {
271
+ const ed = ref.current?.getEditor();
272
+ logAction(`getEditor() → ${ed ? "Monaco instance" : "null (WYSIWYG mode)"}`);
273
+ }}
274
+ >
275
+ getEditor()
276
+ </Button>
277
+ </div>
278
+ {log && (
279
+ <p className="text-caption text-muted-foreground" data-testid="reveal-log">
280
+ {log}
281
+ </p>
282
+ )}
283
+ <div className="min-h-0 flex-1">
284
+ <MarkdownWorkspace ref={ref} value={value} onChange={setValue} defaultMode="source" />
285
+ </div>
286
+ </div>
287
+ );
288
+ },
289
+ play: async ({ canvasElement }) => {
290
+ const canvas = within(canvasElement);
291
+ // Buttons render immediately.
292
+ await expect(
293
+ canvas.getByRole("button", { name: "Heading: Getting Started" }),
294
+ ).toBeInTheDocument();
295
+ // Click scrollToHeading — the log updates (Monaco scrolls in the real browser).
296
+ await userEvent.click(canvas.getByRole("button", { name: "Heading: Getting Started" }));
297
+ await expect(canvas.getByTestId("reveal-log")).toHaveTextContent("getting-started");
298
+ // getElement() resolves to the workspace root div.
299
+ await userEvent.click(canvas.getByRole("button", { name: "getElement()" }));
300
+ await expect(canvas.getByTestId("reveal-log")).toHaveTextContent("markdown-workspace");
301
+ },
302
+ };
303
+
304
+ // Source doc with a trailing blank line to type the slash on.
305
+ const SLASH_SAMPLE = `# Block menu in the source editor
306
+
307
+ Put the caret on the blank line below and type \`/\`, or use the button.
308
+
309
+ `;
310
+
311
+ /**
312
+ * **Type \`/\` to open the block menu in the Monaco SOURCE pane** — the
313
+ * conflict-free trigger (no keybinding), mirroring the WYSIWYG \`/\` menu. A \`/\`
314
+ * at a line start (or after whitespace) opens the branded listbox at the caret;
315
+ * the \`/\` is replaced by the block you pick (Esc removes it, leaving the doc
316
+ * untouched). The story's play step inserts a \`/\` to open it on load — you can
317
+ * also just type \`/\` in the editor.
318
+ */
319
+ export const SourceTypedSlash: Story = {
320
+ name: "Source: type / to insert a block",
321
+ tags: ["autodocs"],
322
+ render: function SourceTypedSlashStory() {
323
+ const ref = useRef<MarkdownWorkspaceHandle>(null);
324
+ const [value, setValue] = useState(SLASH_SAMPLE);
325
+ return (
326
+ <div className="flex h-[560px] flex-col gap-2 border-t border-border p-2">
327
+ <div className="flex items-center gap-3">
328
+ <Button
329
+ size="sm"
330
+ variant="outline"
331
+ onClick={() => {
332
+ const ed = ref.current?.getEditor();
333
+ const model = ed?.getModel();
334
+ if (!ed || !model) return; // no-op until Monaco has mounted
335
+ const line = model.getLineCount();
336
+ ed.focus();
337
+ ed.setPosition({ lineNumber: line, column: 1 });
338
+ ed.executeEdits("demo", [
339
+ {
340
+ range: {
341
+ startLineNumber: line,
342
+ startColumn: 1,
343
+ endLineNumber: line,
344
+ endColumn: 1,
345
+ },
346
+ text: "/",
347
+ },
348
+ ]);
349
+ }}
350
+ >
351
+ Type / (insert a slash)
352
+ </Button>
353
+ <p className="text-caption text-muted-foreground">
354
+ Or click into the editor and type / at the start of a line. Type to filter, ↑/↓ + Enter
355
+ to insert, Esc to dismiss.
356
+ </p>
357
+ </div>
358
+ <div className="min-h-0 flex-1">
359
+ <MarkdownWorkspace ref={ref} value={value} onChange={setValue} defaultMode="source" />
360
+ </div>
361
+ </div>
362
+ );
363
+ },
364
+ play: async ({ canvasElement }) => {
365
+ const canvas = within(canvasElement);
366
+ await waitFor(() => expect(canvasElement.querySelector(".monaco-editor")).toBeTruthy(), {
367
+ timeout: 8000,
368
+ });
369
+ const trigger = canvas.getByRole("button", { name: "Type / (insert a slash)" });
370
+ // Click until the menu opens. Before Monaco is ready the click is a no-op (no
371
+ // stray `/`); once open, the guard stops further clicks.
372
+ await waitFor(
373
+ async () => {
374
+ if (!canvas.queryByRole("listbox", { name: "Insert block" })) {
375
+ await userEvent.click(trigger);
376
+ }
377
+ expect(canvas.getByRole("listbox", { name: "Insert block" })).toBeInTheDocument();
378
+ },
379
+ { timeout: 8000 },
380
+ );
381
+ await expect(
382
+ within(canvas.getByRole("listbox", { name: "Insert block" })).getByText("Card"),
383
+ ).toBeInTheDocument();
384
+ },
385
+ };
386
+
387
+ /**
388
+ * **Hotkey** path (#271): \`slashMenu.shortcut\` opens the SAME menu. The default is
389
+ * **⌘⇧O** (free in Chrome/macOS) — NOT \`Mod-/\`, which Monaco binds to "Toggle Line
390
+ * Comment". ⚠️ ⌘⇧O / Ctrl-Shift-O is the bookmarks shortcut in Firefox and on
391
+ * Windows/Linux Chrome, so override \`slashMenu.shortcut\` (or rely on typing \`/\`)
392
+ * for cross-browser apps. The play fires the bound command to open it on load.
393
+ */
394
+ export const SourceSlashHotkey: Story = {
395
+ name: "Source: ⌘⇧O hotkey",
396
+ tags: ["autodocs"],
397
+ render: function SourceSlashHotkeyStory() {
398
+ const ref = useRef<MarkdownWorkspaceHandle>(null);
399
+ const [value, setValue] = useState(SLASH_SAMPLE);
400
+ return (
401
+ <div className="flex h-[560px] flex-col gap-2 border-t border-border p-2">
402
+ <div className="flex items-center gap-3">
403
+ <Button
404
+ size="sm"
405
+ variant="outline"
406
+ onClick={() => {
407
+ const ed = ref.current?.getEditor();
408
+ ed?.focus();
409
+ ed?.getAction("brand.openSlashMenu")?.run();
410
+ }}
411
+ >
412
+ Open block menu (⌘⇧O)
413
+ </Button>
414
+ <p className="text-caption text-muted-foreground">
415
+ Press ⌘⇧O with the editor focused (Chrome/macOS) or click the button. Override
416
+ slashMenu.shortcut for Firefox / Windows.
417
+ </p>
418
+ </div>
419
+ <div className="min-h-0 flex-1">
420
+ <MarkdownWorkspace ref={ref} value={value} onChange={setValue} defaultMode="source" />
421
+ </div>
422
+ </div>
423
+ );
424
+ },
425
+ play: async ({ canvasElement }) => {
426
+ const canvas = within(canvasElement);
427
+ await waitFor(() => expect(canvasElement.querySelector(".monaco-editor")).toBeTruthy(), {
428
+ timeout: 8000,
429
+ });
430
+ const trigger = canvas.getByRole("button", { name: "Open block menu (⌘⇧O)" });
431
+ await waitFor(
432
+ async () => {
433
+ if (!canvas.queryByRole("listbox", { name: "Insert block" })) {
434
+ await userEvent.click(trigger);
435
+ }
436
+ expect(canvas.getByRole("listbox", { name: "Insert block" })).toBeInTheDocument();
437
+ },
438
+ { timeout: 8000 },
439
+ );
440
+ },
441
+ };
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // #283 — declarative completion-provider API (`completions`)
445
+ // ---------------------------------------------------------------------------
446
+
447
+ const COMPLETIONS_SAMPLE = `# Completions: [[wikilink]] autocomplete (#283)
448
+
449
+ Click the button to type the [[ trigger at the end of this line, or place the
450
+ caret here yourself and type it — no monaco-editor import in this story.
451
+
452
+ `;
453
+
454
+ // A tiny in-memory "note index" the provider searches — entirely consumer-owned
455
+ // (candidates, filtering, and insertText are the app's job; the library only
456
+ // registers the provider and renders/inserts the result). Zero `monaco-editor`
457
+ // imports appear anywhere in this story.
458
+ const NOTE_TITLES = ["Onboarding guide", "Release notes", "Roadmap", "Testing notes"];
459
+
460
+ /** Pure matcher — a real consumer would use a shared, tested helper like this. */
461
+ function matchWikilinks(lineText: string, column: number): string[] {
462
+ const before = lineText.slice(0, column - 1);
463
+ const match = /\[\[([^[\]]*)$/.exec(before);
464
+ if (!match) return [];
465
+ const query = (match[1] ?? "").toLowerCase();
466
+ return NOTE_TITLES.filter((title) => title.toLowerCase().includes(query));
467
+ }
468
+
469
+ /**
470
+ * **`completions` prop (#283)** — a declarative `[[wikilink]]` autocomplete
471
+ * provider, wired with ZERO `monaco-editor` imports in this story. The library
472
+ * owns the Monaco `registerCompletionItemProvider` registration (registered
473
+ * once, refcounted across mounted workspaces, disposed with the last one) and
474
+ * renders Monaco's OWN themed suggest widget — no custom popup on this path.
475
+ * Works in both Source and Split (the pane rendering Monaco either way).
476
+ *
477
+ * The play step verifies the part that is THIS library's responsibility end to
478
+ * end in a real browser: typing `[[` reaches the registered provider with the
479
+ * correct live context, which resolves the right candidates (logged below the
480
+ * editor). Whether Monaco's OWN native suggest popup paints itself in any given
481
+ * host is Monaco's own long-tested internal behavior, not new code — open this
482
+ * story in the Storybook UI to see it render live.
483
+ */
484
+ export const CompletionsWikilink: Story = {
485
+ name: "Completions: [[wikilink]] autocomplete",
486
+ tags: ["autodocs"],
487
+ render: function CompletionsWikilinkStory() {
488
+ const ref = useRef<MarkdownWorkspaceHandle>(null);
489
+ const [value, setValue] = useState(COMPLETIONS_SAMPLE);
490
+ const [log, setLog] = useState("(not triggered yet)");
491
+
492
+ const provider: EditorCompletionProvider = {
493
+ id: "wikilink",
494
+ triggerCharacters: ["["],
495
+ provide: ({ lineText, column }) => {
496
+ const matches = matchWikilinks(lineText, column);
497
+ setLog(matches.length > 0 ? matches.join(", ") : "(no match at this caret)");
498
+ return matches.map((title) => ({ label: title, insertText: `${title}]]` }));
499
+ },
500
+ };
501
+
502
+ const insertTrigger = async () => {
503
+ const ed = ref.current?.getEditor();
504
+ const model = ed?.getModel();
505
+ if (!ed || !model) return; // no-op until Monaco has mounted
506
+ const line = model.getLineCount();
507
+ ed.focus();
508
+ // Two SEPARATE single-character edits (not one 2-char insert), a tick
509
+ // apart — mirrors real typing: the library's force-open listener only
510
+ // recognizes a genuine single-char insert as "the trigger character was
511
+ // just typed", and a stray simultaneous pair doesn't give the FIRST
512
+ // (empty-result) request time to settle before the second.
513
+ for (const char of ["[", "["]) {
514
+ const column = model.getLineContent(line).length + 1;
515
+ ed.setPosition({ lineNumber: line, column });
516
+ ed.executeEdits("demo", [
517
+ {
518
+ range: {
519
+ startLineNumber: line,
520
+ startColumn: column,
521
+ endLineNumber: line,
522
+ endColumn: column,
523
+ },
524
+ text: char,
525
+ },
526
+ ]);
527
+ await new Promise((resolve) => setTimeout(resolve, 80));
528
+ }
529
+ };
530
+ return (
531
+ <div className="flex h-[560px] flex-col gap-2 border-t border-border p-2">
532
+ <div className="flex items-center gap-3">
533
+ <Button size="sm" variant="outline" onClick={insertTrigger}>
534
+ Type [[ (trigger suggestions)
535
+ </Button>
536
+ <p className="text-caption text-muted-foreground">
537
+ Or click into the editor and type [[ yourself. ↑/↓ + Enter to insert.
538
+ </p>
539
+ </div>
540
+ <div className="min-h-0 flex-1">
541
+ <MarkdownWorkspace
542
+ ref={ref}
543
+ value={value}
544
+ onChange={setValue}
545
+ defaultMode="split"
546
+ completions={[provider]}
547
+ />
548
+ </div>
549
+ {/* Proves the end-to-end wiring (trigger detected → provider called with
550
+ the live caret context → candidates resolved) independent of
551
+ whether Monaco's own suggest popup happens to be visible when this
552
+ runs headless. */}
553
+ <p data-testid="wikilink-log" className="text-caption text-muted-foreground">
554
+ Candidates: {log}
555
+ </p>
556
+ </div>
557
+ );
558
+ },
559
+ play: async ({ canvasElement }) => {
560
+ const canvas = within(canvasElement);
561
+ await waitFor(() => expect(canvasElement.querySelector(".monaco-editor")).toBeTruthy(), {
562
+ timeout: 8000,
563
+ });
564
+ const trigger = canvas.getByRole("button", { name: "Type [[ (trigger suggestions)" });
565
+ await userEvent.click(trigger);
566
+ await waitFor(
567
+ () =>
568
+ expect(canvas.getByTestId("wikilink-log")).toHaveTextContent(
569
+ "Onboarding guide, Release notes, Roadmap, Testing notes",
570
+ ),
571
+ { timeout: 8000 },
572
+ );
573
+ },
574
+ };
575
+
576
+ // ---------------------------------------------------------------------------
577
+ // #299 — run-only slash commands (runInSource) in the source pane
578
+ // ---------------------------------------------------------------------------
579
+
580
+ const RUN_ONLY_SAMPLE = `# Run-only slash commands (#299)
581
+
582
+ Put the caret on the blank line below and type \`/\`, then choose "Ask AI".
583
+
584
+ `;
585
+
586
+ const askAiCommand: SlashCommand = {
587
+ id: "ask-ai",
588
+ label: "Ask AI",
589
+ group: "Demo",
590
+ description: "No snippet — runs directly via runInSource",
591
+ keywords: ["ai", "ask"],
592
+ // WYSIWYG (Milkdown) — this story only exercises the source pane, but every
593
+ // SlashCommand still needs `run` (unchanged by #299).
594
+ run: () => {},
595
+ // Source pane (#299): strips the typed `/query`, then this fires with the
596
+ // live editor + the engine-agnostic content access.
597
+ runInSource: ({ content }) => {
598
+ content.insertAtCursor("_(AI answer would go here)_");
599
+ },
600
+ };
601
+
602
+ /**
603
+ * **`runInSource` (#299)** — a run-only command (no `snippet`) that used to be
604
+ * WYSIWYG-only now works in the Monaco SOURCE pane too. Selecting "Ask AI"
605
+ * strips the typed `/query`, then calls `runInSource` with the live editor +
606
+ * engine-agnostic content access — the 12 default (snippet-bearing) commands
607
+ * are unchanged and still insert their snippet.
608
+ */
609
+ export const SourceRunOnlyCommand: Story = {
610
+ name: "Source: run-only command (runInSource)",
611
+ tags: ["autodocs"],
612
+ render: function SourceRunOnlyCommandStory() {
613
+ const ref = useRef<MarkdownWorkspaceHandle>(null);
614
+ const [value, setValue] = useState(RUN_ONLY_SAMPLE);
615
+ return (
616
+ <div className="flex h-[560px] flex-col gap-2 border-t border-border p-2">
617
+ <div className="flex items-center gap-3">
618
+ <Button
619
+ size="sm"
620
+ variant="outline"
621
+ onClick={() => {
622
+ const ed = ref.current?.getEditor();
623
+ const model = ed?.getModel();
624
+ if (!ed || !model) return; // no-op until Monaco has mounted
625
+ const line = model.getLineCount();
626
+ ed.focus();
627
+ ed.setPosition({ lineNumber: line, column: 1 });
628
+ ed.executeEdits("demo", [
629
+ {
630
+ range: {
631
+ startLineNumber: line,
632
+ startColumn: 1,
633
+ endLineNumber: line,
634
+ endColumn: 1,
635
+ },
636
+ text: "/",
637
+ },
638
+ ]);
639
+ }}
640
+ >
641
+ Type / (insert a slash)
642
+ </Button>
643
+ <p className="text-caption text-muted-foreground">
644
+ "Ask AI" has no snippet — only runInSource — yet still appears + runs here.
645
+ </p>
646
+ </div>
647
+ <div className="min-h-0 flex-1">
648
+ <MarkdownWorkspace
649
+ ref={ref}
650
+ value={value}
651
+ onChange={setValue}
652
+ defaultMode="source"
653
+ slashMenu={{ commands: [askAiCommand, ...BRAND_SLASH_COMMANDS] }}
654
+ />
655
+ </div>
656
+ {/* The document's live text, for a robust play-step assertion — scanning
657
+ Monaco's own rendered `.view-line` spans directly is fragile
658
+ (syntax-highlighting can split a run across sibling nodes). */}
659
+ <p data-testid="run-only-log" className="sr-only">
660
+ {value}
661
+ </p>
662
+ </div>
663
+ );
664
+ },
665
+ play: async ({ canvasElement }) => {
666
+ const canvas = within(canvasElement);
667
+ await waitFor(() => expect(canvasElement.querySelector(".monaco-editor")).toBeTruthy(), {
668
+ timeout: 8000,
669
+ });
670
+ const trigger = canvas.getByRole("button", { name: "Type / (insert a slash)" });
671
+ await waitFor(
672
+ async () => {
673
+ if (!canvas.queryByRole("listbox", { name: "Insert block" })) {
674
+ await userEvent.click(trigger);
675
+ }
676
+ expect(canvas.getByRole("listbox", { name: "Insert block" })).toBeInTheDocument();
677
+ },
678
+ { timeout: 8000 },
679
+ );
680
+ const listbox = canvas.getByRole("listbox", { name: "Insert block" });
681
+ await expect(within(listbox).getByText("Ask AI")).toBeInTheDocument();
682
+ await userEvent.click(within(listbox).getByText("Ask AI"));
683
+ await waitFor(
684
+ () => expect(canvas.getByTestId("run-only-log")).toHaveTextContent("AI answer would go here"),
685
+ { timeout: 8000 },
686
+ );
687
+ },
688
+ };