@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,795 @@
1
+ "use client";
2
+
3
+ /**
4
+ * React node-views for the brand `:::` directives.
5
+ *
6
+ * Upgrades the Milkdown (ProseMirror) WYSIWYG surface from token-styled `toDOM`
7
+ * chrome (see directive-nodes.ts) to the ACTUAL @brand React components, rendered
8
+ * live INSIDE the editor via @prosemirror-adapter/react:
9
+ *
10
+ * :::card → real <Card> (title editable inline; body = editable content)
11
+ * :::callout → real <Alert> (title editable inline; body = editable content)
12
+ * ::metric → real <MetricBlock> (label + value editable inline; atomic)
13
+ * :::timeline → branded frame around the editable list (kept editable, not a
14
+ * derived <Timeline>, so steps round-trip as real markdown list items)
15
+ *
16
+ * Inline edits write through `setAttrs` to the directive node's `attributes`, which
17
+ * the existing `toMarkdown` runner serializes back to `:::name{key="value"}` — so the
18
+ * round-trip stays lossless (the editor and the Streamdown preview share one dialect).
19
+ *
20
+ * The `toDOM` definitions in directive-nodes.ts remain as the schema's serialization
21
+ * fallback (clipboard / no-adapter); when these node-views are registered, ProseMirror
22
+ * renders them instead.
23
+ */
24
+ import {
25
+ Alert,
26
+ AlertDescription,
27
+ Card,
28
+ CardContent,
29
+ CardHeader,
30
+ CardTitle,
31
+ ContextMenu,
32
+ ContextMenuContent,
33
+ ContextMenuItem,
34
+ ContextMenuRadioGroup,
35
+ ContextMenuRadioItem,
36
+ ContextMenuSub,
37
+ ContextMenuSubContent,
38
+ ContextMenuSubTrigger,
39
+ ContextMenuTrigger,
40
+ DropdownMenu,
41
+ DropdownMenuContent,
42
+ DropdownMenuItem,
43
+ DropdownMenuRadioGroup,
44
+ DropdownMenuRadioItem,
45
+ DropdownMenuSub,
46
+ DropdownMenuSubContent,
47
+ DropdownMenuSubTrigger,
48
+ DropdownMenuTrigger,
49
+ } from "@elabs-ai/components-ui";
50
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
51
+ import { editorViewCtx, parserCtx, serializerCtx } from "@milkdown/kit/core";
52
+ import type { MilkdownPlugin } from "@milkdown/kit/ctx";
53
+ import type { Node as ProseNode } from "@milkdown/kit/prose/model";
54
+ import { $view } from "@milkdown/kit/utils";
55
+ import { useNodeViewContext, type useNodeViewFactory } from "@prosemirror-adapter/react";
56
+ import {
57
+ ArrowLeftRight,
58
+ FileText,
59
+ Grid3x3,
60
+ LayoutGrid,
61
+ MoreHorizontal,
62
+ Pencil,
63
+ Repeat2,
64
+ } from "lucide-react";
65
+ import { useContext, useEffect, useRef, type KeyboardEvent, type ReactNode } from "react";
66
+
67
+ import { IterationEditContext } from "../markdown-iteration/edit-context";
68
+ import type { IterationLayout } from "../markdown-iteration/iteration";
69
+ import {
70
+ builderValueFromParts,
71
+ directivePartsFromValue,
72
+ evaluateEmbedded,
73
+ ITERATION_LAYOUTS,
74
+ staticMarkdownFromValue,
75
+ transposeIterationValue,
76
+ } from "../markdown-iteration/iteration-builder";
77
+ import { MetricBlock } from "../metric-block";
78
+ import { containerDirectiveSchema, leafDirectiveSchema } from "./directive-nodes";
79
+ import { useInstance } from "./milkdown-react";
80
+
81
+ type Attrs = Record<string, string>;
82
+
83
+ /** Map a callout `type` to an @elabs-ai/components-ui Alert variant (mirrors the preview). */
84
+ const CALLOUT_VARIANT: Record<string, "default" | "info" | "success" | "warning" | "destructive"> =
85
+ {
86
+ info: "info",
87
+ note: "info",
88
+ tip: "success",
89
+ success: "success",
90
+ warning: "warning",
91
+ caution: "warning",
92
+ danger: "destructive",
93
+ error: "destructive",
94
+ destructive: "destructive",
95
+ };
96
+
97
+ function capitalize(s: string): string {
98
+ return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
99
+ }
100
+
101
+ /** Read the directive node's `name` + `attributes`, with a writer that round-trips. */
102
+ function useDirectiveAttrs() {
103
+ const { node, setAttrs } = useNodeViewContext();
104
+ const name = String(node.attrs.name);
105
+ const attributes = (node.attrs.attributes ?? {}) as Attrs;
106
+ const update = (key: string, value: string) => {
107
+ const next: Attrs = { ...attributes };
108
+ if (value === "") delete next[key];
109
+ else next[key] = value;
110
+ setAttrs({ attributes: next });
111
+ };
112
+ return { name, attributes, update };
113
+ }
114
+
115
+ interface InlineEditProps {
116
+ value: string;
117
+ onCommit: (value: string) => void;
118
+ ariaLabel: string;
119
+ placeholder?: string;
120
+ className?: string;
121
+ }
122
+
123
+ /**
124
+ * A seamless inline editor for a single directive attribute.
125
+ *
126
+ * Uncontrolled `contentEditable` (so the caret never jumps mid-type), syncing the
127
+ * DOM text from `value` only while NOT focused, and committing on blur / Enter
128
+ * (Escape reverts). Marked `data-directive-chrome` so the node-view's `stopEvent`
129
+ * routes its keystrokes to the browser, not ProseMirror.
130
+ */
131
+ function InlineEdit({ value, onCommit, ariaLabel, placeholder, className }: InlineEditProps) {
132
+ const ref = useRef<HTMLSpanElement>(null);
133
+
134
+ useEffect(() => {
135
+ const el = ref.current;
136
+ if (!el) return;
137
+ // Don't clobber the user's text while they're editing this field.
138
+ if (el === el.ownerDocument.activeElement) return;
139
+ if (el.textContent !== value) el.textContent = value;
140
+ }, [value]);
141
+
142
+ const commit = () => {
143
+ const next = (ref.current?.textContent ?? "").trim();
144
+ if (next !== value) onCommit(next);
145
+ };
146
+
147
+ const onKeyDown = (e: KeyboardEvent<HTMLSpanElement>) => {
148
+ if (e.key === "Enter") {
149
+ e.preventDefault();
150
+ e.currentTarget.blur();
151
+ } else if (e.key === "Escape") {
152
+ e.preventDefault();
153
+ if (ref.current) ref.current.textContent = value;
154
+ e.currentTarget.blur();
155
+ }
156
+ };
157
+
158
+ return (
159
+ <span
160
+ ref={ref}
161
+ role="textbox"
162
+ aria-label={ariaLabel}
163
+ // Single-line field: Enter commits (it never inserts a newline).
164
+ aria-multiline={false}
165
+ data-directive-chrome=""
166
+ data-placeholder={placeholder}
167
+ contentEditable
168
+ suppressContentEditableWarning
169
+ tabIndex={0}
170
+ spellCheck={false}
171
+ onBlur={commit}
172
+ onKeyDown={onKeyDown}
173
+ className={cn(
174
+ "brand-inline-edit rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
175
+ className,
176
+ )}
177
+ />
178
+ );
179
+ }
180
+
181
+ /** `:::name` block directives → live @brand component with an editable body. */
182
+ function ContainerDirectiveView() {
183
+ const { contentRef } = useNodeViewContext();
184
+ const { name, attributes, update } = useDirectiveAttrs();
185
+
186
+ // The editable ProseMirror content (the directive body) mounts here.
187
+ const body = <div className="brand-directive__body" ref={contentRef} />;
188
+
189
+ if (name === "card") {
190
+ return (
191
+ <Card className="brand-directive brand-directive--card" data-brand-directive="card">
192
+ <CardHeader className="pb-3">
193
+ <CardTitle>
194
+ <InlineEdit
195
+ ariaLabel="Card title"
196
+ placeholder="Card title"
197
+ value={attributes.title ?? ""}
198
+ onCommit={(v) => update("title", v)}
199
+ />
200
+ </CardTitle>
201
+ </CardHeader>
202
+ <CardContent>{body}</CardContent>
203
+ </Card>
204
+ );
205
+ }
206
+
207
+ if (name === "callout") {
208
+ const variant = CALLOUT_VARIANT[attributes.type ?? ""] ?? "default";
209
+ return (
210
+ <Alert
211
+ variant={variant}
212
+ className="brand-directive brand-directive--callout"
213
+ data-brand-directive="callout"
214
+ >
215
+ {/* Non-heading title (matches the preview): a callout sits inside content
216
+ flow, so its label must not join the document heading outline (see #21). */}
217
+ <div className="mb-1 font-medium leading-none tracking-tight">
218
+ <InlineEdit
219
+ ariaLabel="Callout title"
220
+ placeholder={capitalize(attributes.type ?? "note")}
221
+ value={attributes.title ?? ""}
222
+ onCommit={(v) => update("title", v)}
223
+ />
224
+ </div>
225
+ <AlertDescription>{body}</AlertDescription>
226
+ </Alert>
227
+ );
228
+ }
229
+
230
+ if (name === "timeline") {
231
+ // Kept as an editable list inside a branded frame — a derived <Timeline> can't
232
+ // be edited in place, and the steps must serialize back as markdown list items.
233
+ return (
234
+ <div className="brand-directive brand-directive--timeline" data-brand-directive="timeline">
235
+ {body}
236
+ </div>
237
+ );
238
+ }
239
+
240
+ if (name === "iterate" || name === "pivot") {
241
+ // Owns hooks (dialog state + editor instance) → its own component.
242
+ return <IterationDirectiveView />;
243
+ }
244
+
245
+ // Unknown container directive — surface it, but never drop the body content.
246
+ // `role="note"` (not Alert's default assertive `role="alert"`): this is a
247
+ // PERSISTENT block that re-renders on every re-parse, so an assertive live region
248
+ // would re-announce on each keystroke (#37). The title is a label, not a heading.
249
+ return (
250
+ <Alert
251
+ role="note"
252
+ variant="destructive"
253
+ className="brand-directive brand-directive--unknown"
254
+ data-brand-directive={name}
255
+ >
256
+ <div className="mb-1 font-medium leading-none tracking-tight">Unknown block: {name}</div>
257
+ <AlertDescription>{body}</AlertDescription>
258
+ </Alert>
259
+ );
260
+ }
261
+
262
+ /* ------------------------------------------------------------------ */
263
+ /* Iteration node-view (`:::iterate` / `:::pivot`) + the ⋯ re-edit modal */
264
+ /* ------------------------------------------------------------------ */
265
+
266
+ type GetEditor = () =>
267
+ | { ctx: { get: (token: unknown) => unknown }; action: <T>(fn: (ctx: unknown) => T) => T }
268
+ | undefined;
269
+
270
+ /** Serialize a directive node's BODY content back to markdown (the template). */
271
+ function readBodyMarkdown(getInstance: GetEditor, node: ProseNode): string {
272
+ try {
273
+ const editor = getInstance();
274
+ if (!editor) return node.textContent;
275
+ return editor
276
+ .action((ctx) => {
277
+ const serialize = (ctx as { get: (t: unknown) => (n: ProseNode) => string }).get(
278
+ serializerCtx,
279
+ );
280
+ const doc = node.type.schema.topNodeType.create(null, node.content);
281
+ return serialize(doc);
282
+ })
283
+ .trim();
284
+ } catch {
285
+ // Serializer unavailable (e.g. SSR/edge) — fall back to the plain text body.
286
+ return node.textContent;
287
+ }
288
+ }
289
+
290
+ /** Replace a directive node's BODY with markdown parsed back into PM content. */
291
+ function writeBodyMarkdown(
292
+ getInstance: GetEditor,
293
+ getPos: () => number | undefined,
294
+ template: string,
295
+ ): void {
296
+ try {
297
+ const editor = getInstance();
298
+ const pos = getPos();
299
+ if (!editor || pos == null) return;
300
+ editor.action((ctx) => {
301
+ const parse = (ctx as { get: (t: unknown) => (md: string) => ProseNode | null }).get(
302
+ parserCtx,
303
+ );
304
+ const parsed = parse(template);
305
+ if (!parsed) return;
306
+ const view = (
307
+ ctx as {
308
+ get: (t: unknown) => {
309
+ state: { doc: ProseNode; tr: unknown };
310
+ dispatch: (tr: unknown) => void;
311
+ };
312
+ }
313
+ ).get(editorViewCtx);
314
+ const node = view.state.doc.nodeAt(pos);
315
+ if (!node) return;
316
+ const start = pos + 1;
317
+ const end = start + node.content.size;
318
+ const tr = (
319
+ view.state.tr as { replaceWith: (from: number, to: number, content: unknown) => unknown }
320
+ ).replaceWith(start, end, parsed.content);
321
+ view.dispatch(tr);
322
+ });
323
+ } catch {
324
+ // Parser unavailable / position stale — leave the inline body as the source.
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Merge `next` into `attrs`, OMITTING any key whose value is an empty string
330
+ * rather than writing it. `directivePartsFromValue` always computes `rows`/
331
+ * `cols`/`values` as a joined string — `""` when the list is empty — and an
332
+ * empty-string attribute value serializes as a BARE flag (`rows` with no
333
+ * `="…"`) via mdast-util-directive, which a consumer's `evaluate` then sees as
334
+ * `attributes.rows === ""` instead of `undefined` (a real behaviour-flip risk
335
+ * for any consumer that branches on attribute presence). Every writer that
336
+ * rewrites the builder-known keys (`as`/`layout`/`values`/`rows`/`cols`) with a
337
+ * possibly-empty computed value routes through this instead of a raw spread —
338
+ * that's also what keeps a consumer's OWN attributes (e.g. `source`/`region`,
339
+ * unknown to the builder model) intact: only the keys `next` actually names
340
+ * are touched, everything else in `attrs` passes through untouched.
341
+ */
342
+ function mergeAttrsOmittingEmpty(
343
+ attrs: Record<string, string>,
344
+ next: Record<string, string>,
345
+ ): Record<string, string> {
346
+ const merged: Record<string, string> = { ...attrs };
347
+ for (const [key, value] of Object.entries(next)) {
348
+ if (value === "") delete merged[key];
349
+ else merged[key] = value;
350
+ }
351
+ return merged;
352
+ }
353
+
354
+ /**
355
+ * Replace the ENTIRE directive node (not just its body) with parsed markdown —
356
+ * the "Convert to static" node-menu action's writer. A no-op on blank markdown
357
+ * (nothing resolved yet — e.g. no values entered) so the block is never
358
+ * clobbered with empty content.
359
+ */
360
+ function replaceNodeWithMarkdown(
361
+ getInstance: GetEditor,
362
+ getPos: () => number | undefined,
363
+ markdown: string,
364
+ ): void {
365
+ if (!markdown.trim()) return;
366
+ try {
367
+ const editor = getInstance();
368
+ const pos = getPos();
369
+ if (!editor || pos == null) return;
370
+ editor.action((ctx) => {
371
+ const parse = (ctx as { get: (t: unknown) => (md: string) => ProseNode | null }).get(
372
+ parserCtx,
373
+ );
374
+ const parsed = parse(markdown);
375
+ if (!parsed) return;
376
+ const view = (
377
+ ctx as {
378
+ get: (t: unknown) => {
379
+ state: { doc: ProseNode; tr: unknown };
380
+ dispatch: (tr: unknown) => void;
381
+ };
382
+ }
383
+ ).get(editorViewCtx);
384
+ const node = view.state.doc.nodeAt(pos);
385
+ if (!node) return;
386
+ const tr = (
387
+ view.state.tr as { replaceWith: (from: number, to: number, content: unknown) => unknown }
388
+ ).replaceWith(pos, pos + node.nodeSize, parsed.content);
389
+ view.dispatch(tr);
390
+ });
391
+ } catch {
392
+ // Parser/position unavailable — leave the node as a live directive.
393
+ }
394
+ }
395
+
396
+ /* -------------------------------------------------------------------- */
397
+ /* The iteration node MENU (⋯ dropdown AND right-click context menu) */
398
+ /* -------------------------------------------------------------------- */
399
+
400
+ interface IterationMenuAction {
401
+ type: "item";
402
+ id: string;
403
+ label: string;
404
+ icon: ReactNode;
405
+ onSelect: () => void;
406
+ /**
407
+ * Disable the item (e.g. "Transpose"/"Convert to static" with no resolvable
408
+ * data yet). The WHY must be folded into `label` itself — a disabled Radix
409
+ * menu item is `pointer-events-none` (a `title` tooltip can never fire) and
410
+ * skipped by keyboard roving-focus, so `title` is not a viable explanation
411
+ * channel for any input modality.
412
+ */
413
+ disabled?: boolean;
414
+ }
415
+
416
+ interface IterationMenuLayoutGroup {
417
+ type: "layout";
418
+ id: "layout";
419
+ label: string;
420
+ icon: ReactNode;
421
+ value: IterationLayout;
422
+ options: IterationLayout[];
423
+ onChange: (layout: IterationLayout) => void;
424
+ }
425
+
426
+ type IterationMenuEntry = IterationMenuAction | IterationMenuLayoutGroup;
427
+
428
+ /**
429
+ * Render one shared item list into EITHER the `⋯` dropdown or the right-click
430
+ * context menu — the two surfaces the AC requires to expose the SAME actions
431
+ * (#223). Taking `entries` as data (rather than duplicating JSX per menu type)
432
+ * means the two menus can never diverge: a new action is added once, here.
433
+ */
434
+ function IterationMenuItems({
435
+ kind,
436
+ entries,
437
+ }: {
438
+ kind: "dropdown" | "context";
439
+ entries: IterationMenuEntry[];
440
+ }) {
441
+ const isDropdown = kind === "dropdown";
442
+ const Item = isDropdown ? DropdownMenuItem : ContextMenuItem;
443
+ const Sub = isDropdown ? DropdownMenuSub : ContextMenuSub;
444
+ const SubTrigger = isDropdown ? DropdownMenuSubTrigger : ContextMenuSubTrigger;
445
+ const SubContent = isDropdown ? DropdownMenuSubContent : ContextMenuSubContent;
446
+ const RadioGroup = isDropdown ? DropdownMenuRadioGroup : ContextMenuRadioGroup;
447
+ const RadioItem = isDropdown ? DropdownMenuRadioItem : ContextMenuRadioItem;
448
+
449
+ return (
450
+ <>
451
+ {entries.map((entry) => {
452
+ if (entry.type === "layout") {
453
+ return (
454
+ <Sub key={entry.id}>
455
+ <SubTrigger className="gap-2">
456
+ {entry.icon}
457
+ {entry.label}
458
+ </SubTrigger>
459
+ <SubContent>
460
+ <RadioGroup
461
+ value={entry.value}
462
+ onValueChange={(next) => entry.onChange(next as IterationLayout)}
463
+ >
464
+ {entry.options.map((option) => (
465
+ <RadioItem key={option} value={option} className="capitalize">
466
+ {option}
467
+ </RadioItem>
468
+ ))}
469
+ </RadioGroup>
470
+ </SubContent>
471
+ </Sub>
472
+ );
473
+ }
474
+ return (
475
+ <Item key={entry.id} onSelect={entry.onSelect} disabled={entry.disabled}>
476
+ {entry.icon}
477
+ {entry.label}
478
+ </Item>
479
+ );
480
+ })}
481
+ </>
482
+ );
483
+ }
484
+
485
+ /**
486
+ * `:::iterate` / `:::pivot` node-view: the body IS the per-cell TEMPLATE (with
487
+ * `{{tokens}}`), kept editable inline in a quiet labelled frame (accent rail, no
488
+ * fill). When the consumer provides an {@link IterationEditContext} handler, a
489
+ * `⋯` button AND a right-click both open the SAME node menu (#223):
490
+ * - "Edit iteration…" — the existing guided re-edit (`requestEdit`).
491
+ * - "Change layout" — rewrites the `layout` attribute directly (no dialog).
492
+ * - "Transpose" (pivot only) — swaps the rows/cols value lists.
493
+ * - "Convert to static" — replaces the directive with its populated markdown.
494
+ * With no handler wired, neither menu renders and the body stays editable
495
+ * inline (today's behaviour, unchanged).
496
+ */
497
+ function IterationDirectiveView() {
498
+ const { contentRef, node, getPos, setAttrs } = useNodeViewContext();
499
+ const { name, attributes } = useDirectiveAttrs();
500
+ const [, getInstance] = useInstance();
501
+ const onEdit = useContext(IterationEditContext);
502
+
503
+ const isPivot = name === "pivot";
504
+ const kind: "iterate" | "pivot" = isPivot ? "pivot" : "iterate";
505
+ const Icon = isPivot ? Grid3x3 : Repeat2;
506
+
507
+ const requestEdit = () => {
508
+ onEdit?.({
509
+ kind,
510
+ template: readBodyMarkdown(getInstance as GetEditor, node),
511
+ // A5: hand the current attributes (value lists, bind name, layout) to the
512
+ // handler so the GUIDED builder can reopen with its data — and a writer that
513
+ // round-trips BOTH the attributes and the body, not just the template.
514
+ attributes: { ...(attributes as Record<string, string>) },
515
+ onSave: (template) => writeBodyMarkdown(getInstance as GetEditor, getPos, template),
516
+ // MERGE the guided builder's write-back into the EXISTING attributes rather
517
+ // than replacing the whole record — `directivePartsFromValue` only knows
518
+ // about `as`/`layout`/`values`/`rows`/`cols`, so a naive
519
+ // `setAttrs({ attributes: nextAttrs })` would silently drop every other
520
+ // attribute the directive carries (e.g. a consumer's `source`/`region`
521
+ // reference — `containerDirectiveSchema.attrs.attributes` is a free-form
522
+ // record, and those keys are load-bearing for the consumer's `evaluate`).
523
+ // Mirrors the `transpose()` fix below.
524
+ onSaveData: ({ attributes: nextAttrs, template }) => {
525
+ setAttrs({
526
+ attributes: mergeAttrsOmittingEmpty(attributes as Record<string, string>, nextAttrs),
527
+ });
528
+ writeBodyMarkdown(getInstance as GetEditor, getPos, template);
529
+ },
530
+ onSetAttributes: (nextAttrs) => setAttrs({ attributes: nextAttrs }),
531
+ onReplaceWithMarkdown: (markdown) =>
532
+ replaceNodeWithMarkdown(getInstance as GetEditor, getPos, markdown),
533
+ });
534
+ };
535
+
536
+ const setLayout = (layout: IterationLayout) => {
537
+ setAttrs({ attributes: { ...(attributes as Record<string, string>), layout } });
538
+ };
539
+
540
+ /**
541
+ * Swap a pivot's rows/cols in place — a no-op template read (transpose never
542
+ * touches the body), so it doesn't need the (async-ish) `readBodyMarkdown`.
543
+ *
544
+ * MERGES the transposed `rows`/`cols` into the EXISTING attributes rather than
545
+ * replacing the whole record (via `mergeAttrsOmittingEmpty`) — so a consumer's
546
+ * OTHER attributes (e.g. `source`/`region`) survive, AND an empty transposed
547
+ * axis is OMITTED rather than written as `""` (which mdast-util-directive would
548
+ * serialize as a bare `rows`/`cols` FLAG, not a genuinely-absent attribute). The
549
+ * menu gates this action on `hasEmbeddedData` below, so in practice both axes
550
+ * are always non-empty here — this stays defensive rather than load-bearing.
551
+ */
552
+ const transpose = () => {
553
+ const seed = builderValueFromParts(kind, attributes as Record<string, string>, "");
554
+ const { attributes: transposed } = directivePartsFromValue(transposeIterationValue(seed));
555
+ setAttrs({
556
+ attributes: mergeAttrsOmittingEmpty(attributes as Record<string, string>, {
557
+ rows: transposed.rows ?? "",
558
+ cols: transposed.cols ?? "",
559
+ }),
560
+ });
561
+ };
562
+
563
+ // "Transpose" (pivot) and "Convert to static" only have anything to act on
564
+ // when the block's data is EMBEDDED in its own attributes (`values=` /
565
+ // `rows=`×`cols=`) — the built-in `evaluateEmbedded` resolver. A block whose
566
+ // cells come from the consumer's `evaluate` instead (e.g. `source="repos"`)
567
+ // resolves to zero cells here — Transpose would be a pure visual no-op that
568
+ // still DIRTIES the document (writing bare `rows`/`cols` flags), and Convert
569
+ // to static has nothing to flatten — so both are disabled, with the reason
570
+ // folded into the visible LABEL (not `title`): a disabled Radix menu item is
571
+ // both `pointer-events-none` (no hover tooltip can ever fire) and skipped by
572
+ // keyboard roving-focus, so `title` is unreachable by any input modality —
573
+ // the rendered text is the only place a reason can actually be read.
574
+ const hasEmbeddedData =
575
+ evaluateEmbedded({
576
+ kind,
577
+ layout: (attributes.layout as IterationLayout) || ITERATION_LAYOUTS[kind][0]!,
578
+ template: "",
579
+ as: attributes.as || "item",
580
+ attributes: attributes as Record<string, string>,
581
+ }).cells.length > 0;
582
+
583
+ const disabledHint = "— needs embedded values";
584
+
585
+ const convertToStatic = () => {
586
+ const template = readBodyMarkdown(getInstance as GetEditor, node);
587
+ const value = builderValueFromParts(kind, attributes as Record<string, string>, template);
588
+ replaceNodeWithMarkdown(getInstance as GetEditor, getPos, staticMarkdownFromValue(value));
589
+ };
590
+
591
+ const menuEntries: IterationMenuEntry[] = [
592
+ {
593
+ type: "item",
594
+ id: "edit",
595
+ label: "Edit iteration…",
596
+ icon: <Pencil className="size-4" aria-hidden="true" />,
597
+ onSelect: requestEdit,
598
+ },
599
+ {
600
+ type: "layout",
601
+ id: "layout",
602
+ label: "Change layout",
603
+ icon: <LayoutGrid className="size-4" aria-hidden="true" />,
604
+ value: (attributes.layout as IterationLayout) || ITERATION_LAYOUTS[kind][0]!,
605
+ options: ITERATION_LAYOUTS[kind],
606
+ onChange: setLayout,
607
+ },
608
+ ...(isPivot
609
+ ? [
610
+ {
611
+ type: "item",
612
+ id: "transpose",
613
+ label: hasEmbeddedData ? "Transpose" : `Transpose ${disabledHint}`,
614
+ icon: <ArrowLeftRight className="size-4" aria-hidden="true" />,
615
+ onSelect: transpose,
616
+ disabled: !hasEmbeddedData,
617
+ } satisfies IterationMenuAction,
618
+ ]
619
+ : []),
620
+ {
621
+ type: "item",
622
+ id: "convert-to-static",
623
+ label: hasEmbeddedData ? "Convert to static" : `Convert to static ${disabledHint}`,
624
+ icon: <FileText className="size-4" aria-hidden="true" />,
625
+ onSelect: convertToStatic,
626
+ disabled: !hasEmbeddedData,
627
+ },
628
+ ];
629
+
630
+ const header = (
631
+ <div className="mb-1.5 flex items-center gap-1.5 text-meta font-medium text-info-text">
632
+ <Icon className="size-3.5 shrink-0" aria-hidden="true" />
633
+ <span>{isPivot ? "Pivot" : "Iterate"}</span>
634
+ {!isPivot && attributes.as ? (
635
+ <span className="font-normal text-muted-foreground">· per {attributes.as}</span>
636
+ ) : null}
637
+ <span className="font-normal text-muted-foreground">— template</span>
638
+ {onEdit ? (
639
+ <DropdownMenu>
640
+ <DropdownMenuTrigger asChild>
641
+ <button
642
+ type="button"
643
+ // `data-directive-chrome` routes the click to the browser, not ProseMirror.
644
+ data-directive-chrome=""
645
+ aria-label="Iteration actions"
646
+ title="Iteration actions…"
647
+ className="ms-auto inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
648
+ >
649
+ <MoreHorizontal className="size-4" aria-hidden="true" />
650
+ </button>
651
+ </DropdownMenuTrigger>
652
+ <DropdownMenuContent align="end">
653
+ <IterationMenuItems kind="dropdown" entries={menuEntries} />
654
+ </DropdownMenuContent>
655
+ </DropdownMenu>
656
+ ) : null}
657
+ </div>
658
+ );
659
+
660
+ const body = (
661
+ // The editable template body (inline ProseMirror content).
662
+ <div className="brand-directive__body" ref={contentRef} />
663
+ );
664
+
665
+ if (!onEdit) {
666
+ // No consumer handler wired — no re-edit / node-menu affordance (today's
667
+ // behaviour); the template stays editable inline only.
668
+ return (
669
+ <div
670
+ className="brand-directive brand-directive--iterate border-s-2 border-s-info ps-3"
671
+ data-brand-directive={name}
672
+ >
673
+ {header}
674
+ {body}
675
+ </div>
676
+ );
677
+ }
678
+
679
+ return (
680
+ <ContextMenu>
681
+ <div
682
+ className="brand-directive brand-directive--iterate border-s-2 border-s-info ps-3"
683
+ data-brand-directive={name}
684
+ >
685
+ {/*
686
+ * Scoped to the HEADER chrome only — NOT the editable template body.
687
+ * Radix's ContextMenuTrigger calls `event.preventDefault()` on every
688
+ * `contextmenu` inside its child, so wrapping the whole frame (header +
689
+ * body) hijacked the browser's native context menu (spellcheck
690
+ * suggestions, Paste, Look Up, Emoji) for right-clicks inside the
691
+ * editable template text. Right-click still opens this SAME menu from
692
+ * the header row; the body keeps its native menu.
693
+ */}
694
+ <ContextMenuTrigger asChild>{header}</ContextMenuTrigger>
695
+ {body}
696
+ </div>
697
+ <ContextMenuContent>
698
+ <IterationMenuItems kind="context" entries={menuEntries} />
699
+ </ContextMenuContent>
700
+ </ContextMenu>
701
+ );
702
+ }
703
+
704
+ /** `::name` leaf directives (e.g. `::metric`) → live, atomic @brand component. */
705
+ function LeafDirectiveView() {
706
+ const { name, attributes, update } = useDirectiveAttrs();
707
+
708
+ if (name !== "metric") {
709
+ return (
710
+ <div
711
+ className="brand-directive brand-directive--leaf brand-directive--unknown rounded-md border border-destructive/40 bg-surface-muted p-3 text-sm text-muted-foreground"
712
+ data-brand-leaf={name}
713
+ >
714
+ Unknown inline block: <code>::{name}</code>
715
+ </div>
716
+ );
717
+ }
718
+
719
+ const delta = attributes.delta;
720
+ return (
721
+ <MetricBlock
722
+ className="brand-directive brand-directive--leaf brand-directive--metric"
723
+ data-brand-leaf="metric"
724
+ label={
725
+ <InlineEdit
726
+ ariaLabel="Metric label"
727
+ placeholder="Label"
728
+ value={attributes.label ?? ""}
729
+ onCommit={(v) => update("label", v)}
730
+ />
731
+ }
732
+ value={
733
+ <InlineEdit
734
+ ariaLabel="Metric value"
735
+ placeholder="0"
736
+ value={attributes.value ?? ""}
737
+ onCommit={(v) => update("value", v)}
738
+ className="min-w-[1ch]"
739
+ />
740
+ }
741
+ description={attributes.description}
742
+ delta={delta}
743
+ deltaDirection={delta?.startsWith("+") ? "up" : delta?.startsWith("-") ? "down" : "neutral"}
744
+ />
745
+ );
746
+ }
747
+
748
+ /**
749
+ * Keep keystrokes inside an editable attribute field out of ProseMirror's hands —
750
+ * but ONLY for chrome that belongs to THIS node-view, not an ancestor's. ProseMirror
751
+ * asks the node-view whose `dom` contains the event, and `@prosemirror-adapter` marks
752
+ * every node-view root with `data-node-view-root`. So we walk up from the event target
753
+ * and stop ONLY if we reach a `[data-directive-chrome]` element WITHOUT first crossing
754
+ * a node-view-root boundary. This bounds the capture to the current node-view — a
755
+ * nested directive can't swallow events meant for its parent's chrome, and vice versa
756
+ * (#37). A shared, unbounded `closest()` could reach an ancestor's chrome.
757
+ */
758
+ function directiveStopEvent(event: Event): boolean {
759
+ let node = event.target;
760
+ while (node instanceof HTMLElement) {
761
+ if (node.hasAttribute("data-directive-chrome")) return true;
762
+ // Reached this node-view's own root without finding chrome → any match above
763
+ // belongs to an ancestor node-view; don't capture for it.
764
+ if (node.hasAttribute("data-node-view-root")) return false;
765
+ node = node.parentElement;
766
+ }
767
+ return false;
768
+ }
769
+
770
+ /**
771
+ * Build the `$view` plugins that bind the directive schemas to their React
772
+ * node-views. Call with the factory from `useNodeViewFactory()` (so it must run
773
+ * inside a `<ProsemirrorAdapterProvider>`), then `.use()` the result on the editor.
774
+ */
775
+ export function directiveViewPlugins(
776
+ nodeViewFactory: ReturnType<typeof useNodeViewFactory>,
777
+ ): MilkdownPlugin[] {
778
+ return [
779
+ $view(containerDirectiveSchema.node, () =>
780
+ nodeViewFactory({
781
+ component: ContainerDirectiveView,
782
+ as: "div",
783
+ contentAs: "div",
784
+ stopEvent: directiveStopEvent,
785
+ }),
786
+ ),
787
+ $view(leafDirectiveSchema.node, () =>
788
+ nodeViewFactory({
789
+ component: LeafDirectiveView,
790
+ as: "div",
791
+ stopEvent: directiveStopEvent,
792
+ }),
793
+ ),
794
+ ].flat() as MilkdownPlugin[];
795
+ }