@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,333 @@
1
+ "use client";
2
+
3
+ /**
4
+ * IterationBlock — a `:::iterate` / `:::pivot` repeater.
5
+ *
6
+ * Renders a per-cell markdown TEMPLATE once per data row (iterate) or per
7
+ * row×column cross-tab cell (pivot). Both domain concerns stay in the consumer
8
+ * (the calc/citation precedent — *the library renders, the app computes*):
9
+ * - `evaluate(spec) => IterationData` resolves the data source (the app's DB /
10
+ * query / binding lives there);
11
+ * - `interpolate(template, context) => string` fills the template (the app's
12
+ * templating engine; a minimal `{{path}}` default is provided).
13
+ * The library owns only layout + rendering. `render` turns a resolved cell's
14
+ * markdown into a node (a nested `MarkdownPreview` when wired through the preview).
15
+ *
16
+ * `grid` / `matrix` reuse the shared `@elabs-ai/components-ui` `Table` primitive (the same base
17
+ * `@elabs-ai/components-data`'s DataTable builds on — `@elabs-ai/components-editor` can't import the sibling
18
+ * `@elabs-ai/components-data`, so we compose the shared primitive rather than fork it). `bento`
19
+ * lays the cells out as varied-size `@elabs-ai/components-ui` `BentoGrid` tiles.
20
+ */
21
+ import {
22
+ BentoGrid,
23
+ BentoGridItem,
24
+ Table,
25
+ TableBody,
26
+ TableCell,
27
+ TableHead,
28
+ TableHeader,
29
+ TableRow,
30
+ type BentoGridSize,
31
+ } from "@elabs-ai/components-ui";
32
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
33
+ import { Repeat2 } from "lucide-react";
34
+ import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
35
+
36
+ export type IterationLayout = "stacked" | "grid" | "matrix" | "bento";
37
+
38
+ /** The parsed `:::iterate` / `:::pivot` specification handed to `evaluate`. */
39
+ export interface IterationSpec {
40
+ /** `iterate` (1D repeat) or `pivot` (2D cross-tab). */
41
+ kind: "iterate" | "pivot";
42
+ /** How the cells are laid out. */
43
+ layout: IterationLayout;
44
+ /** The per-cell markdown template (the directive body). */
45
+ template: string;
46
+ /** Loop-variable name exposed to `interpolate` (default `"item"`). */
47
+ as: string;
48
+ /** Opaque data-source reference; resolved by the consumer's `evaluate`. */
49
+ source?: string;
50
+ /** Pivot row / column dimension field names (`pivot`). */
51
+ rows?: string;
52
+ cols?: string;
53
+ /** Explicit grid column count (`grid`). */
54
+ columns?: number;
55
+ /** All raw directive attributes (escape hatch for `evaluate`). */
56
+ attributes: Record<string, string>;
57
+ }
58
+
59
+ /** One resolved cell. */
60
+ export interface IterationCell {
61
+ /** Interpolation context (the row record / pivot-cell scope). */
62
+ context: Record<string, unknown>;
63
+ /** Stable key (defaults to the index). */
64
+ key?: string;
65
+ /** `matrix`: the row / column header this cell sits at. */
66
+ row?: string;
67
+ col?: string;
68
+ /** `bento`: tile size preset. Defaults to a deterministic per-index rhythm. */
69
+ size?: BentoGridSize;
70
+ /** Pre-rendered markdown — skips `interpolate` when the app already templated. */
71
+ markdown?: string;
72
+ }
73
+
74
+ /** What the consumer's `evaluate` returns. */
75
+ export interface IterationData {
76
+ /** Cells in render order. */
77
+ cells: IterationCell[];
78
+ /** `matrix`: ordered row + column header labels. */
79
+ rowHeaders?: string[];
80
+ colHeaders?: string[];
81
+ /** `grid`: column header labels (also sets the column count). */
82
+ columns?: string[];
83
+ }
84
+
85
+ /** Consumer hook: resolve a spec to its data, or `null` when unavailable. */
86
+ export type EvaluateIteration = (spec: IterationSpec) => IterationData | null;
87
+
88
+ /** Consumer hook: fill a template with a cell context. */
89
+ export type InterpolateTemplate = (template: string, context: Record<string, unknown>) => string;
90
+
91
+ /* ------------------------------------------------------------------ */
92
+ /* Default templating (minimal; the app should bring its own engine) */
93
+ /* ------------------------------------------------------------------ */
94
+
95
+ const TOKEN_RE = /\{\{\s*([\w.$]+)\s*\}\}/g;
96
+
97
+ /**
98
+ * Minimal `{{ path.to.value }}` substitution over the cell context (dot paths).
99
+ * Safe (no eval). An UNRESOLVED token is left **literal** — so a nested
100
+ * `:::iterate`'s tokens survive the outer pass and resolve in the inner one (use
101
+ * the `as` loop variable in nested templates to avoid scope collisions). Replace
102
+ * it with your own engine (Mustache/Handlebars/…) via the `interpolate` prop.
103
+ */
104
+ export function defaultInterpolate(template: string, context: Record<string, unknown>): string {
105
+ return template.replace(TOKEN_RE, (match, path: string) => {
106
+ const value = path.split(".").reduce<unknown>((obj, key) => {
107
+ if (obj && typeof obj === "object") return (obj as Record<string, unknown>)[key];
108
+ return undefined;
109
+ }, context);
110
+ return value == null ? match : String(value);
111
+ });
112
+ }
113
+
114
+ /** Build the interpolation scope for a cell (fields + `as`-nested + row/col/index). */
115
+ function cellScope(
116
+ spec: IterationSpec,
117
+ cell: IterationCell,
118
+ index: number,
119
+ ): Record<string, unknown> {
120
+ return {
121
+ ...cell.context,
122
+ [spec.as]: cell.context,
123
+ index,
124
+ row: cell.row,
125
+ col: cell.col,
126
+ };
127
+ }
128
+
129
+ function safeEvaluate(evaluate: EvaluateIteration, spec: IterationSpec): IterationData | null {
130
+ try {
131
+ return evaluate(spec);
132
+ } catch {
133
+ // A throwing hook degrades to the empty state — it never crashes the page.
134
+ return null;
135
+ }
136
+ }
137
+
138
+ function chunk<T>(arr: T[], size: number): T[][] {
139
+ if (size <= 0) return [arr];
140
+ const out: T[][] = [];
141
+ for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
142
+ return out;
143
+ }
144
+
145
+ /**
146
+ * Deterministic bento tile-size rhythm by index — a 2×2 feature tile to open each
147
+ * six-cell cycle and a 2×1 wide tile mid-cycle, the rest 1×1. `grid-auto-flow:
148
+ * dense` packs them and col-spans clamp to the available tracks on narrow widths.
149
+ * A cell can override this via {@link IterationCell.size}.
150
+ */
151
+ function bentoSize(index: number): BentoGridSize {
152
+ const m = index % 6;
153
+ if (m === 0) return "lg";
154
+ if (m === 3) return "md";
155
+ return "sm";
156
+ }
157
+
158
+ /* ------------------------------------------------------------------ */
159
+ /* Component */
160
+ /* ------------------------------------------------------------------ */
161
+
162
+ export interface IterationBlockProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
163
+ /** The parsed iteration spec (from the directive, or hand-built). */
164
+ spec: IterationSpec;
165
+ /** Resolve the spec to data (consumer data source). */
166
+ evaluate: EvaluateIteration;
167
+ /** Fill a template with a cell context. Default: `{{path}}` substitution. */
168
+ interpolate?: InterpolateTemplate;
169
+ /** Render a resolved cell's markdown → node (e.g. a nested `MarkdownPreview`). */
170
+ render: (markdown: string) => ReactNode;
171
+ /** Override `spec.layout`. */
172
+ layout?: IterationLayout;
173
+ /** Empty-state message when there are no cells. */
174
+ emptyLabel?: string;
175
+ }
176
+
177
+ export const IterationBlock = forwardRef<HTMLDivElement, IterationBlockProps>(
178
+ function IterationBlock(
179
+ {
180
+ spec,
181
+ evaluate,
182
+ interpolate = defaultInterpolate,
183
+ render,
184
+ layout,
185
+ emptyLabel,
186
+ className,
187
+ ...props
188
+ },
189
+ ref,
190
+ ) {
191
+ const data = safeEvaluate(evaluate, spec);
192
+ const cells = data?.cells ?? [];
193
+ const resolvedLayout = layout ?? spec.layout;
194
+
195
+ const renderCell = (cell: IterationCell, index: number): ReactNode => {
196
+ const md = cell.markdown ?? interpolate(spec.template, cellScope(spec, cell, index));
197
+ return render(md);
198
+ };
199
+
200
+ const label = spec.kind === "pivot" ? "Pivot" : "Iteration";
201
+
202
+ if (cells.length === 0) {
203
+ return (
204
+ <div
205
+ ref={ref}
206
+ role="group"
207
+ aria-label={label}
208
+ data-iteration={spec.kind}
209
+ className={cn("my-4 flex items-center gap-2 text-meta text-muted-foreground", className)}
210
+ {...props}
211
+ >
212
+ <Repeat2 className="size-4 shrink-0" aria-hidden="true" />
213
+ <span>
214
+ {emptyLabel ?? `Nothing to ${spec.kind === "pivot" ? "pivot" : "iterate"} yet.`}
215
+ </span>
216
+ </div>
217
+ );
218
+ }
219
+
220
+ // matrix needs both header axes; fall back to grid when they're absent.
221
+ const isMatrix =
222
+ resolvedLayout === "matrix" && !!data?.rowHeaders?.length && !!data?.colHeaders?.length;
223
+
224
+ let body: ReactNode;
225
+ if (resolvedLayout === "stacked") {
226
+ body = (
227
+ <ol className="space-y-6">
228
+ {cells.map((cell, i) => (
229
+ <li key={cell.key ?? i} className="[&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
230
+ {renderCell(cell, i)}
231
+ </li>
232
+ ))}
233
+ </ol>
234
+ );
235
+ } else if (resolvedLayout === "bento") {
236
+ body = (
237
+ <BentoGrid>
238
+ {cells.map((cell, i) => (
239
+ <BentoGridItem key={cell.key ?? i} size={cell.size ?? bentoSize(i)}>
240
+ <div className="h-full overflow-auto p-4 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
241
+ {renderCell(cell, i)}
242
+ </div>
243
+ </BentoGridItem>
244
+ ))}
245
+ </BentoGrid>
246
+ );
247
+ } else if (isMatrix) {
248
+ const rowHeaders = data!.rowHeaders!;
249
+ const colHeaders = data!.colHeaders!;
250
+ const at = new Map<string, { cell: IterationCell; index: number }>();
251
+ cells.forEach((cell, i) => {
252
+ if (cell.row != null && cell.col != null)
253
+ at.set(JSON.stringify([cell.row, cell.col]), { cell, index: i });
254
+ });
255
+ body = (
256
+ <Table>
257
+ <TableHeader>
258
+ <TableRow>
259
+ <TableHead aria-hidden="true" />
260
+ {colHeaders.map((c) => (
261
+ <TableHead key={c} scope="col">
262
+ {c}
263
+ </TableHead>
264
+ ))}
265
+ </TableRow>
266
+ </TableHeader>
267
+ <TableBody>
268
+ {rowHeaders.map((r) => (
269
+ <TableRow key={r}>
270
+ <TableHead scope="row" className="font-medium text-foreground">
271
+ {r}
272
+ </TableHead>
273
+ {colHeaders.map((c) => {
274
+ const hit = at.get(JSON.stringify([r, c]));
275
+ return (
276
+ <TableCell key={c} className="align-top">
277
+ {hit ? renderCell(hit.cell, hit.index) : null}
278
+ </TableCell>
279
+ );
280
+ })}
281
+ </TableRow>
282
+ ))}
283
+ </TableBody>
284
+ </Table>
285
+ );
286
+ } else {
287
+ // grid (and matrix fallback): cells laid row-major into N columns.
288
+ const headers = data?.columns;
289
+ const colCount = (headers?.length ?? spec.columns ?? Math.min(cells.length, 3)) || 1;
290
+ const rows = chunk(cells, colCount);
291
+ body = (
292
+ <Table>
293
+ {headers ? (
294
+ <TableHeader>
295
+ <TableRow>
296
+ {headers.map((h) => (
297
+ <TableHead key={h} scope="col">
298
+ {h}
299
+ </TableHead>
300
+ ))}
301
+ </TableRow>
302
+ </TableHeader>
303
+ ) : null}
304
+ <TableBody>
305
+ {rows.map((row, r) => (
306
+ <TableRow key={r}>
307
+ {row.map((cell, c) => (
308
+ <TableCell key={cell.key ?? c} className="align-top">
309
+ {renderCell(cell, r * colCount + c)}
310
+ </TableCell>
311
+ ))}
312
+ </TableRow>
313
+ ))}
314
+ </TableBody>
315
+ </Table>
316
+ );
317
+ }
318
+
319
+ return (
320
+ <div
321
+ ref={ref}
322
+ role="group"
323
+ aria-label={label}
324
+ data-iteration={spec.kind}
325
+ data-iteration-layout={resolvedLayout}
326
+ className={cn("my-4", className)}
327
+ {...props}
328
+ >
329
+ {body}
330
+ </div>
331
+ );
332
+ },
333
+ );
@@ -0,0 +1,63 @@
1
+ import { Button } from "@elabs-ai/components-ui";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { useState } from "react";
4
+ import { expect, userEvent, within } from "storybook/test";
5
+
6
+ import { IterationTemplateDialog } from "./template-dialog";
7
+
8
+ const meta = {
9
+ title: "Editor/Iteration/TemplateDialog",
10
+ component: IterationTemplateDialog,
11
+ tags: ["autodocs"],
12
+ parameters: {
13
+ layout: "centered",
14
+ docs: {
15
+ description: {
16
+ component:
17
+ "The #223 template modal — author a `:::iterate` / `:::pivot` per-cell template in a " +
18
+ "focused `Dialog` embedding the full `MarkdownWorkspace` (source / split / preview-edit). " +
19
+ "Controlled (`open` / `onOpenChange`), seeded with `template`, returning the edit via " +
20
+ "`onSave`. Wire it to the node-view `⋯` re-edit with `IterationTemplateProvider`.",
21
+ },
22
+ },
23
+ },
24
+ } satisfies Meta<typeof IterationTemplateDialog>;
25
+
26
+ export default meta;
27
+ type Story = StoryObj<typeof meta>;
28
+
29
+ export const Default: Story = {
30
+ args: { open: false, onOpenChange: () => {}, template: "", onSave: () => {} },
31
+ render: function DialogStory() {
32
+ const [open, setOpen] = useState(false);
33
+ const [saved, setSaved] = useState(
34
+ `:::card{title="{{item.name}}"}\n**{{item.role}}** · {{item.commits}} commits\n:::`,
35
+ );
36
+ return (
37
+ <div className="flex flex-col items-center gap-3">
38
+ <Button onClick={() => setOpen(true)}>Edit iteration template…</Button>
39
+ <pre className="max-w-md rounded-md bg-surface-muted p-3 text-caption text-muted-foreground">
40
+ {saved}
41
+ </pre>
42
+ <IterationTemplateDialog
43
+ open={open}
44
+ onOpenChange={setOpen}
45
+ template={saved}
46
+ onSave={setSaved}
47
+ />
48
+ </div>
49
+ );
50
+ },
51
+ play: async ({ canvasElement }) => {
52
+ const canvas = within(canvasElement);
53
+ await userEvent.click(canvas.getByRole("button", { name: /Edit iteration template/i }));
54
+ // The dialog portals to the body — assert on a control unique to it.
55
+ await expect(
56
+ await within(document.body).findByRole(
57
+ "button",
58
+ { name: "Save template" },
59
+ { timeout: 12000 },
60
+ ),
61
+ ).toBeInTheDocument();
62
+ },
63
+ };
@@ -0,0 +1,104 @@
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { useContext } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+
6
+ // The dialog embeds a MarkdownWorkspace (Monaco/Milkdown — can't mount in jsdom).
7
+ // Mock it with a plain textarea so these tests exercise the DIALOG's save/cancel +
8
+ // provider LOGIC (not the editor — that has its own tests + the Storybook render).
9
+ vi.mock("../markdown-workspace", () => ({
10
+ MarkdownWorkspace: ({
11
+ value,
12
+ onChange,
13
+ ["aria-label"]: ariaLabel,
14
+ }: {
15
+ value: string;
16
+ onChange: (v: string) => void;
17
+ "aria-label"?: string;
18
+ }) => (
19
+ <textarea aria-label={ariaLabel} value={value} onChange={(e) => onChange(e.target.value)} />
20
+ ),
21
+ }));
22
+
23
+ import { IterationEditContext } from "./edit-context";
24
+ import { IterationTemplateDialog, IterationTemplateProvider } from "./template-dialog";
25
+
26
+ afterEach(cleanup);
27
+
28
+ describe("IterationTemplateDialog", () => {
29
+ it("seeds the editor with the template and saves edits", async () => {
30
+ const user = userEvent.setup();
31
+ const onSave = vi.fn();
32
+ const onOpenChange = vi.fn();
33
+ render(
34
+ <IterationTemplateDialog
35
+ open
36
+ onOpenChange={onOpenChange}
37
+ template="**{{item.name}}**"
38
+ onSave={onSave}
39
+ />,
40
+ );
41
+ const editor = screen.getByRole("textbox", { name: "Iteration template editor" });
42
+ expect(editor).toHaveValue("**{{item.name}}**");
43
+
44
+ await user.clear(editor);
45
+ // (avoid `{{` in user.type — it's the userEvent escape for a literal brace)
46
+ await user.type(editor, "# Edited heading");
47
+ await user.click(screen.getByRole("button", { name: "Save template" }));
48
+
49
+ expect(onSave).toHaveBeenCalledWith("# Edited heading");
50
+ expect(onOpenChange).toHaveBeenCalledWith(false);
51
+ });
52
+
53
+ it("cancel closes without saving", async () => {
54
+ const user = userEvent.setup();
55
+ const onSave = vi.fn();
56
+ const onOpenChange = vi.fn();
57
+ render(
58
+ <IterationTemplateDialog open onOpenChange={onOpenChange} template="t" onSave={onSave} />,
59
+ );
60
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
61
+ expect(onSave).not.toHaveBeenCalled();
62
+ expect(onOpenChange).toHaveBeenCalledWith(false);
63
+ });
64
+
65
+ it("titles + helper copy adapt to the pivot kind", () => {
66
+ render(
67
+ <IterationTemplateDialog
68
+ open
69
+ onOpenChange={() => {}}
70
+ template=""
71
+ onSave={() => {}}
72
+ kind="pivot"
73
+ />,
74
+ );
75
+ expect(screen.getByText(/Edit pivot template/i)).toBeInTheDocument();
76
+ });
77
+ });
78
+
79
+ /** A tiny consumer that fires an edit request through the context. */
80
+ function EditTrigger({ onSave }: { onSave: (t: string) => void }) {
81
+ const onEdit = useContext(IterationEditContext);
82
+ return (
83
+ <button onClick={() => onEdit?.({ kind: "iterate", template: "seed", onSave })}>open</button>
84
+ );
85
+ }
86
+
87
+ describe("IterationTemplateProvider", () => {
88
+ it("opens the dialog on an edit request and routes the save back", async () => {
89
+ const user = userEvent.setup();
90
+ const onSave = vi.fn();
91
+ render(
92
+ <IterationTemplateProvider>
93
+ <EditTrigger onSave={onSave} />
94
+ </IterationTemplateProvider>,
95
+ );
96
+
97
+ await user.click(screen.getByRole("button", { name: "open" }));
98
+ const editor = await screen.findByRole("textbox", { name: "Iteration template editor" });
99
+ expect(editor).toHaveValue("seed");
100
+
101
+ await user.click(screen.getByRole("button", { name: "Save template" }));
102
+ expect(onSave).toHaveBeenCalledWith("seed");
103
+ });
104
+ });
@@ -0,0 +1,116 @@
1
+ "use client";
2
+
3
+ /**
4
+ * IterationTemplateDialog — author a `:::iterate` / `:::pivot` per-cell TEMPLATE
5
+ * in a focused modal (the #223 template modal). Composes `@elabs-ai/components-ui` `Dialog`
6
+ * with the existing `MarkdownWorkspace` (source / split / preview-edit), so the
7
+ * template is edited with the same toolbar + live preview as any document.
8
+ *
9
+ * Controlled: drive `open` / `onOpenChange`; seed `template`; receive the edited
10
+ * markdown via `onSave`. The library does not own where the template is stored —
11
+ * the caller (a slash command, or the node-view `⋯` re-edit menu) wires it back.
12
+ */
13
+ import {
14
+ Button,
15
+ Dialog,
16
+ DialogContent,
17
+ DialogDescription,
18
+ DialogFooter,
19
+ DialogHeader,
20
+ DialogTitle,
21
+ } from "@elabs-ai/components-ui";
22
+ import { useEffect, useState, type ReactNode } from "react";
23
+
24
+ import { MarkdownWorkspace, type MarkdownWorkspaceMode } from "../markdown-workspace";
25
+ import { IterationEditContext, type IterationEditRequest } from "./edit-context";
26
+
27
+ export interface IterationTemplateDialogProps {
28
+ /** Whether the dialog is open. */
29
+ open: boolean;
30
+ /** Open-state change handler (Radix Dialog contract). */
31
+ onOpenChange: (open: boolean) => void;
32
+ /** The initial template markdown (the directive body). */
33
+ template: string;
34
+ /** Called with the edited template when the user saves. */
35
+ onSave: (template: string) => void;
36
+ /** Tunes the title + helper copy. Default `"iterate"`. */
37
+ kind?: "iterate" | "pivot";
38
+ /** Initial workspace mode. Default `"split"`. */
39
+ mode?: MarkdownWorkspaceMode;
40
+ }
41
+
42
+ export function IterationTemplateDialog({
43
+ open,
44
+ onOpenChange,
45
+ template,
46
+ onSave,
47
+ kind = "iterate",
48
+ mode = "split",
49
+ }: IterationTemplateDialogProps) {
50
+ const [draft, setDraft] = useState(template);
51
+
52
+ // Re-seed the draft whenever the dialog (re)opens against a new template.
53
+ useEffect(() => {
54
+ if (open) setDraft(template);
55
+ }, [open, template]);
56
+
57
+ const unit = kind === "pivot" ? "cell" : "row";
58
+
59
+ const save = () => {
60
+ onSave(draft);
61
+ onOpenChange(false);
62
+ };
63
+
64
+ return (
65
+ <Dialog open={open} onOpenChange={onOpenChange}>
66
+ <DialogContent className="flex max-h-[85vh] w-[min(48rem,92vw)] max-w-none flex-col">
67
+ <DialogHeader>
68
+ <DialogTitle>Edit {kind === "pivot" ? "pivot" : "iteration"} template</DialogTitle>
69
+ <DialogDescription>
70
+ The per-{unit} template. Use <code>{"{{token}}"}</code> placeholders (e.g.{" "}
71
+ <code>{"{{item.name}}"}</code>) — each is filled per {unit} when the block renders.
72
+ </DialogDescription>
73
+ </DialogHeader>
74
+ <div className="min-h-0 flex-1">
75
+ <MarkdownWorkspace
76
+ value={draft}
77
+ onChange={setDraft}
78
+ defaultMode={mode}
79
+ className="h-full"
80
+ aria-label="Iteration template editor"
81
+ />
82
+ </div>
83
+ <DialogFooter>
84
+ <Button variant="ghost" onClick={() => onOpenChange(false)}>
85
+ Cancel
86
+ </Button>
87
+ <Button onClick={save}>Save template</Button>
88
+ </DialogFooter>
89
+ </DialogContent>
90
+ </Dialog>
91
+ );
92
+ }
93
+
94
+ /**
95
+ * One-liner wiring for the node-view `⋯` re-edit: provides the
96
+ * {@link IterationEditContext} handler AND renders the `IterationTemplateDialog`
97
+ * it opens. Wrap your `MarkdownEditor` / `MarkdownWorkspace` in it to enable the
98
+ * `⋯` "Edit template…" affordance on `:::iterate` / `:::pivot` node-views.
99
+ */
100
+ export function IterationTemplateProvider({ children }: { children: ReactNode }) {
101
+ const [request, setRequest] = useState<IterationEditRequest | null>(null);
102
+ return (
103
+ <IterationEditContext.Provider value={setRequest}>
104
+ {children}
105
+ <IterationTemplateDialog
106
+ open={request != null}
107
+ onOpenChange={(next) => {
108
+ if (!next) setRequest(null);
109
+ }}
110
+ template={request?.template ?? ""}
111
+ kind={request?.kind ?? "iterate"}
112
+ onSave={(template) => request?.onSave(template)}
113
+ />
114
+ </IterationEditContext.Provider>
115
+ );
116
+ }
@@ -0,0 +1,76 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { Pin } from "lucide-react";
3
+ import { useState } from "react";
4
+
5
+ import { DocumentOutline, useMarkdownOutline } from "./document-outline";
6
+
7
+ const DOC = `# Reporting migration plan
8
+
9
+ ## Phase overview
10
+
11
+ ### Discovery
12
+
13
+ ### Dual-run
14
+
15
+ ## Timeline
16
+
17
+ ## Risks and mitigations
18
+ `;
19
+
20
+ const meta = {
21
+ title: "Editor/DocumentOutline",
22
+ component: DocumentOutline,
23
+ tags: ["autodocs"],
24
+ parameters: { layout: "padded" },
25
+ } satisfies Meta<typeof DocumentOutline>;
26
+
27
+ export default meta;
28
+ type Story = StoryObj<typeof meta>;
29
+
30
+ function Demo() {
31
+ const items = useMarkdownOutline(DOC);
32
+ const [active, setActive] = useState(items[0]?.id);
33
+ return (
34
+ <div className="max-w-56">
35
+ <DocumentOutline items={items} activeId={active} onSelect={(i) => setActive(i.id)} />
36
+ </div>
37
+ );
38
+ }
39
+
40
+ export const Default: Story = {
41
+ args: { items: [] },
42
+ render: () => <Demo />,
43
+ };
44
+
45
+ export const Empty: Story = {
46
+ args: { items: [] },
47
+ };
48
+
49
+ /** Per-entry hover affordances via `itemActions` (e.g. pin a section). */
50
+ function ItemActionsDemo() {
51
+ const items = useMarkdownOutline(DOC);
52
+ const [pinned, setPinned] = useState<string | null>("timeline");
53
+ return (
54
+ <div className="max-w-56">
55
+ <DocumentOutline
56
+ items={items}
57
+ itemActions={(item) => (
58
+ <button
59
+ type="button"
60
+ aria-label={`Pin section “${item.text}”`}
61
+ aria-pressed={pinned === item.id}
62
+ onClick={() => setPinned((p) => (p === item.id ? null : item.id))}
63
+ className="rounded-sm p-0.5 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
64
+ >
65
+ <Pin className="size-3" aria-hidden="true" />
66
+ </button>
67
+ )}
68
+ />
69
+ </div>
70
+ );
71
+ }
72
+
73
+ export const ItemActions: Story = {
74
+ args: { items: [] },
75
+ render: () => <ItemActionsDemo />,
76
+ };