@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,56 @@
1
+ /**
2
+ * Vendored + adapted from `@milkdown/react` (MIT — © 2020-present Mirone).
3
+ *
4
+ * The actual ProseMirror mount/destroy lifecycle. This is the file that matters
5
+ * for React 19 StrictMode: the effect kicks off an async `editor.create()` on
6
+ * mount and `editor.destroy()` on cleanup. StrictMode runs mount → cleanup →
7
+ * mount in dev, so a destroy can fire while the first create is still pending —
8
+ * which `@milkdown/core`'s Editor guards (it defers destroy until OnCreate
9
+ * settles), so the double-mount converges to a single editor. The co-located
10
+ * `markdown-editor.strictmode.test.tsx` is the gate that proves that convergence
11
+ * for our vendored copy; harden here only if that test ever shows >1 surface.
12
+ */
13
+ import { createContext, useContext, useEffect, useRef } from "react";
14
+
15
+ import type { EditorInfoCtx } from "./types";
16
+
17
+ export const editorInfoContext = createContext<EditorInfoCtx>({} as EditorInfoCtx);
18
+
19
+ export function useGetEditor() {
20
+ const {
21
+ dom,
22
+ editor: editorRef,
23
+ setLoading,
24
+ editorFactory: getEditor,
25
+ } = useContext(editorInfoContext);
26
+ const domRef = useRef<HTMLDivElement>(null);
27
+
28
+ useEffect(() => {
29
+ const div = domRef.current;
30
+
31
+ if (!getEditor) return;
32
+ if (!div) return;
33
+
34
+ dom.current = div;
35
+
36
+ const editor = getEditor(div);
37
+ if (!editor) return;
38
+
39
+ setLoading(true);
40
+ editor
41
+ .create()
42
+ .then((editor) => {
43
+ editorRef.current = editor;
44
+ })
45
+ .finally(() => {
46
+ setLoading(false);
47
+ })
48
+ .catch(console.error);
49
+
50
+ return () => {
51
+ editor.destroy().catch(console.error);
52
+ };
53
+ }, [dom, editorRef, getEditor, setLoading]);
54
+
55
+ return domRef;
56
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Vendored + adapted from `@milkdown/react` (MIT — © 2020-present Mirone).
3
+ *
4
+ * `useInstance()` reads the live editor instance from context. Returns
5
+ * `[loading, getInstance]`; only call `getInstance()` once `loading` is false.
6
+ */
7
+ import type { Editor } from "@milkdown/kit/core";
8
+
9
+ import { useCallback, useContext } from "react";
10
+
11
+ import { editorInfoContext } from "./use-get-editor";
12
+
13
+ export type Instance = [true, () => undefined] | [false, () => Editor];
14
+
15
+ export function useInstance() {
16
+ const editorInfo = useContext(editorInfoContext);
17
+
18
+ const getInstance = useCallback(() => {
19
+ return editorInfo.editor.current;
20
+ }, [editorInfo.editor]);
21
+
22
+ return [editorInfo.loading, getInstance] as Instance;
23
+ }
@@ -0,0 +1,355 @@
1
+ /**
2
+ * paste-embed — ProseMirror plugin + helper for image paste/drop embedding.
3
+ *
4
+ * When `onEmbedAsset` is provided by the host:
5
+ * 1. Intercepts paste (from clipboard) or drop events carrying image files.
6
+ * 2. Inserts an inline "uploading…" widget Decoration at the caret/drop position.
7
+ * 3. Calls `onEmbedAsset(file)` and on resolve inserts a real image node, removing
8
+ * the placeholder.
9
+ * 4. On reject removes the placeholder and renders a transient inline error
10
+ * decoration (role="alert", auto-dismissed after 4 s) + toast.error().
11
+ *
12
+ * The plugin is a raw `$prose` ProseMirror plugin (same pattern as the slash menu),
13
+ * so it pulls zero new dependencies beyond what Milkdown already provides.
14
+ *
15
+ * A11y:
16
+ * - Upload placeholder: role="status" aria-live="polite"
17
+ * - Error decoration: role="alert" (assertive; fires once and auto-dismisses)
18
+ *
19
+ * Motion: gated via `duration-normal ease-standard motion-reduce:transition-none`
20
+ * token utilities (defined in @elabs-ai/components-tokens).
21
+ */
22
+
23
+ import type { MilkdownPlugin } from "@milkdown/kit/ctx";
24
+ import { $prose } from "@milkdown/kit/utils";
25
+ import { Plugin, PluginKey, type Transaction } from "@milkdown/kit/prose/state";
26
+ import { Decoration, DecorationSet } from "@milkdown/kit/prose/view";
27
+ import type { EditorView } from "@milkdown/kit/prose/view";
28
+ import { toast } from "@elabs-ai/components-ui";
29
+
30
+ /** The host-provided hook: receives a File, resolves to a URL/path string. */
31
+ export type EmbedAssetFn = (file: File) => Promise<string>;
32
+
33
+ /** A unique key for each in-flight upload. */
34
+ type UploadId = string;
35
+
36
+ function uniqueId(): UploadId {
37
+ return `embed-${Math.random().toString(36).slice(2)}`;
38
+ }
39
+
40
+ /** Meta tags for the plugin's own transactions. */
41
+ interface EmbedMetaAdd {
42
+ type: "add";
43
+ id: UploadId;
44
+ pos: number;
45
+ filename: string;
46
+ }
47
+ interface EmbedMetaRemove {
48
+ type: "remove";
49
+ id: UploadId;
50
+ }
51
+ interface EmbedMetaError {
52
+ type: "error";
53
+ id: UploadId;
54
+ /** Position at which the error chip should appear. */
55
+ pos: number;
56
+ message: string;
57
+ }
58
+ interface EmbedMetaClearError {
59
+ type: "clear-error";
60
+ id: UploadId;
61
+ }
62
+ type EmbedMeta = EmbedMetaAdd | EmbedMetaRemove | EmbedMetaError | EmbedMetaClearError;
63
+
64
+ /** Runtime state: active upload placeholders + transient error chips. */
65
+ interface EmbedState {
66
+ /** Decoration set that maps through doc changes. */
67
+ decos: DecorationSet;
68
+ /** Map from upload id → decoration for fast lookup / removal. */
69
+ byId: Map<UploadId, Decoration>;
70
+ /** Set of error ids currently showing. */
71
+ errorIds: Set<UploadId>;
72
+ }
73
+
74
+ const embedPluginKey = new PluginKey<EmbedState>("brand-embed");
75
+
76
+ /** DOM element for the uploading placeholder chip. */
77
+ function makePlaceholderChip(filename: string): HTMLElement {
78
+ const chip = document.createElement("span");
79
+ chip.setAttribute("role", "status");
80
+ chip.setAttribute("aria-live", "polite");
81
+ chip.setAttribute("aria-label", `Uploading ${filename}`);
82
+ chip.setAttribute("title", `Uploading ${filename}…`);
83
+ chip.className =
84
+ "inline-flex items-center gap-1 rounded px-2 py-0.5 text-caption bg-muted text-muted-foreground " +
85
+ "border border-border select-none transition-opacity duration-normal ease-standard motion-reduce:transition-none";
86
+
87
+ // SVG spinner (token-colored, no Spinner component needed here — widget is a raw DOM node)
88
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
89
+ svg.setAttribute("viewBox", "0 0 24 24");
90
+ svg.setAttribute("fill", "none");
91
+ svg.setAttribute("aria-hidden", "true");
92
+ svg.style.cssText = "width:0.85em;height:0.85em;animation:spin 1s linear infinite;flex-shrink:0;";
93
+ const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
94
+ circle.setAttribute("cx", "12");
95
+ circle.setAttribute("cy", "12");
96
+ circle.setAttribute("r", "9");
97
+ circle.setAttribute("stroke", "currentColor");
98
+ circle.setAttribute("stroke-width", "2.5");
99
+ circle.setAttribute("stroke-dasharray", "56.5");
100
+ circle.setAttribute("stroke-dashoffset", "42");
101
+ circle.setAttribute("stroke-linecap", "round");
102
+ svg.appendChild(circle);
103
+ chip.appendChild(svg);
104
+
105
+ // Ensure the keyframe is injected once per document.
106
+ if (!chip.ownerDocument.head.querySelector("#embed-spin-keyframe")) {
107
+ const style = chip.ownerDocument.createElement("style");
108
+ style.id = "embed-spin-keyframe";
109
+ style.textContent = "@keyframes spin{to{transform:rotate(360deg)}}";
110
+ chip.ownerDocument.head.appendChild(style);
111
+ }
112
+
113
+ const label = document.createElement("span");
114
+ label.textContent = `${filename.length > 24 ? filename.slice(0, 22) + "…" : filename} Uploading…`;
115
+ chip.appendChild(label);
116
+
117
+ return chip;
118
+ }
119
+
120
+ /** DOM element for the error chip (transient, auto-dismissing). */
121
+ function makeErrorChip(message: string): HTMLElement {
122
+ const chip = document.createElement("span");
123
+ chip.setAttribute("role", "alert");
124
+ chip.setAttribute("aria-live", "assertive");
125
+ chip.setAttribute("title", message);
126
+ chip.className =
127
+ "inline-flex items-center gap-1 rounded px-2 py-0.5 text-caption bg-destructive/10 text-destructive " +
128
+ "border border-destructive/30 select-none";
129
+
130
+ const label = document.createElement("span");
131
+ label.textContent = `⚠ Upload failed`;
132
+ chip.appendChild(label);
133
+ return chip;
134
+ }
135
+
136
+ /**
137
+ * Core embed helper — exported so tests can call it directly against a live
138
+ * ProseMirror view without synthesizing ClipboardEvent/DragEvent.
139
+ */
140
+ export function embedAsset(
141
+ view: EditorView,
142
+ file: File,
143
+ pos: number,
144
+ onEmbedAsset: EmbedAssetFn,
145
+ onError?: (msg: string) => void,
146
+ ): void {
147
+ const id = uniqueId();
148
+
149
+ // 1. Insert the uploading placeholder widget.
150
+ const addMeta: EmbedMeta = { type: "add", id, pos, filename: file.name };
151
+ view.dispatch(view.state.tr.setMeta(embedPluginKey, addMeta));
152
+
153
+ // 2. Call the host hook.
154
+ onEmbedAsset(file).then(
155
+ (path) => {
156
+ // 3a. Resolve: find the placeholder's current mapped position.
157
+ const pluginState = embedPluginKey.getState(view.state);
158
+ const deco = pluginState?.byId.get(id);
159
+
160
+ // Remove the placeholder decoration.
161
+ const removeMeta: EmbedMeta = { type: "remove", id };
162
+ const tr: Transaction = view.state.tr.setMeta(embedPluginKey, removeMeta);
163
+
164
+ if (deco) {
165
+ // Insert the image node at the placeholder's current mapped position.
166
+ const decoPos = (deco as unknown as { from: number }).from;
167
+ const imageNode = view.state.schema.nodes.image?.create({
168
+ src: path,
169
+ alt: file.name.replace(/\.[^.]+$/, ""),
170
+ title: null,
171
+ });
172
+ if (imageNode) {
173
+ // We need to insert the image: insert then dispatch remove separately.
174
+ // Insert first so the position is relative to the current doc.
175
+ const insertTr = view.state.tr.insert(decoPos, imageNode);
176
+ view.dispatch(insertTr);
177
+ // Now remove the deco (after the doc has shifted).
178
+ const removeTr = view.state.tr.setMeta(embedPluginKey, removeMeta);
179
+ view.dispatch(removeTr);
180
+ return;
181
+ }
182
+ }
183
+ // Fallback: just remove the placeholder.
184
+ view.dispatch(tr);
185
+ },
186
+ (err) => {
187
+ // 3b. Reject: find the placeholder position, show error.
188
+ const pluginState = embedPluginKey.getState(view.state);
189
+ const deco = pluginState?.byId.get(id);
190
+ const errPos = deco ? (deco as unknown as { from: number }).from : pos;
191
+ const message = err instanceof Error ? err.message : "Upload failed";
192
+
193
+ // Remove placeholder + add error chip.
194
+ const errorMeta: EmbedMeta = { type: "error", id, pos: errPos, message };
195
+ view.dispatch(view.state.tr.setMeta(embedPluginKey, errorMeta));
196
+
197
+ // Surface the error via toast (additional signal; not the only cue).
198
+ toast.error(`Upload failed: ${message}`);
199
+ onError?.(message);
200
+
201
+ // Auto-dismiss the error chip after 4 s.
202
+ setTimeout(() => {
203
+ if (view.isDestroyed) return;
204
+ const clearMeta: EmbedMeta = { type: "clear-error", id };
205
+ view.dispatch(view.state.tr.setMeta(embedPluginKey, clearMeta));
206
+ }, 4000);
207
+ },
208
+ );
209
+ }
210
+
211
+ /** Read image Files from a FileList (or null). */
212
+ function imageFiles(list: FileList | null | undefined): File[] {
213
+ if (!list) return [];
214
+ const files: File[] = [];
215
+ for (let i = 0; i < list.length; i++) {
216
+ const f = list[i];
217
+ if (f && f.type.startsWith("image/")) files.push(f);
218
+ }
219
+ return files;
220
+ }
221
+
222
+ /**
223
+ * Build the paste-embed Milkdown plugin. Pass the `onEmbedAsset` hook from the
224
+ * host. When `onEmbedAsset` is undefined the plugin is still registered but never
225
+ * intercepts events — normal ProseMirror behavior is preserved.
226
+ */
227
+ export function pasteEmbedPlugin(onEmbedAsset?: EmbedAssetFn): MilkdownPlugin {
228
+ return $prose(() => {
229
+ return new Plugin<EmbedState>({
230
+ key: embedPluginKey,
231
+ state: {
232
+ init: () => ({
233
+ decos: DecorationSet.empty,
234
+ byId: new Map(),
235
+ errorIds: new Set(),
236
+ }),
237
+ apply: (tr, prev, _oldState, newState) => {
238
+ // Map existing decorations through the transaction.
239
+ let decos = prev.decos.map(tr.mapping, tr.doc);
240
+ const byId = new Map(prev.byId);
241
+ const errorIds = new Set(prev.errorIds);
242
+
243
+ // Also map the positions stored on each deco (they're widget decorations
244
+ // whose .from tracks through the mapping automatically via DecorationSet.map).
245
+ // We just need to keep byId in sync: after mapping, find each id's deco.
246
+ for (const [id, oldDeco] of prev.byId.entries()) {
247
+ // Find the mapped deco in the new set (same key).
248
+ const key = (oldDeco.spec as { key?: string }).key;
249
+ if (key) {
250
+ // Re-fetch from the mapped set so .from is up to date.
251
+ const found = decos.find(undefined, undefined, (spec) => spec.key === key);
252
+ if (found.length > 0 && found[0]) {
253
+ byId.set(id, found[0]);
254
+ } else {
255
+ byId.delete(id);
256
+ }
257
+ }
258
+ }
259
+
260
+ const meta = tr.getMeta(embedPluginKey) as EmbedMeta | undefined;
261
+ if (!meta) return { decos, byId, errorIds };
262
+
263
+ switch (meta.type) {
264
+ case "add": {
265
+ const chip = makePlaceholderChip(meta.filename);
266
+ const deco = Decoration.widget(meta.pos, chip, {
267
+ key: `embed-placeholder:${meta.id}`,
268
+ side: -1,
269
+ });
270
+ decos = decos.add(newState.doc, [deco]);
271
+ byId.set(meta.id, deco);
272
+ break;
273
+ }
274
+ case "remove": {
275
+ const key = `embed-placeholder:${meta.id}`;
276
+ const toRemove = decos.find(undefined, undefined, (spec) => spec.key === key);
277
+ if (toRemove.length > 0) {
278
+ decos = decos.remove(toRemove);
279
+ }
280
+ byId.delete(meta.id);
281
+ errorIds.delete(meta.id);
282
+ break;
283
+ }
284
+ case "error": {
285
+ // Remove the placeholder chip.
286
+ const placeholderKey = `embed-placeholder:${meta.id}`;
287
+ const placeholders = decos.find(
288
+ undefined,
289
+ undefined,
290
+ (spec) => spec.key === placeholderKey,
291
+ );
292
+ if (placeholders.length > 0) {
293
+ decos = decos.remove(placeholders);
294
+ }
295
+ byId.delete(meta.id);
296
+
297
+ // Add the error chip.
298
+ const chip = makeErrorChip(meta.message);
299
+ const errorKey = `embed-error:${meta.id}`;
300
+ const deco = Decoration.widget(meta.pos, chip, { key: errorKey, side: 1 });
301
+ decos = decos.add(newState.doc, [deco]);
302
+ errorIds.add(meta.id);
303
+ break;
304
+ }
305
+ case "clear-error": {
306
+ const errorKey = `embed-error:${meta.id}`;
307
+ const toRemove = decos.find(undefined, undefined, (spec) => spec.key === errorKey);
308
+ if (toRemove.length > 0) {
309
+ decos = decos.remove(toRemove);
310
+ }
311
+ errorIds.delete(meta.id);
312
+ break;
313
+ }
314
+ }
315
+
316
+ return { decos, byId, errorIds };
317
+ },
318
+ },
319
+ props: {
320
+ decorations: (state) => embedPluginKey.getState(state)?.decos ?? DecorationSet.empty,
321
+
322
+ handlePaste: (view, event) => {
323
+ if (!onEmbedAsset) return false;
324
+ const files = imageFiles(event.clipboardData?.files);
325
+ if (files.length === 0) return false;
326
+
327
+ event.preventDefault();
328
+ const pos = view.state.selection.from;
329
+ for (const file of files) {
330
+ embedAsset(view, file, pos, onEmbedAsset);
331
+ }
332
+ return true;
333
+ },
334
+
335
+ handleDrop: (view, event) => {
336
+ if (!onEmbedAsset) return false;
337
+ const files = imageFiles((event as DragEvent).dataTransfer?.files);
338
+ if (files.length === 0) return false;
339
+
340
+ event.preventDefault();
341
+ // Compute drop position from coordinates.
342
+ const coords = view.posAtCoords({
343
+ left: (event as DragEvent).clientX,
344
+ top: (event as DragEvent).clientY,
345
+ });
346
+ const pos = coords?.pos ?? view.state.selection.from;
347
+ for (const file of files) {
348
+ embedAsset(view, file, pos, onEmbedAsset);
349
+ }
350
+ return true;
351
+ },
352
+ },
353
+ });
354
+ }) as unknown as MilkdownPlugin;
355
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Unit tests for the slash-command registry — pure data: shape, grouping, and the
3
+ * query filter. (The `run` insertion path is covered against a real editor in
4
+ * insert-directive.test.ts.)
5
+ */
6
+ import type { Ctx } from "@milkdown/kit/ctx";
7
+ import { describe, expect, test } from "vitest";
8
+
9
+ import type {
10
+ IterationEditHandler,
11
+ IterationEditRequest,
12
+ } from "../../markdown-iteration/edit-context";
13
+ import {
14
+ BRAND_SLASH_COMMANDS,
15
+ filterSlashCommands,
16
+ groupSlashCommands,
17
+ } from "./brand-slash-commands";
18
+
19
+ describe("BRAND_SLASH_COMMANDS shape", () => {
20
+ test("every command has a unique id, a label, a group, and a run", () => {
21
+ const ids = new Set<string>();
22
+ for (const c of BRAND_SLASH_COMMANDS) {
23
+ expect(typeof c.id).toBe("string");
24
+ expect(c.id.length).toBeGreaterThan(0);
25
+ expect(ids.has(c.id)).toBe(false);
26
+ ids.add(c.id);
27
+ expect(typeof c.label).toBe("string");
28
+ expect(c.label.length).toBeGreaterThan(0);
29
+ expect(typeof c.group).toBe("string");
30
+ expect(typeof c.run).toBe("function");
31
+ }
32
+ });
33
+
34
+ test("covers the four brand directives + the basic blocks", () => {
35
+ const labels = BRAND_SLASH_COMMANDS.map((c) => c.label);
36
+ expect(labels).toEqual(expect.arrayContaining(["Card", "Callout", "Metric", "Timeline"]));
37
+ expect(labels).toEqual(
38
+ expect.arrayContaining([
39
+ "Heading",
40
+ "Bullet list",
41
+ "Numbered list",
42
+ "Quote",
43
+ "Code block",
44
+ "Divider",
45
+ ]),
46
+ );
47
+ });
48
+
49
+ test("brand blocks are in the 'Brand blocks' group, basics in 'Basic'", () => {
50
+ const brand = BRAND_SLASH_COMMANDS.filter((c) => c.group === "Brand blocks");
51
+ const basic = BRAND_SLASH_COMMANDS.filter((c) => c.group === "Basic");
52
+ expect(brand.map((c) => c.label)).toEqual([
53
+ "Card",
54
+ "Callout",
55
+ "Metric",
56
+ "Timeline",
57
+ "Iterate",
58
+ "Pivot",
59
+ "Calc",
60
+ ]);
61
+ expect(basic.length).toBe(6);
62
+ });
63
+
64
+ test("the /calc command (#220) is registered and inserts a calc fence", () => {
65
+ const calc = BRAND_SLASH_COMMANDS.find((c) => c.id === "brand-calc");
66
+ expect(calc).toBeDefined();
67
+ expect(calc?.label).toBe("Calc");
68
+ expect(calc?.keywords).toEqual(expect.arrayContaining(["calc", "math", "formula"]));
69
+ });
70
+ });
71
+
72
+ describe("filterSlashCommands", () => {
73
+ test("an empty query returns the full list in order", () => {
74
+ expect(filterSlashCommands(BRAND_SLASH_COMMANDS, "")).toBe(BRAND_SLASH_COMMANDS);
75
+ expect(filterSlashCommands(BRAND_SLASH_COMMANDS, " ")).toBe(BRAND_SLASH_COMMANDS);
76
+ });
77
+
78
+ test("matches on the label (case-insensitive substring)", () => {
79
+ const r = filterSlashCommands(BRAND_SLASH_COMMANDS, "ca");
80
+ const labels = r.map((c) => c.label);
81
+ // "ca" matches Card + Callout (label) and others via keywords.
82
+ expect(labels).toEqual(expect.arrayContaining(["Card", "Callout"]));
83
+ });
84
+
85
+ test("matches on keywords too (e.g. 'kpi' → Metric)", () => {
86
+ const r = filterSlashCommands(BRAND_SLASH_COMMANDS, "kpi");
87
+ expect(r.map((c) => c.label)).toContain("Metric");
88
+ });
89
+
90
+ test("matches 'alert' → Callout via keyword", () => {
91
+ const r = filterSlashCommands(BRAND_SLASH_COMMANDS, "alert");
92
+ expect(r.map((c) => c.label)).toContain("Callout");
93
+ });
94
+
95
+ test("a no-match query returns an empty list", () => {
96
+ expect(filterSlashCommands(BRAND_SLASH_COMMANDS, "zzzznotacommand")).toHaveLength(0);
97
+ });
98
+ });
99
+
100
+ describe("groupSlashCommands", () => {
101
+ test("preserves first-seen group order and keeps every command", () => {
102
+ const grouped = groupSlashCommands(BRAND_SLASH_COMMANDS);
103
+ expect(grouped.map((g) => g.group)).toEqual(["Brand blocks", "Basic"]);
104
+ const total = grouped.reduce((n, g) => n + g.commands.length, 0);
105
+ expect(total).toBe(BRAND_SLASH_COMMANDS.length);
106
+ });
107
+
108
+ test("a filtered list groups correctly (only matching groups appear)", () => {
109
+ const filtered = filterSlashCommands(BRAND_SLASH_COMMANDS, "card");
110
+ const grouped = groupSlashCommands(filtered);
111
+ // "card" only matches a brand block → only the Brand blocks group shows.
112
+ expect(grouped.map((g) => g.group)).toEqual(["Brand blocks"]);
113
+ });
114
+ });
115
+
116
+ describe("source-mode snippets (A4 — source/split Insert-menu parity)", () => {
117
+ test("every Brand block carries a markdown snippet; basics carry none", () => {
118
+ for (const c of BRAND_SLASH_COMMANDS) {
119
+ if (c.group === "Brand blocks") {
120
+ expect(typeof c.snippet).toBe("string");
121
+ expect((c.snippet ?? "").length).toBeGreaterThan(0);
122
+ } else {
123
+ expect(c.snippet).toBeUndefined();
124
+ }
125
+ }
126
+ });
127
+
128
+ test("each snippet carries the matching directive / fence marker", () => {
129
+ const byId = new Map(BRAND_SLASH_COMMANDS.map((c) => [c.id, c.snippet ?? ""]));
130
+ expect(byId.get("brand-card")).toContain(":::card");
131
+ expect(byId.get("brand-callout")).toContain(":::callout");
132
+ expect(byId.get("brand-metric")).toContain("::metric");
133
+ expect(byId.get("brand-timeline")).toContain(":::timeline");
134
+ expect(byId.get("brand-iterate")).toContain(":::iterate");
135
+ expect(byId.get("brand-pivot")).toContain(":::pivot");
136
+ expect(byId.get("brand-calc")).toContain("```calc");
137
+ });
138
+
139
+ test("iterate / pivot snippets seed a template token so the block isn't empty", () => {
140
+ const byId = new Map(BRAND_SLASH_COMMANDS.map((c) => [c.id, c.snippet ?? ""]));
141
+ expect(byId.get("brand-iterate")).toContain("{{item.name}}");
142
+ expect(byId.get("brand-pivot")).toContain("{{cell}}");
143
+ });
144
+ });
145
+
146
+ describe("guided commands (#223 — /iterate and /pivot open the guided modal when wired)", () => {
147
+ test("only iterate and pivot carry a `guided` variant", () => {
148
+ const byId = new Map(BRAND_SLASH_COMMANDS.map((c) => [c.id, c]));
149
+ expect(typeof byId.get("brand-iterate")?.guided).toBe("function");
150
+ expect(typeof byId.get("brand-pivot")?.guided).toBe("function");
151
+ for (const id of [
152
+ "brand-card",
153
+ "brand-callout",
154
+ "brand-metric",
155
+ "brand-timeline",
156
+ "brand-calc",
157
+ ]) {
158
+ expect(byId.get(id)?.guided).toBeUndefined();
159
+ }
160
+ });
161
+
162
+ test("guided path: iterate emits an edit request seeded BLANK (no values, default template) — not a bare insert", () => {
163
+ const iterate = BRAND_SLASH_COMMANDS.find((c) => c.id === "brand-iterate")!;
164
+ let captured: IterationEditRequest | undefined;
165
+ const handler: IterationEditHandler = (request) => {
166
+ captured = request;
167
+ };
168
+ iterate.guided!({ ctx: {} as Ctx, range: null }, handler);
169
+ expect(captured?.kind).toBe("iterate");
170
+ expect(captured?.attributes).toEqual({});
171
+ expect(captured?.template).toBe("{{item.name}}");
172
+ expect(typeof captured?.onSave).toBe("function");
173
+ expect(typeof captured?.onSaveData).toBe("function");
174
+ });
175
+
176
+ test("guided path: pivot emits an edit request seeded with the pivot default template", () => {
177
+ const pivot = BRAND_SLASH_COMMANDS.find((c) => c.id === "brand-pivot")!;
178
+ let captured: IterationEditRequest | undefined;
179
+ pivot.guided!({ ctx: {} as Ctx, range: null }, (request) => {
180
+ captured = request;
181
+ });
182
+ expect(captured?.kind).toBe("pivot");
183
+ expect(captured?.attributes).toEqual({});
184
+ expect(captured?.template).toBe("{{row}} · {{col}}");
185
+ });
186
+
187
+ test("fallback path: without a handler, `run` still inserts the bare directive directly (unchanged)", () => {
188
+ // Covered end-to-end (against a real ProseMirror doc) in insert-directive.test.ts;
189
+ // this only asserts the command still exposes the plain `run` unconditionally, so
190
+ // a consumer with no IterationEditContext wired keeps today's behaviour.
191
+ const iterate = BRAND_SLASH_COMMANDS.find((c) => c.id === "brand-iterate")!;
192
+ expect(typeof iterate.run).toBe("function");
193
+ });
194
+ });