@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,46 @@
1
+ /**
2
+ * Typed-`/` trigger detection for the Monaco source pane (the conflict-free way
3
+ * to open the brand block menu — no keybinding, mirrors the WYSIWYG `/` trigger).
4
+ *
5
+ * Pure + Monaco-free so it unit-tests without the engine. The workspace feeds it
6
+ * the just-edited line + the column the `/` landed on (from a Monaco content
7
+ * change) and, when it's a valid trigger spot, gets back the model range of that
8
+ * `/` so the menu can replace it with the inserted block (or delete it on cancel).
9
+ *
10
+ * A `/` is a valid trigger only at a textblock start (column 1) or right after
11
+ * whitespace — so a `/` inside a word, a URL, or a path (`a/b`, `http://`) never
12
+ * hijacks typing, matching the WYSIWYG `triggerAllowed` rule.
13
+ */
14
+
15
+ /** A Monaco `IRange`-shaped span (kept structural so this module imports no monaco). */
16
+ export interface SlashTriggerRange {
17
+ startLineNumber: number;
18
+ startColumn: number;
19
+ endLineNumber: number;
20
+ endColumn: number;
21
+ }
22
+
23
+ /**
24
+ * Given the 1-based `line` number, the FULL line content AFTER the `/` was typed,
25
+ * and the 1-based `slashColumn` the `/` now occupies, return the range covering
26
+ * that `/` when it is a valid trigger — otherwise `null`.
27
+ */
28
+ export function slashTriggerRange(
29
+ line: number,
30
+ lineContent: string,
31
+ slashColumn: number,
32
+ ): SlashTriggerRange | null {
33
+ // Sanity: the character at the reported column must actually be the `/`.
34
+ if (lineContent.charAt(slashColumn - 1) !== "/") return null;
35
+ // Allowed at the very start of the line, or immediately after whitespace.
36
+ if (slashColumn > 1) {
37
+ const before = lineContent.charAt(slashColumn - 2);
38
+ if (!/\s/.test(before)) return null;
39
+ }
40
+ return {
41
+ startLineNumber: line,
42
+ startColumn: slashColumn,
43
+ endLineNumber: line,
44
+ endColumn: slashColumn + 1,
45
+ };
46
+ }
@@ -0,0 +1,249 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Table control overlay for the Milkdown WYSIWYG editor.
5
+ *
6
+ * Mechanism: a **contextual toolbar** rendered as a ProseMirror plugin view
7
+ * (via `@prosemirror-adapter/react`'s `usePluginViewFactory`). When the
8
+ * selection is inside a GFM table, a token-styled button bar appears at the
9
+ * bottom of the editor's wrapper offering add/remove row and column ops.
10
+ *
11
+ * Design choice — why NOT a `$view` node-view:
12
+ * A content-replacing `$view` for the GFM table conflicts with gfm's
13
+ * `columnResizingPlugin` and `tableEditingPlugin` decorations (both rewrite
14
+ * the DOM directly), breaking cell selection highlighting and column handles.
15
+ * The contextual toolbar avoids all decoration conflicts while delivering the
16
+ * full feature surface (add/remove row+col, Tab nav, lossless round-trip,
17
+ * token look, keyboard a11y). GFM's `tableKeymap` already binds Tab →
18
+ * NextCell and Shift+Tab → PrevCell so no extra keymap plugin is needed.
19
+ *
20
+ * Focus safety:
21
+ * Plugin views mount OUTSIDE `contentEditable` (ProseMirror appends them to
22
+ * the editor wrapper). Buttons use `onMouseDown={preventDefault}` so clicks
23
+ * never steal focus from the editing surface.
24
+ *
25
+ * Command wiring:
26
+ * All ops call the existing gfm commands (addRowAfterCommand, etc.) via
27
+ * `commandsCtx.call()`. The GFM schema + serializer are never replaced, so
28
+ * GFM round-trip fidelity is structurally guaranteed.
29
+ */
30
+
31
+ import type { CmdKey } from "@milkdown/kit/core";
32
+ import type { Ctx, MilkdownPlugin } from "@milkdown/kit/ctx";
33
+ import { commandsCtx } from "@milkdown/kit/core";
34
+ import {
35
+ addColAfterCommand,
36
+ addColBeforeCommand,
37
+ addRowAfterCommand,
38
+ addRowBeforeCommand,
39
+ deleteSelectedCellsCommand,
40
+ } from "@milkdown/kit/preset/gfm";
41
+ import { Plugin, PluginKey } from "@milkdown/kit/prose/state";
42
+ import type { EditorView } from "@milkdown/kit/prose/view";
43
+ import { $prose } from "@milkdown/kit/utils";
44
+ import { isInTable } from "@milkdown/kit/prose/tables";
45
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
46
+ import { usePluginViewContext } from "@prosemirror-adapter/react";
47
+ import type { usePluginViewFactory } from "@prosemirror-adapter/react";
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Ctx bridge
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * Per-instance Ctx registry. The `$prose` plugin's `view(editorView)` records
55
+ * THIS editor's live Milkdown `Ctx` keyed by its ProseMirror view; the React
56
+ * toolbar reads it back via the view it gets from `usePluginViewContext`. Keyed
57
+ * by the view (NOT a module singleton) so multiple `MarkdownEditor`s on one page
58
+ * — a Storybook autodocs page, a split workspace — each dispatch to the correct
59
+ * instance. Entries are released with their view (WeakMap GC).
60
+ */
61
+ const ctxByView = new WeakMap<EditorView, Ctx>();
62
+
63
+ /** Call a gfm command key on the editor that owns `view`. */
64
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
65
+ function dispatchCommand(view: EditorView, key: CmdKey<any>): void {
66
+ const ctx = ctxByView.get(view);
67
+ if (!ctx) return;
68
+ try {
69
+ ctx.get(commandsCtx).call(key);
70
+ } catch {
71
+ // Editor may be mid-destroy; swallow gracefully.
72
+ }
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Plugin key
77
+ // ---------------------------------------------------------------------------
78
+
79
+ const tableControlsKey = new PluginKey<boolean>("brand-table-controls");
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Toolbar React component
83
+ // ---------------------------------------------------------------------------
84
+
85
+ interface ToolbarButtonProps {
86
+ onClick: () => void;
87
+ ariaLabel: string;
88
+ title: string;
89
+ children: React.ReactNode;
90
+ variant?: "default" | "destructive";
91
+ }
92
+
93
+ function ToolbarButton({
94
+ onClick,
95
+ ariaLabel,
96
+ title,
97
+ children,
98
+ variant = "default",
99
+ }: ToolbarButtonProps) {
100
+ return (
101
+ <button
102
+ type="button"
103
+ aria-label={ariaLabel}
104
+ title={title}
105
+ // Keep focus in the editor — a mousedown without preventDefault would
106
+ // blur the ProseMirror view before the click fires.
107
+ onMouseDown={(e) => e.preventDefault()}
108
+ onClick={onClick}
109
+ className={cn(
110
+ "inline-flex h-7 items-center gap-1 rounded px-2 text-caption font-medium",
111
+ "border border-border-strong",
112
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
113
+ "transition-colors duration-fast ease-standard motion-reduce:transition-none",
114
+ variant === "destructive"
115
+ ? "bg-background text-destructive hover:bg-destructive/10"
116
+ : "bg-background text-foreground hover:bg-surface-muted",
117
+ )}
118
+ >
119
+ {children}
120
+ </button>
121
+ );
122
+ }
123
+
124
+ /** Thin visual separator between button groups. */
125
+ function ToolbarDivider() {
126
+ return <span aria-hidden="true" className="mx-0.5 h-4 w-px bg-border-strong" />;
127
+ }
128
+
129
+ /**
130
+ * The table controls toolbar.
131
+ *
132
+ * Rendered as a ProseMirror plugin view: ProseMirror mounts it into the
133
+ * editor's wrapper DOM (`view.dom.parentElement`) and calls `update()` on
134
+ * every state change — `usePluginViewContext` re-renders the React tree.
135
+ * Hidden when the cursor is not inside a table.
136
+ */
137
+ function TableControlsView() {
138
+ const { view } = usePluginViewContext();
139
+ const inTable = isInTable(view.state);
140
+ const run = (key: Parameters<typeof dispatchCommand>[1]) => () => dispatchCommand(view, key);
141
+
142
+ if (!inTable) return null;
143
+
144
+ return (
145
+ <div
146
+ role="toolbar"
147
+ aria-label="Table controls"
148
+ className={cn(
149
+ "flex flex-wrap items-center gap-1 px-2 py-1.5",
150
+ "border-t border-border-strong bg-surface-muted",
151
+ )}
152
+ >
153
+ {/* Row group */}
154
+ <span className="mr-1 select-none text-caption text-muted-foreground">Row</span>
155
+ <ToolbarButton
156
+ ariaLabel="Add row above"
157
+ title="Add row above"
158
+ onClick={run(addRowBeforeCommand.key)}
159
+ >
160
+ ↑+
161
+ </ToolbarButton>
162
+ <ToolbarButton
163
+ ariaLabel="Add row below"
164
+ title="Add row below"
165
+ onClick={run(addRowAfterCommand.key)}
166
+ >
167
+ ↓+
168
+ </ToolbarButton>
169
+ <ToolbarButton
170
+ ariaLabel="Delete row"
171
+ title="Delete row"
172
+ variant="destructive"
173
+ onClick={run(deleteSelectedCellsCommand.key)}
174
+ >
175
+ ×row
176
+ </ToolbarButton>
177
+
178
+ <ToolbarDivider />
179
+
180
+ {/* Column group */}
181
+ <span className="mr-1 select-none text-caption text-muted-foreground">Col</span>
182
+ <ToolbarButton
183
+ ariaLabel="Add column left"
184
+ title="Add column left"
185
+ onClick={run(addColBeforeCommand.key)}
186
+ >
187
+ ←+
188
+ </ToolbarButton>
189
+ <ToolbarButton
190
+ ariaLabel="Add column right"
191
+ title="Add column right"
192
+ onClick={run(addColAfterCommand.key)}
193
+ >
194
+ →+
195
+ </ToolbarButton>
196
+ <ToolbarButton
197
+ ariaLabel="Delete column"
198
+ title="Delete column"
199
+ variant="destructive"
200
+ onClick={run(deleteSelectedCellsCommand.key)}
201
+ >
202
+ ×col
203
+ </ToolbarButton>
204
+ </div>
205
+ );
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Exported factory
210
+ // ---------------------------------------------------------------------------
211
+
212
+ /**
213
+ * Build the Milkdown plugin array for table controls.
214
+ *
215
+ * Call with `usePluginViewFactory()` from inside `<ProsemirrorAdapterProvider>`,
216
+ * then `.use(tableViewPlugins(pluginViewFactory))` on the editor chain.
217
+ *
218
+ * The signature matches the `directiveViewPlugins` convention: factory
219
+ * captured from the React adapter hook, plugin array returned for `.use()`.
220
+ */
221
+ export function tableViewPlugins(
222
+ pluginViewFactory: ReturnType<typeof usePluginViewFactory>,
223
+ ): MilkdownPlugin[] {
224
+ return [
225
+ $prose((ctx) => {
226
+ // Build the ProseMirror PluginViewSpec from the adapter factory. This
227
+ // returns a `view(editorView) => PluginView` that ProseMirror calls to
228
+ // mount/update/destroy the React tree.
229
+ const makePluginView = pluginViewFactory({ component: TableControlsView });
230
+
231
+ return new Plugin({
232
+ key: tableControlsKey,
233
+ state: {
234
+ // Track whether the cursor is in a table (bool) as plugin state so
235
+ // ProseMirror knows when to trigger an `update()` on the plugin view.
236
+ init: (_cfg, state) => isInTable(state),
237
+ apply: (_tr, _prev, _old, state) => isInTable(state),
238
+ },
239
+ // Bind THIS editor's Ctx to THIS editor's view, so the toolbar's
240
+ // commands dispatch to the right instance when several editors share
241
+ // this module (autodocs page, split workspace).
242
+ view: (editorView) => {
243
+ ctxByView.set(editorView, ctx);
244
+ return makePluginView(editorView);
245
+ },
246
+ });
247
+ }) as unknown as MilkdownPlugin,
248
+ ];
249
+ }
@@ -0,0 +1,92 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The `:::iterate` / `:::pivot` directive bridge — turns a directive context
5
+ * (attributes + the captured raw body template) into an `IterationSpec`, and
6
+ * renders an `IterationBlock` with a recursion guard so a self-referential
7
+ * template can't loop forever (the transclusion depth-cap precedent).
8
+ */
9
+ import { createContext, useContext, type ReactNode } from "react";
10
+
11
+ import {
12
+ IterationBlock,
13
+ type EvaluateIteration,
14
+ type InterpolateTemplate,
15
+ type IterationLayout,
16
+ type IterationSpec,
17
+ } from "./iteration";
18
+
19
+ /** Max nesting depth for `:::iterate` inside an iterated cell. */
20
+ export const MAX_ITERATION_DEPTH = 3;
21
+
22
+ /** Current iteration nesting depth; 0 = top-level document. */
23
+ const IterationDepthContext = createContext(0);
24
+
25
+ const DEFAULT_LAYOUT: Record<"iterate" | "pivot", IterationLayout> = {
26
+ iterate: "stacked",
27
+ pivot: "matrix",
28
+ };
29
+
30
+ const LAYOUTS = new Set<IterationLayout>(["stacked", "grid", "matrix", "bento"]);
31
+
32
+ /** Build an `IterationSpec` from a directive's name + attributes + raw body. */
33
+ export function specFromDirective(
34
+ name: "iterate" | "pivot",
35
+ attributes: Record<string, string>,
36
+ rawBody: string | undefined,
37
+ ): IterationSpec {
38
+ const kind = name;
39
+ const layoutAttr = attributes.layout as IterationLayout | undefined;
40
+ const layout = layoutAttr && LAYOUTS.has(layoutAttr) ? layoutAttr : DEFAULT_LAYOUT[kind];
41
+ const columns = attributes.columns ? Number(attributes.columns) || undefined : undefined;
42
+ return {
43
+ kind,
44
+ layout,
45
+ template: rawBody ?? "",
46
+ as: attributes.as?.trim() || "item",
47
+ source: attributes.source,
48
+ rows: attributes.rows,
49
+ cols: attributes.cols,
50
+ columns,
51
+ attributes,
52
+ };
53
+ }
54
+
55
+ export interface IterationDirectiveProps {
56
+ spec: IterationSpec;
57
+ evaluate: EvaluateIteration;
58
+ interpolate?: InterpolateTemplate;
59
+ /** Render one cell's resolved markdown → node (a nested `MarkdownPreview`). */
60
+ renderCell: (markdown: string) => ReactNode;
61
+ }
62
+
63
+ /**
64
+ * Renders an `IterationBlock` one nesting level deeper, refusing to recurse past
65
+ * {@link MAX_ITERATION_DEPTH}. Cells (which mount nested `MarkdownPreview`s) read
66
+ * the incremented depth, so a nested `:::iterate` is bounded.
67
+ */
68
+ export function IterationDirective({
69
+ spec,
70
+ evaluate,
71
+ interpolate,
72
+ renderCell,
73
+ }: IterationDirectiveProps) {
74
+ const depth = useContext(IterationDepthContext);
75
+ if (depth >= MAX_ITERATION_DEPTH) {
76
+ return (
77
+ <div className="my-4 text-meta text-muted-foreground italic" data-iteration-too-deep="">
78
+ Iteration nested too deep — skipped.
79
+ </div>
80
+ );
81
+ }
82
+ return (
83
+ <IterationDepthContext.Provider value={depth + 1}>
84
+ <IterationBlock
85
+ spec={spec}
86
+ evaluate={evaluate}
87
+ interpolate={interpolate}
88
+ render={renderCell}
89
+ />
90
+ </IterationDepthContext.Provider>
91
+ );
92
+ }
@@ -0,0 +1,56 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The seam between an iteration node-view's `⋯` "Edit template…" affordance and
5
+ * the {@link IterationTemplateDialog} that fulfils it.
6
+ *
7
+ * Decoupled ON PURPOSE: the node-view (inside `MarkdownEditor`) must NOT import the
8
+ * dialog (which embeds a whole `MarkdownWorkspace` — a heavy, cyclic dependency).
9
+ * Instead the node-view emits an edit REQUEST through this context; the consumer
10
+ * (above the editor) renders the dialog and writes the result back via `onSave`.
11
+ * When no handler is provided, the `⋯` button is hidden and the template stays
12
+ * editable inline. This keeps `@elabs-ai/components-editor` a presentation layer — the app owns
13
+ * how/where the modal mounts.
14
+ */
15
+ import { createContext } from "react";
16
+
17
+ export interface IterationEditRequest {
18
+ /** Which directive asked to be edited. */
19
+ kind: "iterate" | "pivot";
20
+ /** The current template markdown (the node body, serialized). */
21
+ template: string;
22
+ /** Apply the edited template back to the node. */
23
+ onSave: (template: string) => void;
24
+ /**
25
+ * The directive's current attributes (value lists, bind name, layout) — present
26
+ * when the node-view supports GUIDED re-editing (A5). The
27
+ * `IterationBuilderProvider` reads these to reopen the builder with its data;
28
+ * the template-only `IterationTemplateProvider` ignores them.
29
+ */
30
+ attributes?: Record<string, string>;
31
+ /**
32
+ * Write back BOTH the attributes and the template (the guided builder path). When
33
+ * present it is preferred over {@link onSave} (which only rewrites the body).
34
+ */
35
+ onSaveData?: (next: { attributes: Record<string, string>; template: string }) => void;
36
+ /**
37
+ * Directly rewrite the node's attributes (e.g. the node-menu's "Change layout" /
38
+ * "Transpose" actions) without touching the template body. Additive (#223) —
39
+ * present alongside {@link onSave}/{@link onSaveData} for back-compat; a
40
+ * consumer-side surface (a future dialog action) can reach the same write the
41
+ * node-view's own menu uses.
42
+ */
43
+ onSetAttributes?: (attributes: Record<string, string>) => void;
44
+ /**
45
+ * Replace the ENTIRE directive node with plain markdown — e.g. the node-menu's
46
+ * "Convert to static" action (the evaluated, populated result). After this the
47
+ * node stops being a `:::iterate`/`:::pivot` directive. Additive (#223).
48
+ */
49
+ onReplaceWithMarkdown?: (markdown: string) => void;
50
+ }
51
+
52
+ /** A consumer handler that opens the template editor for a request. */
53
+ export type IterationEditHandler = (request: IterationEditRequest) => void;
54
+
55
+ /** Provide a handler to enable the node-view `⋯` re-edit affordance. */
56
+ export const IterationEditContext = createContext<IterationEditHandler | null>(null);
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `markdown-iteration` — the `:::iterate` / `:::pivot` repeater for
3
+ * `MarkdownPreview`. Enabled by the `evaluateIteration` prop (the calc/citation
4
+ * precedent — *the library renders, the app computes*); the standalone
5
+ * `IterationBlock` + the public contract types are exported for direct use.
6
+ */
7
+ export {
8
+ IterationBlock,
9
+ defaultInterpolate,
10
+ type EvaluateIteration,
11
+ type InterpolateTemplate,
12
+ type IterationBlockProps,
13
+ type IterationCell,
14
+ type IterationData,
15
+ type IterationLayout,
16
+ type IterationSpec,
17
+ } from "./iteration";
18
+
19
+ export {
20
+ IterationDirective,
21
+ specFromDirective,
22
+ MAX_ITERATION_DEPTH,
23
+ type IterationDirectiveProps,
24
+ } from "./directive";
@@ -0,0 +1,138 @@
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { IterationBlock, type IterationData, type IterationSpec } from "./iteration";
5
+
6
+ afterEach(cleanup);
7
+
8
+ // A plain cell renderer (NOT a nested MarkdownPreview) keeps these tests
9
+ // deterministic + light — they exercise layout + interpolation, not markdown.
10
+ const plainRender = (md: string) => <span data-cell="">{md}</span>;
11
+
12
+ const spec = (over: Partial<IterationSpec> = {}): IterationSpec => ({
13
+ kind: "iterate",
14
+ layout: "stacked",
15
+ template: "{{name}}",
16
+ as: "item",
17
+ attributes: {},
18
+ ...over,
19
+ });
20
+
21
+ describe("IterationBlock", () => {
22
+ it("stacked: renders one interpolated cell per item, in order", () => {
23
+ const data: IterationData = {
24
+ cells: [{ context: { name: "Ada" } }, { context: { name: "Bo" } }],
25
+ };
26
+ render(<IterationBlock spec={spec()} evaluate={() => data} render={plainRender} />);
27
+ const cells = screen.getAllByText(/Ada|Bo/);
28
+ expect(cells.map((c) => c.textContent)).toEqual(["Ada", "Bo"]);
29
+ });
30
+
31
+ it("grid: lays cells into a table with the given column headers", () => {
32
+ const data: IterationData = {
33
+ columns: ["A", "B"],
34
+ cells: [{ context: { n: 1 } }, { context: { n: 2 } }, { context: { n: 3 } }],
35
+ };
36
+ render(
37
+ <IterationBlock
38
+ spec={spec({ layout: "grid", template: "{{n}}" })}
39
+ evaluate={() => data}
40
+ render={plainRender}
41
+ />,
42
+ );
43
+ expect(screen.getByRole("columnheader", { name: "A" })).toBeInTheDocument();
44
+ // 3 cells + 2 columns → 2 rows (row-major).
45
+ expect(screen.getAllByRole("row")).toHaveLength(3); // header + 2 body rows
46
+ });
47
+
48
+ it("bento: renders one BentoGrid tile (bg-card surface) per cell", () => {
49
+ const data: IterationData = {
50
+ cells: [{ context: { n: 1 } }, { context: { n: 2 } }, { context: { n: 3 } }],
51
+ };
52
+ const { container } = render(
53
+ <IterationBlock
54
+ spec={spec({ layout: "bento", template: "{{n}}" })}
55
+ evaluate={() => data}
56
+ render={plainRender}
57
+ />,
58
+ );
59
+ expect(container.querySelector('[data-iteration-layout="bento"]')).toBeInTheDocument();
60
+ // Each tile composes Card (bg-card) → 3 cells = 3 tiles.
61
+ expect(container.querySelectorAll(".bg-card")).toHaveLength(3);
62
+ expect(screen.getByText("1")).toBeInTheDocument();
63
+ });
64
+
65
+ it("matrix: builds a pivot table with row + column headers and cells at intersections", () => {
66
+ const data: IterationData = {
67
+ rowHeaders: ["Q1", "Q2"],
68
+ colHeaders: ["EU", "US"],
69
+ cells: ["Q1", "Q2"].flatMap((r) =>
70
+ ["EU", "US"].map((c) => ({ row: r, col: c, context: { v: `${r}/${c}` } })),
71
+ ),
72
+ };
73
+ render(
74
+ <IterationBlock
75
+ spec={spec({ kind: "pivot", layout: "matrix", template: "{{v}}" })}
76
+ evaluate={() => data}
77
+ render={plainRender}
78
+ />,
79
+ );
80
+ expect(screen.getByRole("columnheader", { name: "EU" })).toBeInTheDocument();
81
+ expect(screen.getByRole("rowheader", { name: "Q1" })).toBeInTheDocument();
82
+ expect(screen.getByText("Q1/US")).toBeInTheDocument();
83
+ });
84
+
85
+ it("matrix without both axes falls back to a grid (no crash)", () => {
86
+ const data: IterationData = { cells: [{ context: { v: "x" } }] };
87
+ render(
88
+ <IterationBlock
89
+ spec={spec({ kind: "pivot", layout: "matrix", template: "{{v}}" })}
90
+ evaluate={() => data}
91
+ render={plainRender}
92
+ />,
93
+ );
94
+ expect(screen.getByText("x")).toBeInTheDocument();
95
+ });
96
+
97
+ it("renders a quiet empty state when there is no data", () => {
98
+ render(<IterationBlock spec={spec()} evaluate={() => null} render={plainRender} />);
99
+ expect(screen.getByRole("group", { name: "Iteration" })).toHaveTextContent(
100
+ /Nothing to iterate/i,
101
+ );
102
+ });
103
+
104
+ it("degrades a throwing evaluate to the empty state (never crashes)", () => {
105
+ const evaluate = vi.fn(() => {
106
+ throw new Error("boom");
107
+ });
108
+ render(<IterationBlock spec={spec()} evaluate={evaluate} render={plainRender} />);
109
+ expect(screen.getByRole("group", { name: "Iteration" })).toBeInTheDocument();
110
+ });
111
+
112
+ it("uses a cell's pre-rendered markdown, skipping interpolate", () => {
113
+ const interpolate = vi.fn(() => "INTERPOLATED");
114
+ const data: IterationData = { cells: [{ context: {}, markdown: "PRERENDERED" }] };
115
+ render(
116
+ <IterationBlock
117
+ spec={spec()}
118
+ evaluate={() => data}
119
+ interpolate={interpolate}
120
+ render={plainRender}
121
+ />,
122
+ );
123
+ expect(screen.getByText("PRERENDERED")).toBeInTheDocument();
124
+ expect(interpolate).not.toHaveBeenCalled();
125
+ });
126
+
127
+ it("exposes the loop variable + fields to the template scope", () => {
128
+ const data: IterationData = { cells: [{ context: { name: "Ada" } }] };
129
+ render(
130
+ <IterationBlock
131
+ spec={spec({ as: "p", template: "{{p.name}}={{name}}" })}
132
+ evaluate={() => data}
133
+ render={plainRender}
134
+ />,
135
+ );
136
+ expect(screen.getByText("Ada=Ada")).toBeInTheDocument();
137
+ });
138
+ });