@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,263 @@
1
+ /**
2
+ * Unit tests for the load-bearing slash-menu logic: the pure ProseMirror
3
+ * insertion commands. We boot a HEADLESS Milkdown editor (no React, no widget —
4
+ * jsdom can't render the popup) with the real directive schemas + serializer, run
5
+ * the insertion through `resolveBrandInsert` / `resolveBasicInsert` exactly as the
6
+ * plugin does, and assert the document serializes to the correct `:::` / `::`
7
+ * markdown (the lossless round-trip the preview already understands).
8
+ *
9
+ * The live popup + keyboard UX (the React widget) is verified separately via
10
+ * Storybook / a browser — see slash-menu.stories.tsx.
11
+ */
12
+ import { Editor, defaultValueCtx, rootCtx, editorViewCtx } from "@milkdown/kit/core";
13
+ import { commonmark } from "@milkdown/kit/preset/commonmark";
14
+ import { gfm } from "@milkdown/kit/preset/gfm";
15
+ import { getMarkdown } from "@milkdown/kit/utils";
16
+ import { Selection } from "@milkdown/kit/prose/state";
17
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
18
+
19
+ import { directivePlugins } from "../directive-nodes";
20
+ import {
21
+ __DIRECTIVE_DEFAULTS,
22
+ CALC_FENCE_SEED,
23
+ isContainerDirective,
24
+ isLeafDirective,
25
+ resolveBasicInsert,
26
+ resolveBrandInsert,
27
+ resolveCalcInsert,
28
+ resolveGuidedIterationInsert,
29
+ type InsertableDirective,
30
+ } from "./insert-directive";
31
+
32
+ /** Boot a headless Milkdown editor with the brand directive pipeline. */
33
+ async function makeEditor(initial = ""): Promise<Editor> {
34
+ const root = document.createElement("div");
35
+ document.body.appendChild(root);
36
+ const editor = await Editor.make()
37
+ .config((ctx) => {
38
+ ctx.set(rootCtx, root);
39
+ ctx.set(defaultValueCtx, initial);
40
+ })
41
+ .use(commonmark)
42
+ .use(gfm)
43
+ .use(directivePlugins)
44
+ .create();
45
+ return editor;
46
+ }
47
+
48
+ /** Serialize the editor's current doc to markdown. */
49
+ function serialize(editor: Editor): string {
50
+ return editor.action(getMarkdown());
51
+ }
52
+
53
+ /** Move the caret to the end of the document. */
54
+ function caretToEnd(editor: Editor): void {
55
+ editor.action((ctx) => {
56
+ const view = ctx.get(editorViewCtx);
57
+ const tr = view.state.tr.setSelection(Selection.atEnd(view.state.doc));
58
+ view.dispatch(tr);
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Place the caret at the end of the document, then run a brand insert there
64
+ * (range=null → insert at the cursor). Returns the resulting markdown.
65
+ */
66
+ function insertBrandAtEnd(editor: Editor, name: InsertableDirective): string {
67
+ caretToEnd(editor);
68
+ editor.action((ctx) => resolveBrandInsert(ctx, name, null));
69
+ return serialize(editor);
70
+ }
71
+
72
+ let editor: Editor;
73
+
74
+ beforeEach(async () => {
75
+ editor = await makeEditor("Intro paragraph.\n");
76
+ });
77
+
78
+ afterEach(async () => {
79
+ await editor.destroy();
80
+ document.body.innerHTML = "";
81
+ });
82
+
83
+ describe("resolveBrandInsert — container directives round-trip", () => {
84
+ test(":::card inserts and serializes to a card directive with the default title", () => {
85
+ const md = insertBrandAtEnd(editor, "card");
86
+ expect(md).toContain(":::card");
87
+ expect(md).toContain('title="Title"');
88
+ // The opening + closing fence are both present (a valid container directive).
89
+ expect(md.match(/:::/g)?.length ?? 0).toBeGreaterThanOrEqual(2);
90
+ });
91
+
92
+ test(":::callout inserts with the default type + title", () => {
93
+ const md = insertBrandAtEnd(editor, "callout");
94
+ expect(md).toContain(":::callout");
95
+ expect(md).toContain('type="info"');
96
+ expect(md).toContain('title="Note"');
97
+ });
98
+
99
+ test(":::timeline inserts with seeded status steps", () => {
100
+ const md = insertBrandAtEnd(editor, "timeline");
101
+ expect(md).toContain(":::timeline");
102
+ expect(md).toContain("Step one");
103
+ expect(md).toContain("(active) Step two");
104
+ });
105
+
106
+ test(":::iterate inserts with default attrs + a seeded {{token}} template body", () => {
107
+ const md = insertBrandAtEnd(editor, "iterate");
108
+ expect(md).toContain(":::iterate");
109
+ expect(md).toContain('as="item"');
110
+ expect(md).toContain('layout="stacked"');
111
+ expect(md).toContain("{{item.name}}");
112
+ });
113
+
114
+ test(":::pivot inserts with the matrix layout + a seeded template body", () => {
115
+ const md = insertBrandAtEnd(editor, "pivot");
116
+ expect(md).toContain(":::pivot");
117
+ expect(md).toContain('layout="matrix"');
118
+ expect(md).toContain("{{cell}}");
119
+ });
120
+ });
121
+
122
+ describe("resolveBrandInsert — leaf directives round-trip", () => {
123
+ test("::metric inserts and serializes to a leaf directive with default attrs", () => {
124
+ const md = insertBrandAtEnd(editor, "metric");
125
+ // A leaf directive uses a single colon-pair and carries the metric attrs.
126
+ expect(md).toContain("::metric");
127
+ expect(md).toContain('label="Label"');
128
+ expect(md).toContain('value="0"');
129
+ // It is NOT a container (no fenced :::metric block).
130
+ expect(md).not.toContain(":::metric");
131
+ });
132
+ });
133
+
134
+ describe("resolveBasicInsert — native blocks", () => {
135
+ test("heading inserts a markdown heading", () => {
136
+ caretToEnd(editor);
137
+ editor.action((ctx) => resolveBasicInsert(ctx, "heading", null));
138
+ const md = serialize(editor);
139
+ expect(md).toMatch(/^##\s/m);
140
+ });
141
+
142
+ test("divider inserts a horizontal rule", () => {
143
+ caretToEnd(editor);
144
+ editor.action((ctx) => resolveBasicInsert(ctx, "divider", null));
145
+ const md = serialize(editor);
146
+ expect(md).toMatch(/^(---|\*\*\*|___)\s*$/m);
147
+ });
148
+
149
+ test("quote inserts a blockquote", () => {
150
+ caretToEnd(editor);
151
+ editor.action((ctx) => resolveBasicInsert(ctx, "quote", null));
152
+ const md = serialize(editor);
153
+ expect(md).toMatch(/^>\s?/m);
154
+ });
155
+ });
156
+
157
+ describe("resolveCalcInsert — calc fence (#220)", () => {
158
+ test("inserts a ```calc fence seeded with the example ledger", () => {
159
+ caretToEnd(editor);
160
+ editor.action((ctx) => resolveCalcInsert(ctx, null));
161
+ const md = serialize(editor);
162
+ // A fenced code block with the `calc` info string, carrying the seed.
163
+ expect(md).toMatch(/```calc/);
164
+ expect(md).toContain("items = 3");
165
+ expect(md).toContain("total = items * price");
166
+ expect(CALC_FENCE_SEED).toContain("items = 3");
167
+ });
168
+ });
169
+
170
+ describe("range replacement (the /query is consumed)", () => {
171
+ test("inserting over a range replaces that range (the typed /card text)", () => {
172
+ // Simulate: user typed "/card" at the end; insert with that range so it's removed.
173
+ const md = editor.action((ctx) => {
174
+ const view = ctx.get(editorViewCtx);
175
+ // Append "/card" to the intro paragraph.
176
+ const docEnd = view.state.doc.content.size;
177
+ const insertTr = view.state.tr.insertText("/card", docEnd - 1);
178
+ view.dispatch(insertTr);
179
+ return null;
180
+ });
181
+ void md;
182
+ // Now compute the range covering "/card" and insert a card over it.
183
+ editor.action((ctx) => {
184
+ const view = ctx.get(editorViewCtx);
185
+ const text = view.state.doc.textContent;
186
+ const slashIndex = text.lastIndexOf("/card");
187
+ // Map text index → doc position inside the single paragraph (offset by 1
188
+ // for the paragraph open token).
189
+ const from = slashIndex + 1;
190
+ const to = from + "/card".length;
191
+ return resolveBrandInsert(ctx, "card", { from, to });
192
+ });
193
+ const out = serialize(editor);
194
+ expect(out).toContain(":::card");
195
+ // The literal "/card" trigger text must be gone (consumed by the insert).
196
+ expect(out).not.toContain("/card");
197
+ });
198
+ });
199
+
200
+ describe("resolveGuidedIterationInsert — the GUIDED /iterate /pivot insert path (#223)", () => {
201
+ test("emits a request seeded blank (no values) — the guided builder's onSaveData inserts the fully-bound directive", () => {
202
+ caretToEnd(editor);
203
+ editor.action((ctx) =>
204
+ resolveGuidedIterationInsert(ctx, "iterate", null, (request) => {
205
+ expect(request.kind).toBe("iterate");
206
+ expect(request.attributes).toEqual({});
207
+ expect(request.template).toBe("{{item.name}}");
208
+ request.onSaveData!({
209
+ attributes: { as: "item", layout: "grid", values: "Alice, Bob" },
210
+ template: "{{item.name}}",
211
+ });
212
+ }),
213
+ );
214
+ const md = serialize(editor);
215
+ expect(md).toContain(":::iterate");
216
+ expect(md).toContain('layout="grid"');
217
+ expect(md).toContain('values="Alice, Bob"');
218
+ });
219
+
220
+ test("the template-only provider's onSave inserts a directive with default (empty) attrs + the edited template", () => {
221
+ caretToEnd(editor);
222
+ editor.action((ctx) =>
223
+ resolveGuidedIterationInsert(ctx, "pivot", null, (request) => {
224
+ request.onSave("{{row}} → {{col}}");
225
+ }),
226
+ );
227
+ const md = serialize(editor);
228
+ expect(md).toContain(":::pivot");
229
+ expect(md).toContain("{{row}} → {{col}}");
230
+ });
231
+
232
+ test("nothing is inserted until the handler actually saves (Cancel leaves the doc untouched)", () => {
233
+ const before = serialize(editor);
234
+ caretToEnd(editor);
235
+ editor.action((ctx) =>
236
+ resolveGuidedIterationInsert(ctx, "iterate", null, () => {
237
+ // Simulate Cancel: the handler never calls onSave/onSaveData.
238
+ }),
239
+ );
240
+ expect(serialize(editor)).toBe(before);
241
+ });
242
+ });
243
+
244
+ describe("directive classification helpers", () => {
245
+ test("container vs leaf split is correct", () => {
246
+ expect(isContainerDirective("card")).toBe(true);
247
+ expect(isContainerDirective("callout")).toBe(true);
248
+ expect(isContainerDirective("timeline")).toBe(true);
249
+ expect(isContainerDirective("metric")).toBe(false);
250
+ expect(isLeafDirective("metric")).toBe(true);
251
+ expect(isLeafDirective("card")).toBe(false);
252
+ });
253
+
254
+ test("default attrs mirror the toolbar snippet shapes", () => {
255
+ expect(__DIRECTIVE_DEFAULTS.card).toEqual({ title: "Title" });
256
+ expect(__DIRECTIVE_DEFAULTS.callout).toEqual({ type: "info", title: "Note" });
257
+ expect(__DIRECTIVE_DEFAULTS.metric).toEqual({
258
+ label: "Label",
259
+ value: "0",
260
+ description: "detail",
261
+ });
262
+ });
263
+ });
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Pure ProseMirror insertion commands for the slash menu.
3
+ *
4
+ * The load-bearing logic of the slash menu lives here, deliberately decoupled
5
+ * from Milkdown's context and from React: an `insertBrandDirective` factory that
6
+ * builds the right `brand_container_directive` / `brand_leaf_directive` node (from
7
+ * the schemas in `directive-nodes.ts`) with sensible default attrs + a placeholder
8
+ * body, replaces the active slash range (or inserts at the cursor), and drops the
9
+ * caret into the first editable field. Because it takes the NodeTypes/Schema as
10
+ * arguments (not a Milkdown `Ctx`), it is unit-testable against a hand-built
11
+ * ProseMirror state and its output round-trips losslessly through the editor's
12
+ * existing `toMarkdown` runners (asserted in insert-directive.test.ts).
13
+ *
14
+ * `resolveBrandInsert` is the thin Milkdown-aware adapter: it reads the NodeTypes
15
+ * from the editor `Ctx` and returns the same pure command — so the slash plugin
16
+ * and any direct caller share one implementation.
17
+ */
18
+ import type { Ctx } from "@milkdown/kit/ctx";
19
+ import { editorViewCtx, parserCtx } from "@milkdown/kit/core";
20
+ import type { Node as ProseNode, NodeType, Schema } from "@milkdown/kit/prose/model";
21
+ import { Fragment } from "@milkdown/kit/prose/model";
22
+ import type { Command, Transaction } from "@milkdown/kit/prose/state";
23
+ import { TextSelection } from "@milkdown/kit/prose/state";
24
+
25
+ import type { IterationEditHandler } from "../../markdown-iteration/edit-context";
26
+ import {
27
+ builderValueFromParts,
28
+ emptyBuilderValue,
29
+ serializeIterationDirective,
30
+ } from "../../markdown-iteration/iteration-builder";
31
+ import { containerDirectiveSchema, leafDirectiveSchema } from "../directive-nodes";
32
+ import type { BasicBlockId } from "./brand-slash-commands";
33
+
34
+ /** A `[from, to)` document range to replace (the typed `/query`), or `null`. */
35
+ export interface SlashRange {
36
+ from: number;
37
+ to: number;
38
+ }
39
+
40
+ /** Default attributes per brand directive — mirror the toolbar snippet shapes. */
41
+ const DIRECTIVE_DEFAULTS: Record<string, Record<string, string>> = {
42
+ card: { title: "Title" },
43
+ callout: { type: "info", title: "Note" },
44
+ timeline: {},
45
+ metric: { label: "Label", value: "0", description: "detail" },
46
+ iterate: { as: "item", layout: "stacked" },
47
+ pivot: { layout: "matrix" },
48
+ };
49
+
50
+ /** Per-directive seed body text (the editable template for iterate/pivot). */
51
+ const DIRECTIVE_BODY_SEED: Record<string, string> = {
52
+ iterate: "{{item.name}}",
53
+ pivot: "{{cell}}",
54
+ };
55
+
56
+ /** Brand container directives (`:::name`, editable body). */
57
+ const CONTAINER_DIRECTIVES = new Set(["card", "callout", "timeline", "iterate", "pivot"]);
58
+ /** Brand leaf directives (`::name`, atomic). */
59
+ const LEAF_DIRECTIVES = new Set(["metric"]);
60
+
61
+ /** The directive names the slash menu can insert as brand blocks. */
62
+ export type InsertableDirective = "card" | "callout" | "timeline" | "metric" | "iterate" | "pivot";
63
+
64
+ /**
65
+ * Build the default body content for a container directive: a single paragraph
66
+ * (the `content: "block+"` schema requires at least one block) carrying a short
67
+ * placeholder so the inserted block is never empty. Timeline seeds a 3-step
68
+ * bullet list (matching the toolbar snippet) when the schema has `bullet_list`.
69
+ */
70
+ function containerBody(schema: Schema, name: string): Fragment {
71
+ const paragraph = schema.nodes.paragraph;
72
+ if (name === "timeline") {
73
+ const bulletList = schema.nodes.bullet_list;
74
+ const listItem = schema.nodes.list_item;
75
+ if (bulletList && listItem && paragraph) {
76
+ const steps = ["(done) Step one", "(active) Step two", "(pending) Step three"];
77
+ const items = steps.map((text) =>
78
+ listItem.create(null, paragraph.create(null, schema.text(text))),
79
+ );
80
+ return Fragment.from(bulletList.create(null, items));
81
+ }
82
+ }
83
+ // iterate / pivot: seed the editable TEMPLATE body with a token placeholder so
84
+ // the inserted block renders something the moment data is wired.
85
+ const seed = DIRECTIVE_BODY_SEED[name];
86
+ if (seed && paragraph) return Fragment.from(paragraph.create(null, schema.text(seed)));
87
+
88
+ // card / callout (and timeline fallback): one empty placeholder paragraph
89
+ // (the `content: "block+"` schema requires at least one block; the user types
90
+ // the body). If the schema somehow lacks a paragraph, return an empty fragment
91
+ // and let the schema fill its required content.
92
+ if (!paragraph) return Fragment.empty;
93
+ return Fragment.from(paragraph.create());
94
+ }
95
+
96
+ /**
97
+ * The pure insertion command. Builds the directive node from the given
98
+ * NodeTypes + schema and replaces `range` (or the current selection when
99
+ * `range` is null), then positions the caret in the first editable field
100
+ * (the body's start for containers; just after the atomic leaf for metric).
101
+ *
102
+ * Returns a ProseMirror `Command` `(state, dispatch?) => boolean`.
103
+ */
104
+ export function insertBrandDirective(
105
+ name: InsertableDirective,
106
+ containerType: NodeType,
107
+ leafType: NodeType,
108
+ schema: Schema,
109
+ range: SlashRange | null,
110
+ ): Command {
111
+ return (state, dispatch) => {
112
+ const isContainer = CONTAINER_DIRECTIVES.has(name);
113
+ const isLeaf = LEAF_DIRECTIVES.has(name);
114
+ if (!isContainer && !isLeaf) return false;
115
+
116
+ const attributes = { ...(DIRECTIVE_DEFAULTS[name] ?? {}) };
117
+ const node = isContainer
118
+ ? containerType.create({ name, attributes }, containerBody(schema, name))
119
+ : leafType.create({ name, attributes });
120
+ if (!node) return false;
121
+
122
+ if (!dispatch) return true;
123
+
124
+ const { from, to } = range ?? { from: state.selection.from, to: state.selection.to };
125
+ const tr: Transaction = state.tr.replaceRangeWith(from, to, node);
126
+
127
+ // Place the caret: containers → first text position inside the body
128
+ // (from + 1 enters the node, +1 more enters its first child block);
129
+ // leaf (atomic) → just after the inserted node.
130
+ const caret = isContainer
131
+ ? Math.min(from + 2, tr.doc.content.size)
132
+ : Math.min(from + node.nodeSize, tr.doc.content.size);
133
+ const sel = TextSelection.near(tr.doc.resolve(caret), 1);
134
+ tr.setSelection(sel).scrollIntoView();
135
+
136
+ dispatch(tr);
137
+ return true;
138
+ };
139
+ }
140
+
141
+ /** Map a basic-block id to a ProseMirror command that inserts the native node. */
142
+ export function insertBasicBlock(
143
+ id: BasicBlockId,
144
+ schema: Schema,
145
+ range: SlashRange | null,
146
+ ): Command {
147
+ return (state, dispatch) => {
148
+ const n = schema.nodes;
149
+ const { from, to } = range ?? { from: state.selection.from, to: state.selection.to };
150
+ const tr = state.tr;
151
+
152
+ const replaceWith = (node: ReturnType<NodeType["create"]> | null, caretOffset: number) => {
153
+ if (!node) return false;
154
+ if (!dispatch) return true;
155
+ tr.replaceRangeWith(from, to, node);
156
+ const caret = Math.min(from + caretOffset, tr.doc.content.size);
157
+ tr.setSelection(TextSelection.near(tr.doc.resolve(caret), 1)).scrollIntoView();
158
+ dispatch(tr);
159
+ return true;
160
+ };
161
+
162
+ switch (id) {
163
+ case "heading":
164
+ return replaceWith(n.heading?.create({ level: 2 }) ?? null, 1);
165
+ case "bullet-list": {
166
+ const li = n.list_item?.create(null, n.paragraph?.create());
167
+ return replaceWith(li ? (n.bullet_list?.create(null, li) ?? null) : null, 2);
168
+ }
169
+ case "ordered-list": {
170
+ const li = n.list_item?.create(null, n.paragraph?.create());
171
+ return replaceWith(li ? (n.ordered_list?.create(null, li) ?? null) : null, 2);
172
+ }
173
+ case "quote":
174
+ return replaceWith(n.blockquote?.create(null, n.paragraph?.create()) ?? null, 2);
175
+ case "code":
176
+ return replaceWith(n.code_block?.create() ?? null, 1);
177
+ case "divider": {
178
+ const hr = n.hr ?? n.horizontal_rule;
179
+ if (!hr) return false;
180
+ if (!dispatch) return true;
181
+ // A divider is atomic: insert it, then add a trailing empty paragraph so
182
+ // the caret has somewhere to land below the rule.
183
+ const node = hr.create();
184
+ tr.replaceRangeWith(from, to, node);
185
+ const para = n.paragraph?.create();
186
+ if (para) tr.insert(from + node.nodeSize, para);
187
+ const caret = Math.min(from + node.nodeSize + 1, tr.doc.content.size);
188
+ tr.setSelection(TextSelection.near(tr.doc.resolve(caret), 1)).scrollIntoView();
189
+ dispatch(tr);
190
+ return true;
191
+ }
192
+ default:
193
+ return false;
194
+ }
195
+ };
196
+ }
197
+
198
+ /** Seed body for a fresh ```calc fence — a tiny ledger so highlight + inlays show at once. */
199
+ export const CALC_FENCE_SEED = ["items = 3", "price = 4.50", "total = items * price"].join("\n");
200
+
201
+ /**
202
+ * Insert a ```calc fence (a `code_block` with `language: "calc"`) seeded with a
203
+ * short example, then drop the caret into the body. The editor's calc layer
204
+ * (`calc-editor-prose`) highlights + adds result inlays once `calc` hooks are wired;
205
+ * with none it is just a fenced code block (and renders as a `CalcBlock` in preview
206
+ * when `evaluate` is supplied) — so inserting one is always safe.
207
+ */
208
+ export function insertCalcFence(
209
+ schema: Schema,
210
+ range: SlashRange | null,
211
+ seed: string = CALC_FENCE_SEED,
212
+ ): Command {
213
+ return (state, dispatch) => {
214
+ const codeBlock = schema.nodes.code_block;
215
+ if (!codeBlock) return false;
216
+ const node = seed
217
+ ? codeBlock.create({ language: "calc" }, schema.text(seed))
218
+ : codeBlock.create({ language: "calc" });
219
+ if (!node) return false;
220
+ if (!dispatch) return true;
221
+
222
+ const { from, to } = range ?? { from: state.selection.from, to: state.selection.to };
223
+ const tr = state.tr.replaceRangeWith(from, to, node);
224
+ // Caret at the body start (from + 1 enters the code block's text).
225
+ const caret = Math.min(from + 1, tr.doc.content.size);
226
+ tr.setSelection(TextSelection.near(tr.doc.resolve(caret), 1)).scrollIntoView();
227
+ dispatch(tr);
228
+ return true;
229
+ };
230
+ }
231
+
232
+ /** Milkdown-aware adapter for {@link insertCalcFence}. */
233
+ export function resolveCalcInsert(ctx: Ctx, range: SlashRange | null): boolean {
234
+ const view = ctx.get(editorViewCtx);
235
+ const command = insertCalcFence(view.state.schema, range);
236
+ return command(view.state, view.dispatch.bind(view));
237
+ }
238
+
239
+ /**
240
+ * Milkdown-aware adapter: resolve the directive NodeTypes from the editor `Ctx`
241
+ * and run {@link insertBrandDirective} against the live view. Used by the slash
242
+ * plugin's command runners.
243
+ */
244
+ export function resolveBrandInsert(
245
+ ctx: Ctx,
246
+ name: InsertableDirective,
247
+ range: SlashRange | null,
248
+ ): boolean {
249
+ const view = ctx.get(editorViewCtx);
250
+ const containerType = containerDirectiveSchema.type(ctx);
251
+ const leafType = leafDirectiveSchema.type(ctx);
252
+ const schema = view.state.schema;
253
+ const command = insertBrandDirective(name, containerType, leafType, schema, range);
254
+ return command(view.state, view.dispatch.bind(view));
255
+ }
256
+
257
+ /** Milkdown-aware adapter for the basic native blocks. */
258
+ export function resolveBasicInsert(ctx: Ctx, id: BasicBlockId, range: SlashRange | null): boolean {
259
+ const view = ctx.get(editorViewCtx);
260
+ const command = insertBasicBlock(id, view.state.schema, range);
261
+ return command(view.state, view.dispatch.bind(view));
262
+ }
263
+
264
+ /**
265
+ * Parse a FULLY-BOUND directive markdown string (e.g. from
266
+ * `serializeIterationDirective`) and insert it at `range` (or the caret when
267
+ * `range` is null) — the insertion counterpart to the node-view's
268
+ * `writeBodyMarkdown` edit-in-place. Used by {@link resolveGuidedIterationInsert}
269
+ * (#223). Never throws; returns `false` when the markdown fails to parse.
270
+ */
271
+ export function insertParsedMarkdown(
272
+ ctx: Ctx,
273
+ markdown: string,
274
+ range: SlashRange | null,
275
+ ): boolean {
276
+ try {
277
+ const view = ctx.get(editorViewCtx);
278
+ const parse = (ctx as { get: (t: unknown) => (md: string) => ProseNode | null }).get(parserCtx);
279
+ const parsed = parse(markdown);
280
+ if (!parsed) return false;
281
+
282
+ const { from, to } = range ?? { from: view.state.selection.from, to: view.state.selection.to };
283
+ const tr = view.state.tr.replaceWith(from, to, parsed.content);
284
+ const caret = Math.min(from + 2, tr.doc.content.size);
285
+ tr.setSelection(TextSelection.near(tr.doc.resolve(caret), 1)).scrollIntoView();
286
+ view.dispatch(tr);
287
+ return true;
288
+ } catch {
289
+ // Parser unavailable / stale range — leave the document untouched.
290
+ return false;
291
+ }
292
+ }
293
+
294
+ /**
295
+ * The GUIDED `/iterate` `/pivot` insert path (#223): instead of inserting a
296
+ * bare directive immediately, emit an edit REQUEST — seeded blank
297
+ * (`emptyBuilderValue`) — through the consumer's `IterationEditContext`
298
+ * handler, opening whichever modal it wires up (the node-view `⋯` re-edit's
299
+ * exact mechanism, reused for a fresh insert). BOTH writers are supplied so
300
+ * this works with either provider:
301
+ * - `onSaveData` (the guided `IterationBuilderProvider`) — inserts the
302
+ * fully-bound directive (value lists + layout + template).
303
+ * - `onSave` (the template-only `IterationTemplateProvider`) — inserts a
304
+ * directive built from DEFAULT (empty) attributes + the edited template.
305
+ * Nothing is INSERTED until the modal actually saves, so Cancel leaves no
306
+ * directive behind — unlike the direct-insert fallback, which always drops a
307
+ * placeholder template immediately. The typed `/query` trigger text itself is
308
+ * consumed up front by the caller (`runSlashCommand` in `brand-slash-plugin.ts`,
309
+ * BEFORE this resolver runs) — that's why `range` arrives as `null` here: by
310
+ * the time this fires, the range has already been deleted and the caret sits
311
+ * where the query used to start, so a save inserts there and a Cancel leaves a
312
+ * clean caret rather than the literal `/query` text.
313
+ */
314
+ export function resolveGuidedIterationInsert(
315
+ ctx: Ctx,
316
+ kind: "iterate" | "pivot",
317
+ range: SlashRange | null,
318
+ handler: IterationEditHandler,
319
+ ): void {
320
+ const seed = emptyBuilderValue(kind);
321
+ handler({
322
+ kind,
323
+ template: seed.template,
324
+ attributes: {},
325
+ onSave: (template) => {
326
+ const value = builderValueFromParts(kind, {}, template);
327
+ insertParsedMarkdown(ctx, serializeIterationDirective(value), range);
328
+ },
329
+ onSaveData: ({ attributes, template }) => {
330
+ const value = builderValueFromParts(kind, attributes, template);
331
+ insertParsedMarkdown(ctx, serializeIterationDirective(value), range);
332
+ },
333
+ });
334
+ }
335
+
336
+ /** Exported for the unit test: the per-directive default attributes. */
337
+ export const __DIRECTIVE_DEFAULTS = DIRECTIVE_DEFAULTS;
338
+
339
+ /** Re-export for callers that need to know which names are containers vs leaves. */
340
+ export function isContainerDirective(name: string): boolean {
341
+ return CONTAINER_DIRECTIVES.has(name);
342
+ }
343
+ export function isLeafDirective(name: string): boolean {
344
+ return LEAF_DIRECTIVES.has(name);
345
+ }