@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,324 @@
1
+ /**
2
+ * The brand slash-command registry — pure, typed, tree-shakeable data.
3
+ *
4
+ * Each command is a label + group + match keywords + an icon + a `run` that
5
+ * inserts the corresponding block at the caret. `run` is given a
6
+ * {@link SlashInsertContext} (the Milkdown `Ctx` + the active `/query` range);
7
+ * brand directives delegate to `resolveBrandInsert`, basic blocks to
8
+ * `resolveBasicInsert` — so the SAME registry drives the menu and any
9
+ * programmatic insert, and the load-bearing insertion logic stays in
10
+ * `insert-directive.ts` (unit-tested there).
11
+ *
12
+ * Defaults: card · callout · metric · timeline ("Brand blocks") +
13
+ * heading · bullet-list · ordered-list · quote · code · divider ("Basic").
14
+ * Consumers can extend or replace the list via the `slashMenu` prop.
15
+ *
16
+ * `runInSource` (#299) is an OPTIONAL, ADDITIVE second handler for the Monaco
17
+ * source pane — `run` (Milkdown) stays untouched, so the 12 default commands
18
+ * need zero migration. `MonacoCodeEditor` / `IRange` / `EditorContentAccess`
19
+ * below are TYPE-ONLY imports (erased at build) — this file stays free of any
20
+ * `monaco-editor` RUNTIME import, so the Milkdown-facing `slash/index.ts`
21
+ * barrel this file feeds still pulls zero Monaco code.
22
+ */
23
+ import type { Ctx } from "@milkdown/kit/ctx";
24
+ import {
25
+ Calculator,
26
+ Grid3x3,
27
+ Heading2,
28
+ List,
29
+ ListOrdered,
30
+ Minus,
31
+ Quote,
32
+ Repeat2,
33
+ SquareCode,
34
+ type LucideIcon,
35
+ } from "lucide-react";
36
+ import type { IRange } from "monaco-editor";
37
+ import { createElement, type ReactNode } from "react";
38
+
39
+ import type { MonacoCodeEditor } from "../../code-editor";
40
+ import type { EditorContentAccess } from "../../lib/editor-content-access";
41
+ import type { IterationEditHandler } from "../../markdown-iteration/edit-context";
42
+ import {
43
+ CALC_FENCE_SEED,
44
+ resolveBasicInsert,
45
+ resolveBrandInsert,
46
+ resolveCalcInsert,
47
+ resolveGuidedIterationInsert,
48
+ type InsertableDirective,
49
+ type SlashRange,
50
+ } from "./insert-directive";
51
+
52
+ /** The native (non-directive) blocks the slash menu can insert. */
53
+ export type BasicBlockId =
54
+ | "heading"
55
+ | "bullet-list"
56
+ | "ordered-list"
57
+ | "quote"
58
+ | "code"
59
+ | "divider";
60
+
61
+ /**
62
+ * Context handed to a {@link SlashCommand.run}. `ctx` is the live Milkdown editor
63
+ * context; `range` is the `[from, to)` document range of the typed trigger +
64
+ * query (so the command replaces it), or `null` to insert at the caret.
65
+ */
66
+ export interface SlashInsertContext {
67
+ ctx: Ctx;
68
+ range: SlashRange | null;
69
+ }
70
+
71
+ /**
72
+ * Context handed to a {@link SlashCommand.runInSource} (#299) — the Monaco
73
+ * SOURCE-pane counterpart to {@link SlashInsertContext}. By the time this runs,
74
+ * the typed `/query` trigger has already been stripped from the document (the
75
+ * same edit `cancel` uses), so the handler starts from a clean doc.
76
+ */
77
+ export interface SourceSlashContext {
78
+ /** The raw Monaco editor instance — full imperative access when needed. */
79
+ editor: MonacoCodeEditor;
80
+ /**
81
+ * Model range the typed `/query` trigger occupied (already removed by the
82
+ * time this fires) — `null` on the hotkey path, where no `/` was ever typed.
83
+ */
84
+ range: IRange | null;
85
+ /** Engine-agnostic content access (get/replace-selection/insert-at-cursor). */
86
+ content: EditorContentAccess;
87
+ }
88
+
89
+ /** One entry in the slash menu. Pure data + an insertion `run`. */
90
+ export interface SlashCommand {
91
+ /** Stable id (used as the React key + `aria-activedescendant` target). */
92
+ id: string;
93
+ /** Visible label, e.g. "Card". */
94
+ label: string;
95
+ /** Grouping header, e.g. "Brand blocks" | "Basic". */
96
+ group?: string;
97
+ /** A short description shown under the label. */
98
+ description?: string;
99
+ /** Extra fuzzy-match aids beyond the label (matched case-insensitively). */
100
+ keywords?: string[];
101
+ /** Leading icon (a Lucide glyph or any node). */
102
+ icon?: ReactNode;
103
+ /**
104
+ * Markdown SNIPPET the source / split toolbar's Insert menu inserts (text mode).
105
+ * Optional: commands without one — the basic blocks, which already have their
106
+ * own toolbar buttons — don't appear in that menu. Brand blocks set this so the
107
+ * source toolbar inserts the SAME directive / fence the WYSIWYG `run` builds, so
108
+ * the two surfaces stay at parity. (A4)
109
+ */
110
+ snippet?: string;
111
+ /** Insert the block at the caret. WYSIWYG (Milkdown) — UNCHANGED (#299). */
112
+ run: (ctx: SlashInsertContext) => void;
113
+ /**
114
+ * OPTIONAL source-pane (Monaco) handler (#299) — for a RUN-ONLY command (no
115
+ * `snippet`, e.g. an "Ask AI" entry) that can't express itself as a markdown
116
+ * insert. When present, selecting the command in the Monaco source-pane menu
117
+ * (`MonacoSlashMenu`) strips the typed `/query` first, then calls this with
118
+ * the live editor + the (now-stripped) trigger range + engine-agnostic
119
+ * content access — instead of the `snippet`-only `commitSnippet` fallback. A
120
+ * command needs only ONE of `snippet` / `runInSource` to appear in source (both
121
+ * is fine too — `runInSource` wins there since it's the more capable handler).
122
+ */
123
+ runInSource?: (ctx: SourceSlashContext) => void;
124
+ /**
125
+ * OPTIONAL guided variant (#223, WYSIWYG only). When present AND the
126
+ * consumer has wired an `IterationEditContext` handler (an
127
+ * `IterationBuilderProvider` / `IterationTemplateProvider` above the
128
+ * editor), this REPLACES `run` — it opens the guided modal seeded blank
129
+ * instead of inserting a bare directive. Falls back to `run` when no
130
+ * handler is available, so the 12 non-guided default commands need zero
131
+ * changes. Set on the `iterate` / `pivot` commands only.
132
+ */
133
+ guided?: (insert: SlashInsertContext, handler: IterationEditHandler) => void;
134
+ }
135
+
136
+ /** Build a Lucide icon element at the menu's standard size. */
137
+ function glyph(icon: LucideIcon): ReactNode {
138
+ return createElement(icon, { className: "size-4", "aria-hidden": "true" });
139
+ }
140
+
141
+ /**
142
+ * Source-mode markdown snippet per brand directive — the text the Insert menu
143
+ * drops into the Monaco pane. Mirrors the WYSIWYG insert (the `DIRECTIVE_DEFAULTS`
144
+ * attrs + body seeds in `insert-directive.ts`) so both surfaces produce the same
145
+ * `:::` markdown.
146
+ */
147
+ const DIRECTIVE_SNIPPET: Record<InsertableDirective, string> = {
148
+ card: `:::card{title="Title"}\nContent\n:::`,
149
+ callout: `:::callout{type="info" title="Note"}\nMessage\n:::`,
150
+ metric: `::metric{label="Label" value="0" description="detail"}`,
151
+ timeline: `:::timeline\n- (done) Step one\n- (active) Step two\n- (pending) Step three\n:::`,
152
+ iterate: `:::iterate{as="item" layout="stacked"}\n{{item.name}}\n:::`,
153
+ pivot: `:::pivot{layout="matrix"}\n{{cell}}\n:::`,
154
+ };
155
+
156
+ /** Directives with a guided authoring modal (#223) — see {@link SlashCommand.guided}. */
157
+ const GUIDED_DIRECTIVES = new Set<InsertableDirective>(["iterate", "pivot"]);
158
+
159
+ /** A brand-directive command (card/callout/metric/timeline/iterate/pivot). */
160
+ function brandCommand(
161
+ name: InsertableDirective,
162
+ label: string,
163
+ description: string,
164
+ keywords: string[],
165
+ icon: ReactNode,
166
+ ): SlashCommand {
167
+ const command: SlashCommand = {
168
+ id: `brand-${name}`,
169
+ label,
170
+ group: "Brand blocks",
171
+ description,
172
+ keywords,
173
+ icon,
174
+ snippet: DIRECTIVE_SNIPPET[name],
175
+ run: ({ ctx, range }) => resolveBrandInsert(ctx, name, range),
176
+ };
177
+ if (GUIDED_DIRECTIVES.has(name)) {
178
+ command.guided = ({ ctx, range }, handler) =>
179
+ resolveGuidedIterationInsert(ctx, name as "iterate" | "pivot", range, handler);
180
+ }
181
+ return command;
182
+ }
183
+
184
+ /** A basic native-block command. */
185
+ function basicCommand(
186
+ id: BasicBlockId,
187
+ label: string,
188
+ description: string,
189
+ keywords: string[],
190
+ icon: LucideIcon,
191
+ ): SlashCommand {
192
+ return {
193
+ id: `basic-${id}`,
194
+ label,
195
+ group: "Basic",
196
+ description,
197
+ keywords,
198
+ icon: glyph(icon),
199
+ run: ({ ctx, range }) => resolveBasicInsert(ctx, id, range),
200
+ };
201
+ }
202
+
203
+ /**
204
+ * The default command list. Brand blocks first (the wedge), then the basics.
205
+ * The brand-block icons reuse the `data-brand-directive` chrome look via Lucide
206
+ * stand-ins (the editor renders the real component once inserted).
207
+ */
208
+ export const BRAND_SLASH_COMMANDS: SlashCommand[] = [
209
+ brandCommand(
210
+ "card",
211
+ "Card",
212
+ "A titled content card",
213
+ ["card", "panel", "box", "section"],
214
+ glyph(SquareCode),
215
+ ),
216
+ brandCommand(
217
+ "callout",
218
+ "Callout",
219
+ "A highlighted note / alert",
220
+ ["callout", "alert", "note", "info", "warning", "tip"],
221
+ glyph(Quote),
222
+ ),
223
+ brandCommand(
224
+ "metric",
225
+ "Metric",
226
+ "A single KPI value",
227
+ ["metric", "kpi", "stat", "number", "value"],
228
+ glyph(Minus),
229
+ ),
230
+ brandCommand(
231
+ "timeline",
232
+ "Timeline",
233
+ "A list of steps with status",
234
+ ["timeline", "steps", "milestones", "roadmap", "progress"],
235
+ glyph(List),
236
+ ),
237
+ brandCommand(
238
+ "iterate",
239
+ "Iterate",
240
+ "Repeat a template per data row",
241
+ ["iterate", "repeat", "loop", "for-each", "foreach", "map", "list", "template"],
242
+ glyph(Repeat2),
243
+ ),
244
+ brandCommand(
245
+ "pivot",
246
+ "Pivot",
247
+ "A row × column cross-tab",
248
+ ["pivot", "matrix", "cross-tab", "crosstab", "table", "grid"],
249
+ glyph(Grid3x3),
250
+ ),
251
+ {
252
+ // Inserts a ```calc fence (not a `:::` directive), so it runs its own command
253
+ // rather than `resolveBrandInsert`. Highlight + result inlays come from the
254
+ // editor's calc hooks; the fence is harmless when none are wired.
255
+ id: "brand-calc",
256
+ label: "Calc",
257
+ group: "Brand blocks",
258
+ description: "A live calculation block",
259
+ keywords: ["calc", "calculation", "math", "formula", "sum", "ledger", "budget"],
260
+ icon: glyph(Calculator),
261
+ snippet: ["```calc", CALC_FENCE_SEED, "```"].join("\n"),
262
+ run: ({ ctx, range }) => resolveCalcInsert(ctx, range),
263
+ },
264
+ basicCommand("heading", "Heading", "Section heading", ["heading", "title", "h2"], Heading2),
265
+ basicCommand(
266
+ "bullet-list",
267
+ "Bullet list",
268
+ "An unordered list",
269
+ ["bullet", "list", "unordered", "ul"],
270
+ List,
271
+ ),
272
+ basicCommand(
273
+ "ordered-list",
274
+ "Numbered list",
275
+ "An ordered list",
276
+ ["numbered", "ordered", "list", "ol"],
277
+ ListOrdered,
278
+ ),
279
+ basicCommand("quote", "Quote", "A block quotation", ["quote", "blockquote", "citation"], Quote),
280
+ basicCommand(
281
+ "code",
282
+ "Code block",
283
+ "A fenced code block",
284
+ ["code", "snippet", "pre", "fence"],
285
+ SquareCode,
286
+ ),
287
+ basicCommand(
288
+ "divider",
289
+ "Divider",
290
+ "A horizontal rule",
291
+ ["divider", "rule", "hr", "separator"],
292
+ Minus,
293
+ ),
294
+ ];
295
+
296
+ /**
297
+ * Filter a command list by a query (case-insensitive substring over label +
298
+ * keywords). An empty query returns the full list (in registry order).
299
+ */
300
+ export function filterSlashCommands(commands: SlashCommand[], query: string): SlashCommand[] {
301
+ const q = query.trim().toLowerCase();
302
+ if (!q) return commands;
303
+ return commands.filter((c) => {
304
+ if (c.label.toLowerCase().includes(q)) return true;
305
+ return (c.keywords ?? []).some((k) => k.toLowerCase().includes(q));
306
+ });
307
+ }
308
+
309
+ /** Preserve registry order while grouping; used by the menu to render headers. */
310
+ export function groupSlashCommands(
311
+ commands: SlashCommand[],
312
+ ): { group: string; commands: SlashCommand[] }[] {
313
+ const order: string[] = [];
314
+ const byGroup = new Map<string, SlashCommand[]>();
315
+ for (const c of commands) {
316
+ const g = c.group ?? "Other";
317
+ if (!byGroup.has(g)) {
318
+ byGroup.set(g, []);
319
+ order.push(g);
320
+ }
321
+ byGroup.get(g)!.push(c);
322
+ }
323
+ return order.map((group) => ({ group, commands: byGroup.get(group)! }));
324
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Unit tests for brand-slash-plugin.ts — the `nextState` open-meta path and the
3
+ * `runSlashCommand` range behavior for shortcut mode (#271).
4
+ *
5
+ * We use a real headless Milkdown editor (the insert-directive.test.ts precedent)
6
+ * to verify the pure ProseMirror logic. The React widget and the keyboard UX are
7
+ * verified in Storybook (browser only — jsdom can't render Monaco/Milkdown).
8
+ *
9
+ * No monaco mock is needed: brand-slash-plugin.ts imports only the PURE
10
+ * `./shortcut` (matchesKeyboardEvent); the Monaco-keybinding parser lives in the
11
+ * separate `./shortcut-monaco`, which this plugin never imports — so the WYSIWYG
12
+ * plugin graph stays Monaco-free (and jsdom-safe).
13
+ */
14
+ import { Editor, defaultValueCtx, editorViewCtx, rootCtx } from "@milkdown/kit/core";
15
+ import { commonmark } from "@milkdown/kit/preset/commonmark";
16
+ import { gfm } from "@milkdown/kit/preset/gfm";
17
+ import { getMarkdown } from "@milkdown/kit/utils";
18
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
19
+
20
+ import { directivePlugins } from "../directive-nodes";
21
+ import { BRAND_SLASH_COMMANDS } from "./brand-slash-commands";
22
+ import {
23
+ CLOSED,
24
+ createSlashController,
25
+ nextState,
26
+ runSlashCommand,
27
+ slashPluginKey,
28
+ type SlashPluginState,
29
+ } from "./brand-slash-plugin";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Headless Milkdown helpers (same pattern as insert-directive.test.ts)
33
+ // ---------------------------------------------------------------------------
34
+
35
+ let editorRoot: HTMLDivElement | null = null;
36
+
37
+ async function makeEditor(initial = ""): Promise<Editor> {
38
+ const root = document.createElement("div");
39
+ document.body.appendChild(root);
40
+ editorRoot = root;
41
+ const editor = await Editor.make()
42
+ .config((ctx) => {
43
+ ctx.set(rootCtx, root);
44
+ ctx.set(defaultValueCtx, initial);
45
+ })
46
+ .use(commonmark)
47
+ .use(gfm)
48
+ .use(directivePlugins)
49
+ .create();
50
+ return editor;
51
+ }
52
+
53
+ function serialize(editor: Editor): string {
54
+ return editor.action(getMarkdown());
55
+ }
56
+
57
+ beforeEach(() => {
58
+ editorRoot = null;
59
+ });
60
+ afterEach(() => {
61
+ editorRoot?.remove();
62
+ editorRoot = null;
63
+ });
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // nextState: the `_open` meta opens the menu in shortcut mode (real behavior).
67
+ //
68
+ // `nextState` is a pure function over (prev, transaction, trigger). We build a
69
+ // real ProseMirror transaction from a headless editor and assert the resulting
70
+ // state — no source-text regex, no widget mounting needed.
71
+ // ---------------------------------------------------------------------------
72
+
73
+ describe("nextState — shortcut open meta", () => {
74
+ test("CLOSED defaults triggered to 'char'", () => {
75
+ expect(CLOSED.triggered).toBe("char");
76
+ expect(CLOSED.active).toBe(false);
77
+ });
78
+
79
+ test("an `_open` meta opens the menu at the given caret in shortcut mode", async () => {
80
+ const editor = await makeEditor("Before text\n");
81
+ await editor.action((ctx) => {
82
+ const view = ctx.get(editorViewCtx);
83
+ const tr = view.state.tr.setMeta(slashPluginKey, {
84
+ _open: true,
85
+ from: 5,
86
+ triggered: "shortcut",
87
+ });
88
+ const next = nextState(CLOSED, tr, "/");
89
+ expect(next.active).toBe(true);
90
+ expect(next.triggered).toBe("shortcut");
91
+ expect(next.from).toBe(5);
92
+ expect(next.query).toBe("");
93
+ expect(next.index).toBe(0);
94
+ });
95
+ });
96
+
97
+ test("a `close` meta returns the CLOSED state", async () => {
98
+ const editor = await makeEditor("Before text\n");
99
+ await editor.action((ctx) => {
100
+ const view = ctx.get(editorViewCtx);
101
+ const open: SlashPluginState = {
102
+ active: true,
103
+ from: 5,
104
+ query: "",
105
+ index: 0,
106
+ triggered: "shortcut",
107
+ };
108
+ const tr = view.state.tr.setMeta(slashPluginKey, "close");
109
+ const next = nextState(open, tr, "/");
110
+ expect(next.active).toBe(false);
111
+ expect(next.triggered).toBe("char");
112
+ });
113
+ });
114
+ });
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // runSlashCommand — shortcut mode replaces [from, from+query.length]; char mode
118
+ // replaces the whole /query run.
119
+ // ---------------------------------------------------------------------------
120
+
121
+ describe("runSlashCommand — shortcut vs char range", () => {
122
+ test("in shortcut mode, the card command inserts at caret without deleting anything", async () => {
123
+ const editor = await makeEditor("Before text\n");
124
+
125
+ await editor.action(async (ctx) => {
126
+ const view = ctx.get(editorViewCtx);
127
+
128
+ // Shortcut opens at the caret with no typed query — here the caret is at the
129
+ // end of "Before text" (pos 12). An empty-query shortcut inserts at `from`
130
+ // and deletes nothing, so the paragraph text survives intact.
131
+ const shortcutState: SlashPluginState = {
132
+ active: true,
133
+ from: 12,
134
+ query: "",
135
+ index: 0,
136
+ triggered: "shortcut",
137
+ };
138
+
139
+ const controller = createSlashController(BRAND_SLASH_COMMANDS, "/");
140
+ controller._ctx = ctx;
141
+
142
+ const cardCommand = BRAND_SLASH_COMMANDS.find((c) => c.id === "brand-card")!;
143
+ expect(cardCommand).toBeDefined();
144
+
145
+ runSlashCommand(view, controller, shortcutState, cardCommand);
146
+
147
+ // The document should now contain the card directive.
148
+ const md = serialize(editor);
149
+ expect(md).toContain(":::card");
150
+ // "Before text" must still be present (nothing was deleted).
151
+ expect(md).toContain("Before text");
152
+ });
153
+ });
154
+
155
+ test("in char mode, the card command replaces the /query range", async () => {
156
+ const editor = await makeEditor("/\n");
157
+
158
+ await editor.action(async (ctx) => {
159
+ const view = ctx.get(editorViewCtx);
160
+
161
+ // Char-mode state: the `/` is at position 1 (start of paragraph).
162
+ const charState: SlashPluginState = {
163
+ active: true,
164
+ from: 1,
165
+ query: "",
166
+ index: 0,
167
+ triggered: "char",
168
+ };
169
+
170
+ const controller = createSlashController(BRAND_SLASH_COMMANDS, "/");
171
+ controller._ctx = ctx;
172
+
173
+ const cardCommand = BRAND_SLASH_COMMANDS.find((c) => c.id === "brand-card")!;
174
+ runSlashCommand(view, controller, charState, cardCommand);
175
+
176
+ const md = serialize(editor);
177
+ expect(md).toContain(":::card");
178
+ // The `/` trigger should be gone.
179
+ expect(md).not.toMatch(/^\/\s*$/m);
180
+ });
181
+ });
182
+
183
+ test("in shortcut mode, a typed filter query is removed (#271 review)", async () => {
184
+ // The shortcut opens with no leading `/`, but any filter the user typed is
185
+ // real document text — runSlashCommand must replace [from, from+query.length].
186
+ const editor = await makeEditor("callout\n");
187
+
188
+ await editor.action(async (ctx) => {
189
+ const view = ctx.get(editorViewCtx);
190
+ // Caret-open at paragraph start (pos 1); the 7-char query "callout" follows.
191
+ const shortcutState: SlashPluginState = {
192
+ active: true,
193
+ from: 1,
194
+ query: "callout",
195
+ index: 0,
196
+ triggered: "shortcut",
197
+ };
198
+
199
+ const controller = createSlashController(BRAND_SLASH_COMMANDS, "/");
200
+ controller._ctx = ctx;
201
+
202
+ const calloutCommand = BRAND_SLASH_COMMANDS.find((c) => c.id === "brand-callout")!;
203
+ runSlashCommand(view, controller, shortcutState, calloutCommand);
204
+
205
+ const md = serialize(editor);
206
+ expect(md).toContain(":::callout");
207
+ // The typed filter text must NOT survive as a stray paragraph.
208
+ expect(md).not.toMatch(/^callout\s*$/m);
209
+ });
210
+ });
211
+ });
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // runSlashCommand — the GUIDED branch (#223) must consume the typed `/query`
215
+ // range up front, even when the consumer's handler never saves (Cancel). The
216
+ // guided resolver doesn't dispatch an insert transaction until the modal
217
+ // SAVES, so without this the literal "/iterate" text was left behind
218
+ // permanently whenever the user opened the guided builder and cancelled it.
219
+ // ---------------------------------------------------------------------------
220
+
221
+ describe("runSlashCommand — guided branch consumes the /query range", () => {
222
+ test("typing /iterate and cancelling the guided modal still removes the typed trigger text", async () => {
223
+ const editor = await makeEditor("/iterate\n");
224
+
225
+ await editor.action(async (ctx) => {
226
+ const view = ctx.get(editorViewCtx);
227
+
228
+ const charState: SlashPluginState = {
229
+ active: true,
230
+ from: 1,
231
+ query: "iterate",
232
+ index: 0,
233
+ triggered: "char",
234
+ };
235
+
236
+ // A handler that simulates the user cancelling the guided modal: it
237
+ // receives the edit request but never calls onSave/onSaveData.
238
+ const controller = createSlashController(BRAND_SLASH_COMMANDS, "/", undefined, () => () => {
239
+ // Cancel: intentionally does nothing.
240
+ });
241
+ controller._ctx = ctx;
242
+
243
+ const iterateCommand = BRAND_SLASH_COMMANDS.find((c) => c.id === "brand-iterate")!;
244
+ expect(iterateCommand.guided).toBeDefined();
245
+
246
+ runSlashCommand(view, controller, charState, iterateCommand);
247
+
248
+ const md = serialize(editor);
249
+ // No directive was inserted (Cancel) — but the typed "/iterate" trigger
250
+ // text must be gone too, not left behind as stray body text.
251
+ expect(md).not.toMatch(/\/iterate/);
252
+ });
253
+ });
254
+ });