@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,560 @@
1
+ "use client";
2
+
3
+ /**
4
+ * MarkdownEditor — a headless Milkdown (ProseMirror) WYSIWYG markdown surface,
5
+ * vendored onto brand-ui. Companion to the Monaco-based CodeEditor: same package,
6
+ * different engine (Monaco = source/diff; Milkdown = direct-manipulation markdown).
7
+ *
8
+ * - Engine dep: only `@milkdown/kit` (headless). React glue is vendored under
9
+ * ./milkdown-react so we never pull `@milkdown/react` → `@milkdown/crepe` → Vue.
10
+ * - Theming: token-driven via markdown-editor.css (all three themes, no raw color).
11
+ * - Controlled (`value` + `onChange`) or uncontrolled (`defaultValue`). Mirrors the
12
+ * platform: `isControlled = value !== undefined`; never flips between modes.
13
+ * - StrictMode-safe (see markdown-editor.strictmode.test.tsx).
14
+ * - The brand `:::` directives render as live @brand React components inside the
15
+ * editor (real <Card>/<Alert>/<MetricBlock>, with inline-editable titles +
16
+ * metric label/value) via @prosemirror-adapter/react — see directive-views.tsx.
17
+ * The shared remark pipeline keeps the editor and the Streamdown preview on one
18
+ * markdown dialect.
19
+ */
20
+ import "@milkdown/kit/prose/view/style/prosemirror.css";
21
+ import "./markdown-editor.css";
22
+
23
+ import {
24
+ Editor,
25
+ defaultValueCtx,
26
+ editorViewCtx,
27
+ editorViewOptionsCtx,
28
+ parserCtx,
29
+ rootCtx,
30
+ serializerCtx,
31
+ } from "@milkdown/kit/core";
32
+ import { commonmark } from "@milkdown/kit/preset/commonmark";
33
+ import { gfm } from "@milkdown/kit/preset/gfm";
34
+ import { history } from "@milkdown/kit/plugin/history";
35
+ import { listener, listenerCtx } from "@milkdown/kit/plugin/listener";
36
+ import type { Node as ProseNode } from "@milkdown/kit/prose/model";
37
+ import { TextSelection } from "@milkdown/kit/prose/state";
38
+ import type { EditorView } from "@milkdown/kit/prose/view";
39
+ import { getMarkdown, replaceAll } from "@milkdown/kit/utils";
40
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
41
+ import {
42
+ ProsemirrorAdapterProvider,
43
+ useNodeViewFactory,
44
+ usePluginViewFactory,
45
+ useWidgetViewFactory,
46
+ } from "@prosemirror-adapter/react";
47
+ import {
48
+ forwardRef,
49
+ useContext,
50
+ useEffect,
51
+ useImperativeHandle,
52
+ useRef,
53
+ type HTMLAttributes,
54
+ } from "react";
55
+
56
+ import { calcProsePlugins } from "../calc-block/calc-editor-prose";
57
+ import type { CalcEditorHooks } from "../calc-block/types";
58
+ import { completionsViewPlugins } from "./completions";
59
+ import type { EditorCompletionProvider } from "../lib/editor-completions";
60
+ import type { EditorContentAccess, EditorSelection } from "../lib/editor-content-access";
61
+ import { proseMirrorContentAccess, selectionWatchPlugin } from "../lib/editor-content-access-prose";
62
+ import { markdownScaleVars } from "../lib/markdown/markdown-scale";
63
+ import { plainText, slugifyHeading, uniqueSlug } from "../lib/markdown/slugify";
64
+ import {
65
+ IterationEditContext,
66
+ type IterationEditHandler,
67
+ } from "../markdown-iteration/edit-context";
68
+ import { parseMarkdownOutline } from "../markdown-outline";
69
+ import { directivePlugins } from "./directive-nodes";
70
+ import { directiveViewPlugins } from "./directive-views";
71
+ import { exitKeymapPlugins } from "./exit-keymap";
72
+ import { Milkdown, MilkdownProvider, useEditor, useInstance } from "./milkdown-react";
73
+ import { pasteEmbedPlugin, type EmbedAssetFn } from "./paste-embed";
74
+ import { brandSlashViewPlugins, type SlashCommand } from "./slash";
75
+ import { tableViewPlugins } from "./table-view";
76
+
77
+ /**
78
+ * Imperative handle exposed via `ref`.
79
+ *
80
+ * Extends {@link EditorContentAccess} so any consumer of a `MarkdownEditor`
81
+ * ref can drive AI editing operations (insert, replace, subscribe to selection)
82
+ * through the engine-agnostic interface, alongside the markdown-specific helpers.
83
+ *
84
+ * `getText()` is an alias for `getMarkdown()` (same output; both return the full
85
+ * document serialized to markdown). The duplication is intentional: `getText`
86
+ * satisfies the `EditorContentAccess` interface contract; `getMarkdown` is the
87
+ * historically-named markdown-specific method. JSDoc notes the equivalence.
88
+ */
89
+ export interface MarkdownEditorHandle extends EditorContentAccess {
90
+ /**
91
+ * Serialize the current document to a markdown string.
92
+ * Equivalent to `getText()` — both return the full document as markdown.
93
+ */
94
+ getMarkdown: () => string;
95
+ /**
96
+ * Serialize the current document, or `null` while the engine is still
97
+ * booting. Used to capture the pre-edit normalization BASELINE for the
98
+ * lossless-edit merge (WI-1) — unlike `getMarkdown` it never falls back to
99
+ * the raw input, so a non-null result is always the editor's own output.
100
+ */
101
+ serialized: () => string | null;
102
+ /**
103
+ * Best-effort: scroll the heading whose outline slug matches into view (#273).
104
+ * Walks the ProseMirror doc for `heading` nodes, slugifies their text with the
105
+ * same algorithm as `parseMarkdownOutline`, and scrolls the first match into
106
+ * view. No-op (never throws) while the engine is booting or if no match.
107
+ */
108
+ scrollToHeading: (slug: string) => void;
109
+ /**
110
+ * Best-effort: scroll toward FULL-SOURCE 1-based `line` by resolving the
111
+ * nearest preceding heading in the ProseMirror doc (no exact source map in
112
+ * WYSIWYG — Milkdown keeps no `data-sourcepos`). No-op when no preceding
113
+ * heading is found or while booting. (#273)
114
+ *
115
+ * For precise navigation, prefer `scrollToHeading(slug)` — the workspace
116
+ * resolves the slug from the line via `parseMarkdownOutline` + `fmOffset`
117
+ * before forwarding here.
118
+ */
119
+ revealLine: (line: number, opts?: { center?: boolean }) => void;
120
+ }
121
+
122
+ export interface MarkdownEditorProps extends Omit<
123
+ HTMLAttributes<HTMLDivElement>,
124
+ "onChange" | "defaultValue"
125
+ > {
126
+ /** Controlled markdown value. Pair with `onChange`. */
127
+ value?: string;
128
+ /** Initial markdown for uncontrolled use. */
129
+ defaultValue?: string;
130
+ /** Fires on every edit with the full markdown document. */
131
+ onChange?: (markdown: string) => void;
132
+ /** Render the editor read-only (still selectable, not editable). */
133
+ readOnly?: boolean;
134
+ /**
135
+ * Accessible name for the editable surface. Milkdown/ProseMirror renders an ARIA
136
+ * `textbox` (`contenteditable`); without a name, screen readers announce an
137
+ * unlabeled field. Set onto the ProseMirror view's `attributes`. Default
138
+ * `"Markdown editor"`.
139
+ */
140
+ ariaLabel?: string;
141
+ /**
142
+ * Enable the `/` command menu (insert brand `:::` directives + basic blocks
143
+ * live at the caret). `true` (default) uses the built-in
144
+ * {@link BRAND_SLASH_COMMANDS}; pass a `commands` array to extend/replace them,
145
+ * or `false` to disable. The `trigger` defaults to `"/"`.
146
+ *
147
+ * Pass `shortcut` (default `"Mod-Shift-O"`) to control the keyboard shortcut that
148
+ * opens the menu at the caret without typing the trigger character — works in
149
+ * BOTH the WYSIWYG pane and the Monaco source pane (#271).
150
+ */
151
+ slashMenu?: boolean | { commands?: SlashCommand[]; trigger?: string; shortcut?: string };
152
+ /**
153
+ * Opt-in calc authoring inside ```calc fences (off by default). Supply the
154
+ * consumer's hooks — `tokenize` (highlight), `evaluate` (result inlays), and/or
155
+ * `complete` (autocomplete; Monaco surface). The library DECORATES, the consumer
156
+ * COMPUTES — no calc engine is bundled. Mirrors `MarkdownPreview`'s `evaluate`.
157
+ */
158
+ calc?: CalcEditorHooks;
159
+ /**
160
+ * Declarative completion providers (#283) — e.g. `[[wikilink]]` autocomplete.
161
+ * Off by default; mirrors the `slashMenu`/`calc` opt-in pattern. Each provider
162
+ * registers a `triggerCharacters` set + a `provide(ctx)` that returns
163
+ * candidates; the library owns detecting the trigger and rendering/inserting.
164
+ *
165
+ * This is a DELIBERATELY MINIMAL, best-effort mirror of the Monaco source-pane
166
+ * behavior (see `markdown-editor/completions/completions-prose.ts` for the
167
+ * exact gaps — no real cross-block line/column, a plain listbox instead of a
168
+ * native suggest widget). Forwarded from `MarkdownWorkspace`'s `completions`
169
+ * prop, which also wires the FULL Monaco/source-pane path.
170
+ */
171
+ completions?: EditorCompletionProvider[];
172
+ /**
173
+ * Host-provided callback for image paste/drop embedding.
174
+ *
175
+ * When set, the editor intercepts paste and drop events that contain image
176
+ * files, shows an inline "uploading…" placeholder, calls this function with
177
+ * the `File`, and on resolve inserts `![filename](returnedPath)` in the
178
+ * document. On reject the placeholder is removed and an inline error chip
179
+ * (+ toast) is shown — the document never contains a broken image.
180
+ *
181
+ * If not set, the editor's default paste/drop behavior is unchanged.
182
+ *
183
+ * The library NEVER stores assets — all persistence is the host's responsibility.
184
+ */
185
+ onEmbedAsset?: EmbedAssetFn;
186
+ }
187
+
188
+ export type { EmbedAssetFn };
189
+
190
+ interface ViewProps {
191
+ initialValue: string;
192
+ value?: string;
193
+ onChange?: (markdown: string) => void;
194
+ readOnly: boolean;
195
+ ariaLabel: string;
196
+ slashMenu: boolean | { commands?: SlashCommand[]; trigger?: string; shortcut?: string };
197
+ calc?: CalcEditorHooks;
198
+ completions?: EditorCompletionProvider[];
199
+ onEmbedAsset?: EmbedAssetFn;
200
+ }
201
+
202
+ /**
203
+ * Walk the ProseMirror doc for a `heading` node whose slug (slugified with the
204
+ * SAME algorithm as `parseMarkdownOutline`) matches `slug`, and scroll it into
205
+ * view. Best-effort: a no-op (never throws) when no match / out of range. Shared
206
+ * by `scrollToHeading` and the best-effort `revealLine` (#273).
207
+ */
208
+ function scrollHeadingBySlug(editor: Editor, slug: string): void {
209
+ editor.action((ctx) => {
210
+ const view = ctx.get(editorViewCtx);
211
+ const { doc } = view.state;
212
+ const used = new Map<string, number>();
213
+ let targetPos: number | null = null;
214
+ doc.forEach((node, offset) => {
215
+ if (targetPos !== null) return;
216
+ if (node.type.name === "heading") {
217
+ const id = uniqueSlug(slugifyHeading(plainText(node.textContent)), used);
218
+ if (id === slug) targetPos = offset + 1; // +1 steps inside the heading node
219
+ }
220
+ });
221
+ if (targetPos === null) return;
222
+ try {
223
+ const resolved = doc.resolve(targetPos);
224
+ view.dispatch(view.state.tr.setSelection(TextSelection.near(resolved)).scrollIntoView());
225
+ } catch {
226
+ // Guard against out-of-range positions — never throw.
227
+ }
228
+ });
229
+ }
230
+
231
+ const MarkdownEditorView = forwardRef<MarkdownEditorHandle, ViewProps>(function MarkdownEditorView(
232
+ {
233
+ initialValue,
234
+ value,
235
+ onChange,
236
+ readOnly,
237
+ ariaLabel,
238
+ slashMenu,
239
+ calc,
240
+ completions,
241
+ onEmbedAsset,
242
+ },
243
+ ref,
244
+ ) {
245
+ // Latest onChange via a ref so the create effect can run once per `readOnly`.
246
+ const onChangeRef = useRef(onChange);
247
+ onChangeRef.current = onChange;
248
+ // Tracks the editor's current markdown — both the "preserve across recreate"
249
+ // value and the echo-guard for controlled `value` syncing.
250
+ const lastMarkdown = useRef(initialValue);
251
+
252
+ // Renders the brand `:::` directives as live @brand React components inside the
253
+ // editor (must run under <ProsemirrorAdapterProvider>). The factory is stable.
254
+ const nodeViewFactory = useNodeViewFactory();
255
+ // The widget factory backs the `/` command menu (rendered as a ProseMirror
256
+ // widget at the caret). Same adapter, also stable.
257
+ const widgetViewFactory = useWidgetViewFactory();
258
+ // The plugin-view factory mounts the table controls toolbar as a ProseMirror
259
+ // plugin view (outside contentEditable, no focus conflicts).
260
+ const pluginViewFactory = usePluginViewFactory();
261
+
262
+ // Resolve the slash config through a ref so a fresh `commands` array identity
263
+ // each render never rebuilds the editor — only enabling/disabling does.
264
+ const slashEnabled = slashMenu !== false;
265
+ const slashConfigRef = useRef<{ commands?: SlashCommand[]; trigger?: string; shortcut?: string }>(
266
+ {},
267
+ );
268
+ slashConfigRef.current = typeof slashMenu === "object" ? slashMenu : {};
269
+
270
+ // Calc authoring hooks read through a ref — like the slash config, a fresh
271
+ // `calc` object identity each render must NOT rebuild the editor; only
272
+ // toggling the feature on/off does (the plugin reads the latest hooks lazily).
273
+ const calcEnabled = calc != null;
274
+ const calcRef = useRef<CalcEditorHooks | undefined>(calc);
275
+ calcRef.current = calc;
276
+
277
+ // Completion providers (#283) read through a ref — a fresh `completions`
278
+ // array identity each render must NOT rebuild the editor (or re-register the
279
+ // Monaco-side global provider); only enabling/disabling the feature does. The
280
+ // plugin's async `provide()` fetch reads the LATEST list via this getter.
281
+ const completionsEnabled = completions != null;
282
+ const completionsRef = useRef<EditorCompletionProvider[] | undefined>(completions);
283
+ completionsRef.current = completions;
284
+
285
+ // Keep onEmbedAsset in a ref so the editor factory captures it by reference —
286
+ // a new function identity each render never causes an editor rebuild.
287
+ const onEmbedAssetRef = useRef(onEmbedAsset);
288
+ onEmbedAssetRef.current = onEmbedAsset;
289
+
290
+ // #223: the live `IterationEditContext` handler (set by a consumer's
291
+ // `IterationBuilderProvider` / `IterationTemplateProvider` ABOVE this editor),
292
+ // read through a ref like the other slash/calc hooks — a fresh Provider value
293
+ // each render must NOT rebuild the editor; the plugin resolves it lazily via
294
+ // the getter passed to `brandSlashViewPlugins`.
295
+ const iterationEditHandler = useContext(IterationEditContext);
296
+ const iterationEditHandlerRef = useRef<IterationEditHandler | null>(iterationEditHandler);
297
+ iterationEditHandlerRef.current = iterationEditHandler;
298
+
299
+ // Shared selection-change listener set: the selectionWatchPlugin writes to it;
300
+ // proseMirrorContentAccess reads/writes subscriptions. Stable across re-renders
301
+ // (created once) — the plugin reads it via the thunk each transaction.
302
+ const selectionListeners = useRef(new Set<(sel: EditorSelection) => void>()).current;
303
+
304
+ // The serializeSlice closure is built lazily in useImperativeHandle (needs
305
+ // getInstance), but the plugin reads the LATEST version via a thunk so it never
306
+ // captures a stale closure. We hold a ref that useImperativeHandle fills in.
307
+ const serializeSliceRef = useRef<(view: EditorView) => string>(() => "");
308
+
309
+ useEditor(
310
+ (root) => {
311
+ // Build the paste-embed plugin with an indirection through the ref so the
312
+ // plugin always calls the latest host callback without rebuilding.
313
+ const embedPlugin = pasteEmbedPlugin(
314
+ onEmbedAssetRef.current ? (file: File) => onEmbedAssetRef.current!(file) : undefined,
315
+ );
316
+
317
+ let editor = Editor.make()
318
+ .config((ctx) => {
319
+ ctx.set(rootCtx, root);
320
+ ctx.set(defaultValueCtx, lastMarkdown.current);
321
+ ctx.update(editorViewOptionsCtx, (prev) => ({
322
+ ...prev,
323
+ editable: () => !readOnly,
324
+ // Name the ProseMirror `role="textbox"` surface so AT announces it.
325
+ // Spread prev.attributes so we never clobber Milkdown's own view attrs.
326
+ attributes: { ...prev.attributes, "aria-label": ariaLabel },
327
+ }));
328
+ ctx.get(listenerCtx).markdownUpdated((_, markdown) => {
329
+ lastMarkdown.current = markdown;
330
+ onChangeRef.current?.(markdown);
331
+ });
332
+ })
333
+ .use(commonmark)
334
+ .use(gfm)
335
+ .use(tableViewPlugins(pluginViewFactory))
336
+ .use(exitKeymapPlugins())
337
+ .use(history)
338
+ .use(listener)
339
+ .use(directivePlugins)
340
+ .use(directiveViewPlugins(nodeViewFactory))
341
+ .use(embedPlugin)
342
+ // Always-present selection-watch plugin — notifies selectionListeners when
343
+ // the ProseMirror selection changes. Listeners subscribe/unsubscribe via the
344
+ // proseMirrorContentAccess adapter; the plugin itself is stateless per-sub.
345
+ .use(
346
+ selectionWatchPlugin(
347
+ () => selectionListeners,
348
+ () => serializeSliceRef.current,
349
+ ),
350
+ );
351
+ if (slashEnabled) {
352
+ editor = editor.use(
353
+ brandSlashViewPlugins(widgetViewFactory, {
354
+ ...slashConfigRef.current,
355
+ getIterationEditHandler: () => iterationEditHandlerRef.current,
356
+ }),
357
+ );
358
+ }
359
+ if (calcEnabled) {
360
+ editor = editor.use(calcProsePlugins(() => calcRef.current));
361
+ }
362
+ if (completionsEnabled) {
363
+ editor = editor.use(
364
+ completionsViewPlugins(widgetViewFactory, () => completionsRef.current),
365
+ );
366
+ }
367
+ return editor;
368
+ },
369
+ [readOnly, ariaLabel, slashEnabled, calcEnabled, completionsEnabled],
370
+ );
371
+
372
+ const [loading, getInstance] = useInstance();
373
+
374
+ // Controlled sync: push external `value` into the editor only when it diverges
375
+ // from what the editor last emitted (avoids clobbering on our own echo).
376
+ useEffect(() => {
377
+ if (loading || value === undefined) return;
378
+ if (value === lastMarkdown.current) return;
379
+ const editor = getInstance();
380
+ if (!editor) return;
381
+ lastMarkdown.current = value;
382
+ editor.action(replaceAll(value));
383
+ }, [loading, value, getInstance]);
384
+
385
+ useImperativeHandle(
386
+ ref,
387
+ () => {
388
+ // Build serialize/parse closures via getInstance().action(ctx => ...) —
389
+ // the exact technique used by directive-views.tsx (readBodyMarkdown /
390
+ // writeBodyMarkdown). These are re-built whenever getInstance changes
391
+ // (i.e. when the editor is recreated).
392
+
393
+ /** Serialize the current selection slice to markdown ("" when collapsed). */
394
+ const serializeSlice = (view: EditorView): string => {
395
+ const editor = getInstance();
396
+ if (!editor) return "";
397
+ const { selection } = view.state;
398
+ if (selection.empty) return "";
399
+ try {
400
+ return editor
401
+ .action((ctx) => {
402
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
403
+ const serialize = (ctx as any).get(serializerCtx) as (node: ProseNode) => string;
404
+ const doc = selection.content().content;
405
+ // Wrap in a top-level doc node so the serializer can emit all block types.
406
+ const wrapper = view.state.doc.type.schema.topNodeType.create(null, doc);
407
+ return serialize(wrapper);
408
+ })
409
+ .trim();
410
+ } catch {
411
+ // Serializer unavailable (e.g. booting) — fall back to plain text.
412
+ return view.state.doc.textBetween(selection.from, selection.to, "\n");
413
+ }
414
+ };
415
+
416
+ // Update the ref so the selection-watch plugin always reads the latest closure.
417
+ serializeSliceRef.current = serializeSlice;
418
+
419
+ /** Parse `md` as markdown and replace the current selection; plain-text fallback. */
420
+ const parseAndReplace = (view: EditorView, md: string): void => {
421
+ const editor = getInstance();
422
+ if (!editor) return;
423
+ try {
424
+ editor.action((ctx) => {
425
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
426
+ const parse = (ctx as any).get(parserCtx) as (md: string) => ProseNode | null;
427
+ const parsed = parse(md);
428
+ if (!parsed) {
429
+ // Null parse result → plain-text insert.
430
+ view.dispatch(view.state.tr.insertText(md));
431
+ return;
432
+ }
433
+ // Replace the current selection with the parsed content.
434
+ const tr = view.state.tr.replaceSelectionWith(parsed);
435
+ view.dispatch(tr);
436
+ });
437
+ } catch {
438
+ // Parse failure → plain-text fallback (never blanks the doc, never throws).
439
+ try {
440
+ view.dispatch(view.state.tr.insertText(md));
441
+ } catch {
442
+ // Guard: if even insertText fails (e.g. read-only), silently no-op.
443
+ }
444
+ }
445
+ };
446
+
447
+ const getView = (): EditorView | null => {
448
+ const editor = getInstance();
449
+ if (!editor) return null;
450
+ try {
451
+ return editor.action((ctx) => ctx.get(editorViewCtx));
452
+ } catch {
453
+ return null;
454
+ }
455
+ };
456
+
457
+ const access = proseMirrorContentAccess({
458
+ getView,
459
+ getText: () => getInstance()?.action(getMarkdown()) ?? lastMarkdown.current,
460
+ serializeSlice,
461
+ parseAndReplace,
462
+ listeners: selectionListeners,
463
+ });
464
+
465
+ return {
466
+ // EditorContentAccess methods (via proseMirrorContentAccess).
467
+ getText: access.getText,
468
+ getSelection: access.getSelection,
469
+ replaceSelection: access.replaceSelection,
470
+ insertAtCursor: access.insertAtCursor,
471
+ focus: access.focus,
472
+ onSelectionChange: access.onSelectionChange,
473
+
474
+ // Markdown-specific methods.
475
+ getMarkdown: () => getInstance()?.action(getMarkdown()) ?? lastMarkdown.current,
476
+ serialized: () => getInstance()?.action(getMarkdown()) ?? null,
477
+
478
+ scrollToHeading: (slug: string) => {
479
+ const editor = getInstance();
480
+ if (editor) scrollHeadingBySlug(editor, slug);
481
+ },
482
+
483
+ revealLine: (line: number, _opts?: { center?: boolean }) => {
484
+ // WYSIWYG has no source map (Milkdown keeps no data-sourcepos), so this is
485
+ // best-effort: serialize the doc, resolve the nearest preceding heading via
486
+ // the shared outline parser, and scroll to it. No-op when none. (#273)
487
+ const editor = getInstance();
488
+ if (!editor) return;
489
+ const md = editor.action(getMarkdown());
490
+ const preceding = parseMarkdownOutline(md)
491
+ .filter((item) => item.line <= line)
492
+ .at(-1);
493
+ if (preceding) scrollHeadingBySlug(editor, preceding.id);
494
+ },
495
+ };
496
+ },
497
+ // eslint-disable-next-line react-hooks/exhaustive-deps
498
+ [getInstance],
499
+ );
500
+
501
+ return <Milkdown />;
502
+ });
503
+
504
+ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorProps>(
505
+ function MarkdownEditor(
506
+ {
507
+ value,
508
+ defaultValue,
509
+ onChange,
510
+ readOnly = false,
511
+ ariaLabel = "Markdown editor",
512
+ slashMenu = true,
513
+ calc,
514
+ completions,
515
+ onEmbedAsset,
516
+ className,
517
+ style,
518
+ ...props
519
+ },
520
+ ref,
521
+ ) {
522
+ // Capture the initial value once; later `value` changes flow through the
523
+ // controlled-sync effect, not a remount.
524
+ const initialValue = useRef(value ?? defaultValue ?? "").current;
525
+
526
+ return (
527
+ <div
528
+ data-testid="markdown-editor"
529
+ className={cn(
530
+ // A 1px hairline focus ring (not a heavy 2px ring) — the editable is a
531
+ // large surface, so a thinner edit-mode ring reads calmer while still
532
+ // meeting the visible-focus requirement (same `ring` token). (A7)
533
+ "milkdown-host overflow-auto rounded-md border border-border bg-background text-foreground focus-within:ring-1 focus-within:ring-ring",
534
+ className,
535
+ )}
536
+ // Publish the shared markdown scale as CSS vars the editor CSS reads, so the
537
+ // WYSIWYG headings/measure match the preview (single source of truth, #18).
538
+ style={{ ...markdownScaleVars(), ...style }}
539
+ {...props}
540
+ >
541
+ <MilkdownProvider>
542
+ <ProsemirrorAdapterProvider>
543
+ <MarkdownEditorView
544
+ ref={ref}
545
+ initialValue={initialValue}
546
+ value={value}
547
+ onChange={onChange}
548
+ readOnly={readOnly}
549
+ ariaLabel={ariaLabel}
550
+ slashMenu={slashMenu}
551
+ calc={calc}
552
+ completions={completions}
553
+ onEmbedAsset={onEmbedAsset}
554
+ />
555
+ </ProsemirrorAdapterProvider>
556
+ </MilkdownProvider>
557
+ </div>
558
+ );
559
+ },
560
+ );
@@ -0,0 +1,33 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Vendored + adapted from `@milkdown/react` (MIT — © 2020-present Mirone).
5
+ *
6
+ * `<MilkdownProvider>` holds the editor instance/loading state in context;
7
+ * `<Milkdown />` renders the root node the ProseMirror view mounts into.
8
+ */
9
+ import type { Editor } from "@milkdown/kit/core";
10
+ import { type FC, type ReactNode, useMemo, useRef, useState } from "react";
11
+
12
+ import type { EditorInfoCtx, GetEditor } from "./types";
13
+ import { editorInfoContext, useGetEditor } from "./use-get-editor";
14
+
15
+ export const Milkdown: FC = () => {
16
+ const domRef = useGetEditor();
17
+
18
+ return <div data-milkdown-root ref={domRef} />;
19
+ };
20
+
21
+ export const MilkdownProvider: FC<{ children: ReactNode }> = ({ children }) => {
22
+ const dom = useRef<HTMLDivElement | undefined>(undefined);
23
+ const [editorFactory, setEditorFactory] = useState<GetEditor | undefined>(undefined);
24
+ const editor = useRef<Editor | undefined>(undefined);
25
+ const [loading, setLoading] = useState(true);
26
+
27
+ const editorInfoCtx = useMemo<EditorInfoCtx>(
28
+ () => ({ loading, dom, editor, setLoading, editorFactory, setEditorFactory }),
29
+ [loading, editorFactory],
30
+ );
31
+
32
+ return <editorInfoContext.Provider value={editorInfoCtx}>{children}</editorInfoContext.Provider>;
33
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Vendored Milkdown ⇄ React integration (MIT — adapted from @milkdown/react,
3
+ * © 2020-present Mirone). Depends only on the headless `@milkdown/kit`.
4
+ */
5
+ export { Milkdown, MilkdownProvider } from "./editor";
6
+ export { useEditor } from "./use-editor";
7
+ export { useInstance, type Instance } from "./use-instance";
8
+ export type { GetEditor, UseEditorReturn, EditorInfoCtx } from "./types";
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Vendored + adapted from `@milkdown/react` (MIT — Copyright (c) 2020-present
3
+ * Mirone, https://github.com/Milkdown/milkdown).
4
+ *
5
+ * We vendor the ~120-line React integration so `@elabs-ai/components-editor` depends only on
6
+ * the headless `@milkdown/kit` — NOT `@milkdown/react`, which hard-depends on
7
+ * `@milkdown/crepe` (and therefore Vue 3). The upstream `GetEditor` union with
8
+ * `CrepeBuilder` is intentionally dropped: we never use Crepe.
9
+ */
10
+ import type { Editor } from "@milkdown/kit/core";
11
+ import type { Dispatch, RefObject, SetStateAction } from "react";
12
+
13
+ export type GetEditor = (container: HTMLElement) => Editor | undefined;
14
+
15
+ export interface UseEditorReturn {
16
+ readonly loading: boolean;
17
+ readonly get: () => Editor | undefined;
18
+ }
19
+
20
+ export interface EditorInfoCtx {
21
+ loading: boolean;
22
+ setLoading: Dispatch<SetStateAction<boolean>>;
23
+ dom: RefObject<HTMLDivElement | undefined>;
24
+ editor: RefObject<Editor | undefined>;
25
+ editorFactory: GetEditor | undefined;
26
+ setEditorFactory: Dispatch<SetStateAction<GetEditor | undefined>>;
27
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Vendored + adapted from `@milkdown/react` (MIT — © 2020-present Mirone).
3
+ *
4
+ * `useEditor(getEditor, deps)` registers the editor factory with the provider.
5
+ * `getEditor` is memoized on `deps`, so the mount effect runs exactly once per
6
+ * dependency change (re-creating the editor when `deps` change).
7
+ */
8
+ import { type DependencyList, useCallback, useContext, useLayoutEffect } from "react";
9
+
10
+ import type { GetEditor, UseEditorReturn } from "./types";
11
+ import { editorInfoContext } from "./use-get-editor";
12
+
13
+ export function useEditor(getEditor: GetEditor, deps: DependencyList = []): UseEditorReturn {
14
+ const editorInfo = useContext(editorInfoContext);
15
+
16
+ // eslint-disable-next-line react-hooks/exhaustive-deps
17
+ const factory = useCallback(getEditor, deps);
18
+
19
+ useLayoutEffect(() => {
20
+ editorInfo.setEditorFactory(() => factory);
21
+ }, [editorInfo, factory]);
22
+
23
+ return {
24
+ loading: editorInfo.loading,
25
+ get: () => editorInfo.editor.current,
26
+ };
27
+ }