@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,787 @@
1
+ "use client";
2
+
3
+ /**
4
+ * MarkdownWorkspace — the hybrid markdown authoring surface for the Workbench.
5
+ *
6
+ * One markdown value, three modes (a @elabs-ai/components-ui ToggleGroup):
7
+ * - "source" : Monaco CodeEditor(markdown) + the MarkdownToolbar
8
+ * - "wysiwyg" : the Milkdown MarkdownEditor (direct manipulation)
9
+ * - "split" : source ↔ the branded MarkdownPreview (drag-resizable)
10
+ *
11
+ * The value is shared across modes, so switching is lossless. Controlled
12
+ * (`value`/`onChange`) or uncontrolled (`defaultValue`); same for `mode`.
13
+ */
14
+ import {
15
+ ResizableHandle,
16
+ ResizablePanel,
17
+ ResizablePanelGroup,
18
+ Toggle,
19
+ ToggleGroup,
20
+ ToggleGroupItem,
21
+ Tooltip,
22
+ TooltipContent,
23
+ TooltipProvider,
24
+ TooltipTrigger,
25
+ } from "@elabs-ai/components-ui";
26
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
27
+ import { Columns2, Eye, Focus, SquareCode } from "lucide-react";
28
+ import {
29
+ forwardRef,
30
+ useEffect,
31
+ useImperativeHandle,
32
+ useMemo,
33
+ useRef,
34
+ useState,
35
+ type HTMLAttributes,
36
+ type ReactNode,
37
+ } from "react";
38
+
39
+ import { attachCalcMonaco } from "../calc-block/calc-editor-monaco";
40
+ import type { CalcEditorHooks } from "../calc-block/types";
41
+ import { CodeEditor, type EditorAction, type MonacoCodeEditor } from "../code-editor";
42
+ import { attachCompletionsMonaco } from "../lib/editor-completions-monaco";
43
+ import type { EditorCompletionProvider } from "../lib/editor-completions";
44
+ import {
45
+ monacoContentAccess,
46
+ type EditorContentAccess,
47
+ type EditorSelection,
48
+ } from "../lib/editor-content-access";
49
+ import { parseFrontmatter } from "../lib/markdown/frontmatter";
50
+ import { mergeNormalizedEdit } from "../lib/markdown/merge";
51
+ import { MarkdownEditor, type MarkdownEditorHandle, type EmbedAssetFn } from "../markdown-editor";
52
+ import { parseMarkdownOutline } from "../markdown-outline";
53
+ import { BRAND_SLASH_COMMANDS, type SlashCommand } from "../markdown-editor/slash";
54
+ // MonacoSlashMenu + parseShortcut are imported from their files (NOT the slash
55
+ // barrel), which pull the Monaco runtime — the workspace already does too. Keeps
56
+ // the Milkdown-facing slash barrel Monaco-free. The pure `shortcut.ts` holds the
57
+ // default; `shortcut-monaco.ts` holds the Monaco-keybinding parser.
58
+ import { MonacoSlashMenu } from "../markdown-editor/slash/monaco-slash-menu";
59
+ import {
60
+ slashTriggerRange,
61
+ type SlashTriggerRange,
62
+ } from "../markdown-editor/slash/source-slash-trigger";
63
+ import { DEFAULT_SLASH_SHORTCUT } from "../markdown-editor/slash/shortcut";
64
+ import { parseShortcut } from "../markdown-editor/slash/shortcut-monaco";
65
+ import { MarkdownPreview } from "../markdown-preview";
66
+ import { MarkdownToolbar } from "../markdown-toolbar";
67
+ import { topLevelBlockOf, typewriterDelta } from "./focus-writing";
68
+
69
+ export type MarkdownWorkspaceMode = "source" | "wysiwyg" | "split";
70
+
71
+ /**
72
+ * Imperative handle exposed via `MarkdownWorkspace`'s `ref` (#273, DECISION A).
73
+ *
74
+ * Migration note: the forwarded ref type changed from `HTMLDivElement` to this
75
+ * handle. Replace any `ref.current` DOM access with `ref.current?.getElement()`.
76
+ *
77
+ * Extends {@link EditorContentAccess} — all AI content-access methods delegate to
78
+ * the active engine: Monaco (source/split) or the Milkdown WYSIWYG handle.
79
+ *
80
+ * **`onSelectionChange` caveat:** the subscription is scoped to the engine active at
81
+ * call time. A mode switch (source ↔ wysiwyg) does NOT auto-rebind the listener —
82
+ * re-subscribe from an effect whose deps include the mode. A self-rebinding v2 is a
83
+ * noted future enhancement, out of v1 scope.
84
+ */
85
+ export interface MarkdownWorkspaceHandle extends EditorContentAccess {
86
+ /**
87
+ * Scroll the active editor so FULL-SOURCE 1-based `line` is visible (Monaco
88
+ * coordinates). No-op (never throws) while the engine is booting or `line` is
89
+ * out of range. In WYSIWYG it is best-effort: resolves the nearest preceding
90
+ * heading via `parseMarkdownOutline` + `fmOffset`, then delegates to
91
+ * `scrollToHeading`. No-op if no preceding heading found.
92
+ */
93
+ revealLine(line: number, opts?: { center?: boolean }): void;
94
+ /**
95
+ * Scroll to a heading by its outline slug (same slugs as `DocumentOutline` /
96
+ * `useMarkdownOutline` / `parseMarkdownOutline`). In Source/Split mode
97
+ * resolves the line via `parseMarkdownOutline` then calls `revealLine`. In
98
+ * WYSIWYG delegates to the `MarkdownEditorHandle.scrollToHeading`.
99
+ */
100
+ scrollToHeading(slug: string): void;
101
+ /**
102
+ * The live Monaco source editor instance, or `null` when not mounted or when
103
+ * the active mode is `"wysiwyg"` (source pane not rendered).
104
+ */
105
+ getEditor(): MonacoCodeEditor | null;
106
+ /**
107
+ * The workspace root DOM element. Preserves the old `HTMLDivElement` ref
108
+ * access that existed before DECISION A (ref type change in #273).
109
+ */
110
+ getElement(): HTMLDivElement | null;
111
+ }
112
+
113
+ export interface MarkdownWorkspaceProps extends Omit<
114
+ HTMLAttributes<HTMLDivElement>,
115
+ "onChange" | "defaultValue"
116
+ > {
117
+ value?: string;
118
+ defaultValue?: string;
119
+ onChange?: (markdown: string) => void;
120
+ mode?: MarkdownWorkspaceMode;
121
+ defaultMode?: MarkdownWorkspaceMode;
122
+ onModeChange?: (mode: MarkdownWorkspaceMode) => void;
123
+ /**
124
+ * Start with FOCUS WRITING on (wysiwyg mode): typewriter scrolling keeps
125
+ * the caret vertically centered and inactive paragraphs dim. Toggleable in
126
+ * the editor's mode row.
127
+ */
128
+ defaultFocusWriting?: boolean;
129
+ /**
130
+ * The `/` command menu in the WYSIWYG (preview-edit) pane AND the Monaco source
131
+ * pane. `true` (default) uses the built-in brand commands; pass a config to
132
+ * extend/replace them, or `false` to disable. Forwarded to {@link MarkdownEditor}.
133
+ *
134
+ * `shortcut` (default `"Mod-Shift-O"`) opens the menu at the caret in BOTH panes —
135
+ * in the WYSIWYG pane via the ProseMirror plugin's `handleKeyDown`, and in the
136
+ * source/split pane via a `CodeEditor` action (no `/` is inserted into the
137
+ * doc). (#271)
138
+ */
139
+ slashMenu?: boolean | { commands?: SlashCommand[]; trigger?: string; shortcut?: string };
140
+ /**
141
+ * Customize the source / split toolbar's **Insert** menu (A4). Defaults to the
142
+ * same commands as the WYSIWYG slash menu (so `/calc`, `/iterate`, `/pivot` and
143
+ * any consumer commands are insertable in source mode too). Only commands with
144
+ * a `snippet` appear; pass your own list to override.
145
+ */
146
+ insertCommands?: SlashCommand[];
147
+ /**
148
+ * Opt-in calc authoring inside ```calc fences (off by default). Wired to BOTH
149
+ * surfaces: the Monaco source pane (highlight + autocomplete + result inlays)
150
+ * and the WYSIWYG pane (highlight + result inlays). Supply the consumer's
151
+ * `tokenize` / `evaluate` / `complete` hooks; the library bundles no calc engine.
152
+ */
153
+ calc?: CalcEditorHooks;
154
+ /**
155
+ * Declarative completion providers (#283) — e.g. `[[wikilink]]` autocomplete.
156
+ * Off by default; mirrors the `slashMenu`/`calc` opt-in pattern. The library
157
+ * owns the Monaco `registerCompletionItemProvider` registration lifecycle
158
+ * (registered once, refcounted across mounted workspaces, disposed with the
159
+ * last one — see `lib/editor-completions-monaco.ts`) for the Source/Split
160
+ * panes, and mirrors providers into the WYSIWYG pane via `MarkdownEditor`'s
161
+ * `completions` prop (a deliberately minimal mirror — see
162
+ * `markdown-editor/completions/completions-prose.ts` for the exact gaps).
163
+ * Zero `monaco-editor` imports needed in consumer code.
164
+ */
165
+ completions?: EditorCompletionProvider[];
166
+ /**
167
+ * Host-provided callback for image paste/drop embedding in the WYSIWYG pane.
168
+ * Forwarded to {@link MarkdownEditor}. See `MarkdownEditorProps.onEmbedAsset`
169
+ * for the full contract.
170
+ */
171
+ onEmbedAsset?: EmbedAssetFn;
172
+ /**
173
+ * Show the built-in "Focus writing" toggle in the WYSIWYG (preview-edit)
174
+ * toolbar row. `false` HIDES and DISABLES it (no keyboard path) and forces
175
+ * focus-writing OFF. `undefined`/`true` keep current behavior. The INITIAL
176
+ * on/off state still comes from `defaultFocusWriting`. (#270)
177
+ */
178
+ focusWriting?: boolean;
179
+ /**
180
+ * Show the built-in Source / Split / Preview-edit mode switch. `false` hides
181
+ * it in both toolbar branches so the host owns the view switch (controlled
182
+ * `mode`/`onModeChange` still drive the panes). Default `true`. (#272)
183
+ */
184
+ modeSwitch?: boolean;
185
+ /**
186
+ * Host-supplied controls rendered in the toolbar's trailing slot (where the
187
+ * built-in mode switch sits). Use with `modeSwitch={false}` to supply your
188
+ * own switch / actions. (#272)
189
+ */
190
+ toolbarActions?: ReactNode;
191
+ }
192
+
193
+ const MODES: { value: MarkdownWorkspaceMode; label: string; icon: typeof Eye }[] = [
194
+ { value: "source", label: "Source", icon: SquareCode },
195
+ { value: "split", label: "Split", icon: Columns2 },
196
+ { value: "wysiwyg", label: "Preview-edit", icon: Eye },
197
+ ];
198
+
199
+ export const MarkdownWorkspace = forwardRef<MarkdownWorkspaceHandle, MarkdownWorkspaceProps>(
200
+ function MarkdownWorkspace(
201
+ {
202
+ value,
203
+ defaultValue,
204
+ onChange,
205
+ mode,
206
+ defaultMode = "split",
207
+ onModeChange,
208
+ defaultFocusWriting = false,
209
+ focusWriting,
210
+ modeSwitch = true,
211
+ toolbarActions,
212
+ slashMenu = true,
213
+ insertCommands,
214
+ calc,
215
+ completions,
216
+ onEmbedAsset,
217
+ className,
218
+ ...props
219
+ },
220
+ ref,
221
+ ) {
222
+ // The source/split Insert menu defaults to the SAME commands as the WYSIWYG
223
+ // slash menu, so both surfaces insert the same blocks (A4).
224
+ const slashCommandList =
225
+ typeof slashMenu === "object" && slashMenu.commands
226
+ ? slashMenu.commands
227
+ : BRAND_SLASH_COMMANDS;
228
+ const toolbarInsertCommands = insertCommands ?? slashCommandList;
229
+ const isControlled = value !== undefined;
230
+ const [internalValue, setInternalValue] = useState(value ?? defaultValue ?? "");
231
+ const markdown = isControlled ? value : internalValue;
232
+
233
+ const [internalMode, setInternalMode] = useState<MarkdownWorkspaceMode>(defaultMode);
234
+ const activeMode = mode ?? internalMode;
235
+
236
+ const [monaco, setMonaco] = useState<MonacoCodeEditor | null>(null);
237
+ // Stable set of onSelectionChange listeners (engine-agnostic). The handle adds
238
+ // here; a binding effect forwards the active engine's selection events. (#AI)
239
+ const [selectionListeners] = useState(() => new Set<(sel: EditorSelection) => void>());
240
+
241
+ /* ------------------------- calc authoring (#220) ------------------------ */
242
+ // Read the calc hooks through a ref so a fresh `calc` object identity never
243
+ // re-attaches the Monaco layer; only toggling the feature on/off does.
244
+ const calcRef = useRef<CalcEditorHooks | undefined>(calc);
245
+ calcRef.current = calc;
246
+ const calcEnabled = calc != null;
247
+
248
+ useEffect(() => {
249
+ if (!monaco || !calcEnabled) return;
250
+ // Let calc completions surface as you type inside the fence (markdown
251
+ // otherwise suppresses quick suggestions outside comments/strings).
252
+ monaco.updateOptions({
253
+ quickSuggestions: { other: true, comments: false, strings: false },
254
+ });
255
+ return attachCalcMonaco(monaco, () => calcRef.current);
256
+ }, [monaco, calcEnabled]);
257
+
258
+ /* --------------------- completion providers (#283) ----------------------- */
259
+ // Read through a ref so a fresh `completions` array identity (a re-render)
260
+ // never re-attaches — the Monaco lifecycle (`attachCompletionsMonaco`) reads
261
+ // the LIVE list on every suggestion request; only mount/unmount and
262
+ // enabling/disabling the feature touch the effect.
263
+ const completionsRef = useRef<EditorCompletionProvider[] | undefined>(completions);
264
+ completionsRef.current = completions;
265
+ const completionsEnabled = completions != null;
266
+
267
+ useEffect(() => {
268
+ if (!monaco || !completionsEnabled) return;
269
+ return attachCompletionsMonaco(monaco, () => completionsRef.current);
270
+ }, [monaco, completionsEnabled]);
271
+
272
+ // The source CodeEditor unmounts when the active mode becomes WYSIWYG, but its
273
+ // `onMount` only fires on (re)mount — nothing clears the held instance. Drop it
274
+ // here so `getEditor()` honors its documented `null` contract in WYSIWYG and
275
+ // `revealLine`/`scrollToHeading` delegate to the WYSIWYG handle instead of
276
+ // acting on a disposed Monaco editor. (#271 review)
277
+ useEffect(() => {
278
+ if (activeMode === "wysiwyg") setMonaco(null);
279
+ }, [activeMode]);
280
+
281
+ /* --------- source-pane slash menu (#271) -------------------------------- */
282
+ const slashEnabled = slashMenu !== false;
283
+ // Honor an explicitly disabled shortcut (`shortcut: ""` / `shortcut: undefined`)
284
+ // the SAME way the WYSIWYG plugin does (`"shortcut" in options`), so both panes
285
+ // agree: only fall back to the default when the key is absent entirely. (#271 review)
286
+ const shortcut =
287
+ typeof slashMenu === "object" && "shortcut" in slashMenu
288
+ ? slashMenu.shortcut
289
+ : DEFAULT_SLASH_SHORTCUT;
290
+
291
+ const [sourceSlashOpen, setSourceSlashOpen] = useState(false);
292
+ // The model range of a typed `/` when the menu was opened by typing (not the
293
+ // hotkey). null for the hotkey path. Drives MonacoSlashMenu's `triggerRange`:
294
+ // on select the `/` is replaced by the block; on cancel it is removed.
295
+ const [typedTrigger, setTypedTrigger] = useState<SlashTriggerRange | null>(null);
296
+
297
+ // Close handler: clear the typed-trigger whenever the menu closes so a later
298
+ // hotkey-open doesn't inherit a stale range.
299
+ const handleSourceSlashOpenChange = (next: boolean) => {
300
+ setSourceSlashOpen(next);
301
+ if (!next) setTypedTrigger(null);
302
+ };
303
+
304
+ // Typing `/` at a line start (or after whitespace) opens the menu in the
305
+ // source pane — the conflict-free trigger that mirrors the WYSIWYG `/`. We
306
+ // watch model edits for a lone `/` insertion at a valid spot; our own
307
+ // insert/cancel edits are multi-char or empty, so they never re-trigger.
308
+ useEffect(() => {
309
+ if (!monaco || !slashEnabled) return;
310
+ const sub = monaco.onDidChangeModelContent((e) => {
311
+ if (sourceSlashOpen || e.changes.length !== 1) return;
312
+ const change = e.changes[0];
313
+ if (!change || change.text !== "/") return;
314
+ const model = monaco.getModel();
315
+ if (!model) return;
316
+ const line = change.range.startLineNumber;
317
+ const range = slashTriggerRange(line, model.getLineContent(line), change.range.startColumn);
318
+ if (!range) return;
319
+ setTypedTrigger(range);
320
+ setSourceSlashOpen(true);
321
+ });
322
+ return () => sub.dispose();
323
+ }, [monaco, slashEnabled, sourceSlashOpen]);
324
+
325
+ // A command works in the source pane when it has a text snippet OR an
326
+ // explicit source-pane handler (#299) — a run-only command whose ONLY
327
+ // handler is Milkdown's `run` (needs a Ctx unavailable in Monaco) is
328
+ // excluded; `runInSource` is exactly the escape hatch for that case.
329
+ const sourceCommands = useMemo(
330
+ () =>
331
+ slashCommandList.filter((c) => c.snippet != null || typeof c.runInSource === "function"),
332
+ [slashCommandList],
333
+ );
334
+
335
+ // A Layer-1 CodeEditor action that opens the source slash popup when fired
336
+ // (via its keybinding or the command palette). All KeyMod/KeyCode references
337
+ // live inside parseShortcut (slash/shortcut.ts) — NEVER reference a bare
338
+ // monaco.KeyMod here, because `monaco` is a state variable (the editor
339
+ // instance), not the namespace.
340
+ const sourceActions = useMemo<EditorAction[]>(
341
+ () =>
342
+ slashEnabled && shortcut
343
+ ? [
344
+ {
345
+ id: "brand.openSlashMenu",
346
+ label: "Insert block…",
347
+ keybindings: [parseShortcut(shortcut)],
348
+ // Hotkey open inserts at the caret (no typed `/` to replace).
349
+ run: () => {
350
+ setTypedTrigger(null);
351
+ setSourceSlashOpen(true);
352
+ },
353
+ },
354
+ ]
355
+ : [],
356
+ [slashEnabled, shortcut],
357
+ );
358
+
359
+ /* ------------------- split-view scroll synchronization ------------------ */
360
+ // Line-accurate (not percentage) sync: preview blocks carry
361
+ // `data-sourcepos` in frontmatter-STRIPPED coordinates; Monaco lines are
362
+ // full-source — bridge with the stripped-line offset.
363
+ const previewPaneRef = useRef<HTMLDivElement | null>(null);
364
+ const scrollLock = useRef<"editor" | "preview" | null>(null);
365
+ const lockTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
366
+
367
+ const fmOffset = useMemo(() => {
368
+ try {
369
+ const body = parseFrontmatter(markdown).content;
370
+ return markdown.split("\n").length - body.split("\n").length;
371
+ } catch {
372
+ return 0;
373
+ }
374
+ }, [markdown]);
375
+
376
+ const lock = (owner: "editor" | "preview") => {
377
+ scrollLock.current = owner;
378
+ if (lockTimer.current) clearTimeout(lockTimer.current);
379
+ lockTimer.current = setTimeout(() => {
380
+ scrollLock.current = null;
381
+ }, 150);
382
+ };
383
+
384
+ // Editor → preview.
385
+ useEffect(() => {
386
+ if (!monaco || activeMode !== "split") return;
387
+ const disposable = monaco.onDidScrollChange(() => {
388
+ if (scrollLock.current === "preview") return;
389
+ const range = monaco.getVisibleRanges()[0];
390
+ const host = previewPaneRef.current;
391
+ if (!range || !host) return;
392
+ const line = range.startLineNumber - fmOffset;
393
+ let target: HTMLElement | null = null;
394
+ for (const el of host.querySelectorAll<HTMLElement>("[data-sourcepos]")) {
395
+ const end = Number(el.dataset.sourcepos?.split(":")[1]);
396
+ if (end >= line) {
397
+ target = el;
398
+ break;
399
+ }
400
+ }
401
+ if (!target) return;
402
+ lock("editor");
403
+ host.scrollTop =
404
+ target.getBoundingClientRect().top -
405
+ host.getBoundingClientRect().top +
406
+ host.scrollTop -
407
+ 12;
408
+ });
409
+ return () => disposable.dispose();
410
+ }, [monaco, activeMode, fmOffset]);
411
+
412
+ // Preview → editor.
413
+ const onPreviewScroll = () => {
414
+ if (scrollLock.current === "editor" || !monaco || activeMode !== "split") return;
415
+ const host = previewPaneRef.current;
416
+ if (!host) return;
417
+ const hostTop = host.getBoundingClientRect().top;
418
+ for (const el of host.querySelectorAll<HTMLElement>("[data-sourcepos]")) {
419
+ if (el.getBoundingClientRect().bottom >= hostTop) {
420
+ const start = Number(el.dataset.sourcepos?.split(":")[0]);
421
+ if (!Number.isNaN(start)) {
422
+ lock("preview");
423
+ monaco.setScrollTop(monaco.getTopForLineNumber(Math.max(1, start + fmOffset)));
424
+ }
425
+ return;
426
+ }
427
+ }
428
+ };
429
+
430
+ const setMarkdown = (next: string) => {
431
+ if (!isControlled) setInternalValue(next);
432
+ onChange?.(next);
433
+ };
434
+
435
+ /* ------------------ lossless WYSIWYG editing (WI-1) ------------------ */
436
+ // Milkdown re-serializes the WHOLE document on every edit, normalizing
437
+ // formatting the user never touched — a one-line edit became a whole-file
438
+ // diff. Capture the editor's pre-edit serialization as a BASELINE and
439
+ // merge each emission back onto the byte-exact original: unedited blocks
440
+ // keep their original bytes.
441
+ const wysiwygRef = useRef<MarkdownEditorHandle | null>(null);
442
+ const wysiwygBase = useRef<{ original: string; baseline: string | null } | null>(null);
443
+
444
+ useEffect(() => {
445
+ if (activeMode !== "wysiwyg") {
446
+ wysiwygBase.current = null;
447
+ return;
448
+ }
449
+ // Capture at mode entry; the editor is uncontrolled while in wysiwyg,
450
+ // so the workspace buffer is the only writer.
451
+ const original = markdown;
452
+ wysiwygBase.current = { original, baseline: null };
453
+ const poll = setInterval(() => {
454
+ const base = wysiwygBase.current;
455
+ if (!base || base.baseline !== null) {
456
+ clearInterval(poll);
457
+ return;
458
+ }
459
+ const s = wysiwygRef.current?.serialized();
460
+ if (s != null) {
461
+ base.baseline = s;
462
+ clearInterval(poll);
463
+ }
464
+ }, 50);
465
+ const stop = setTimeout(() => clearInterval(poll), 5000);
466
+ return () => {
467
+ clearInterval(poll);
468
+ clearTimeout(stop);
469
+ };
470
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- capture markdown at ENTRY only
471
+ }, [activeMode]);
472
+
473
+ const onWysiwygChange = (emitted: string) => {
474
+ const base = wysiwygBase.current;
475
+ // Baseline captured before the first keystroke → merge; otherwise fall
476
+ // back to the raw emission (rare boot race — old behavior, never worse).
477
+ const next =
478
+ base && base.baseline !== null
479
+ ? mergeNormalizedEdit(base.original, base.baseline, emitted)
480
+ : emitted;
481
+ setMarkdown(next);
482
+ };
483
+
484
+ /* ---------------- focus writing (Ulysses Phase A, wysiwyg) ---------------- */
485
+ // Typewriter scrolling + paragraph focus, driven from OUTSIDE the engine:
486
+ // selectionchange marks the active top-level block (CSS dims the rest);
487
+ // right after typing, the pane re-centers the caret into a middle band.
488
+ const focusWritingEnabled = focusWriting !== false;
489
+ const [focusWritingOn, setFocusWritingOn] = useState(
490
+ focusWritingEnabled ? defaultFocusWriting : false,
491
+ );
492
+ const wysiwygPaneRef = useRef<HTMLDivElement | null>(null);
493
+ const lastInputAt = useRef(0);
494
+
495
+ useEffect(() => {
496
+ if (!(focusWritingEnabled && focusWritingOn && activeMode === "wysiwyg")) return;
497
+ const pane = wysiwygPaneRef.current;
498
+ if (!pane) return;
499
+ let active: Element | null = null;
500
+
501
+ const onSelectionChange = () => {
502
+ const root = pane.querySelector<HTMLElement>(".ProseMirror");
503
+ if (!root) return;
504
+ const sel = document.getSelection();
505
+ const node = sel?.anchorNode ?? null;
506
+ if (!node || !root.contains(node)) return;
507
+ const block = topLevelBlockOf(root, node);
508
+ if (block !== active) {
509
+ active?.classList.remove("wb-fw-active");
510
+ block?.classList.add("wb-fw-active");
511
+ active = block;
512
+ }
513
+ // Re-center only right after typing — a mouse click must not yank
514
+ // the viewport (Ulysses recenters while WRITING, not while aiming).
515
+ if (Date.now() - lastInputAt.current < 200 && sel && sel.rangeCount > 0) {
516
+ const range = sel.getRangeAt(0).getBoundingClientRect();
517
+ const caret = range.height > 0 ? range : (active?.getBoundingClientRect() ?? range);
518
+ const host = pane.getBoundingClientRect();
519
+ const delta = typewriterDelta(caret.top, caret.height, host.top, host.height);
520
+ if (delta !== 0) pane.scrollTop += delta;
521
+ }
522
+ };
523
+ const onInput = () => {
524
+ lastInputAt.current = Date.now();
525
+ };
526
+
527
+ document.addEventListener("selectionchange", onSelectionChange);
528
+ pane.addEventListener("input", onInput, true);
529
+ onSelectionChange();
530
+ return () => {
531
+ document.removeEventListener("selectionchange", onSelectionChange);
532
+ pane.removeEventListener("input", onInput, true);
533
+ active?.classList.remove("wb-fw-active");
534
+ };
535
+ }, [focusWritingEnabled, focusWritingOn, activeMode]);
536
+
537
+ /* ------------------- imperative handle (#273, DECISION A) --------------- */
538
+ // The forwarded ref is now a MarkdownWorkspaceHandle (not the div).
539
+ // The root <div> gets rootRef; getElement() returns rootRef.current.
540
+ const rootRef = useRef<HTMLDivElement | null>(null);
541
+
542
+ useImperativeHandle(
543
+ ref,
544
+ () => {
545
+ const revealLine = (n: number, opts?: { center?: boolean }) => {
546
+ if (monaco) {
547
+ // Source / Split: Monaco exact-line reveal.
548
+ const max = monaco.getModel()?.getLineCount() ?? 0;
549
+ if (n < 1 || n > max) return;
550
+ if (opts?.center === false) {
551
+ monaco.revealLine(n);
552
+ } else {
553
+ monaco.revealLineInCenter(n);
554
+ }
555
+ } else {
556
+ // WYSIWYG: best-effort — find the nearest preceding heading whose
557
+ // (stripped) line + fmOffset ≤ n, then delegate to scrollToHeading.
558
+ const items = parseMarkdownOutline(markdown);
559
+ // items.line is frontmatter-stripped (1-based); full-source = line + fmOffset
560
+ const preceding = items.filter((item) => item.line + fmOffset <= n).at(-1);
561
+ if (!preceding) return;
562
+ wysiwygRef.current?.scrollToHeading(preceding.id);
563
+ }
564
+ };
565
+
566
+ const scrollToHeading = (slug: string) => {
567
+ if (monaco) {
568
+ // Source / Split: resolve stripped line via outline, lift to full-source.
569
+ const item = parseMarkdownOutline(markdown).find((i) => i.id === slug);
570
+ if (!item) return;
571
+ revealLine(item.line + fmOffset, { center: true });
572
+ } else {
573
+ // WYSIWYG: delegate to the Milkdown handle.
574
+ wysiwygRef.current?.scrollToHeading(slug);
575
+ }
576
+ };
577
+
578
+ // Content-access delegation: monaco (source/split) → monacoContentAccess;
579
+ // wysiwyg → the MarkdownEditorHandle (which now IS an EditorContentAccess).
580
+ // If both are null (booting), fall back to best-effort no-ops (never throw).
581
+ const getAccess = (): EditorContentAccess | null => {
582
+ if (monaco) return monacoContentAccess(monaco);
583
+ if (wysiwygRef.current) return wysiwygRef.current;
584
+ return null;
585
+ };
586
+
587
+ return {
588
+ revealLine,
589
+ scrollToHeading,
590
+ getEditor: () => monaco,
591
+ getElement: () => rootRef.current,
592
+
593
+ // EditorContentAccess — read/write delegate to the active engine at call
594
+ // time (the AI acts after mount, so a call-time snapshot is correct here).
595
+ getText: () => getAccess()?.getText() ?? "",
596
+ getSelection: () => getAccess()?.getSelection() ?? { text: "", empty: true },
597
+ replaceSelection: (text: string) => getAccess()?.replaceSelection(text),
598
+ insertAtCursor: (text: string) => getAccess()?.insertAtCursor(text),
599
+ focus: () => getAccess()?.focus(),
600
+ // onSelectionChange uses the STABLE listener set (not getAccess()) so a
601
+ // subscribe-in-mount-effect survives the editor's async mount + mode
602
+ // switches; the binding effect below forwards the active engine's events.
603
+ onSelectionChange: (listener) => {
604
+ selectionListeners.add(listener);
605
+ return () => selectionListeners.delete(listener);
606
+ },
607
+ };
608
+ },
609
+ // Re-create when the things the methods close over change.
610
+ // eslint-disable-next-line react-hooks/exhaustive-deps
611
+ [monaco, markdown, fmOffset, activeMode, selectionListeners],
612
+ );
613
+
614
+ // Forward the ACTIVE engine's selection changes into the stable listener set,
615
+ // re-binding when the engine (monaco/wysiwyg) or mode changes. This is what
616
+ // makes the handle's onSelectionChange robust to the editor's async mount.
617
+ useEffect(() => {
618
+ let unsub: (() => void) | undefined;
619
+ if (monaco) {
620
+ unsub = monacoContentAccess(monaco).onSelectionChange((sel) =>
621
+ selectionListeners.forEach((l) => l(sel)),
622
+ );
623
+ } else if (activeMode === "wysiwyg" && wysiwygRef.current) {
624
+ unsub = wysiwygRef.current.onSelectionChange((sel) =>
625
+ selectionListeners.forEach((l) => l(sel)),
626
+ );
627
+ }
628
+ return () => unsub?.();
629
+ }, [monaco, activeMode, selectionListeners]);
630
+
631
+ const setMode = (next: string) => {
632
+ if (next !== "source" && next !== "split" && next !== "wysiwyg") return;
633
+ if (!mode) setInternalMode(next);
634
+ onModeChange?.(next);
635
+ };
636
+
637
+ const modeToggle = (
638
+ <TooltipProvider delayDuration={300}>
639
+ {/* Segmented mode switch — same recessed-track / raised-segment
640
+ grammar as every other mode control (Tabs, Read/Write). */}
641
+ <ToggleGroup
642
+ type="single"
643
+ value={activeMode}
644
+ onValueChange={setMode}
645
+ variant="segmented"
646
+ size="sm"
647
+ className="rounded-md p-0.5"
648
+ >
649
+ {MODES.map(({ value: m, label, icon: Icon }) => (
650
+ <Tooltip key={m}>
651
+ <TooltipTrigger asChild>
652
+ <ToggleGroupItem
653
+ value={m}
654
+ aria-label={label}
655
+ className="h-6 min-w-7 rounded-[5px] px-2"
656
+ >
657
+ <Icon className="size-4" />
658
+ </ToggleGroupItem>
659
+ </TooltipTrigger>
660
+ <TooltipContent>{label}</TooltipContent>
661
+ </Tooltip>
662
+ ))}
663
+ </ToggleGroup>
664
+ </TooltipProvider>
665
+ );
666
+
667
+ const trailing = (
668
+ <>
669
+ {modeSwitch ? modeToggle : null}
670
+ {toolbarActions}
671
+ </>
672
+ );
673
+
674
+ const sourcePane = (
675
+ <CodeEditor
676
+ language="markdown"
677
+ value={markdown}
678
+ onChange={setMarkdown}
679
+ actions={sourceActions}
680
+ onMount={(editor) => {
681
+ // Markdown is prose: soft-wrap so the source pane lays out like the
682
+ // preview (line-based scroll sync stays accurate either way, but
683
+ // matching layouts keep the two panes visually in step).
684
+ editor.updateOptions({ wordWrap: "on" });
685
+ setMonaco(editor);
686
+ }}
687
+ />
688
+ );
689
+
690
+ return (
691
+ <div
692
+ ref={rootRef}
693
+ data-testid="markdown-workspace"
694
+ className={cn("flex h-full min-h-0 flex-col overflow-hidden", className)}
695
+ {...props}
696
+ >
697
+ {activeMode === "wysiwyg" ? (
698
+ <div className="flex h-10 shrink-0 items-center justify-end gap-2 border-b border-border bg-surface px-2">
699
+ {focusWritingEnabled ? (
700
+ <TooltipProvider delayDuration={300}>
701
+ <Tooltip>
702
+ <TooltipTrigger asChild>
703
+ <Toggle
704
+ size="sm"
705
+ pressed={focusWritingOn}
706
+ onPressedChange={setFocusWritingOn}
707
+ aria-label="Focus writing"
708
+ className="h-6 gap-1.5 px-2 text-caption"
709
+ >
710
+ <Focus className="size-3.5" aria-hidden="true" /> Focus
711
+ </Toggle>
712
+ </TooltipTrigger>
713
+ <TooltipContent>Typewriter scrolling · inactive paragraphs dim</TooltipContent>
714
+ </Tooltip>
715
+ </TooltipProvider>
716
+ ) : null}
717
+ {trailing}
718
+ </div>
719
+ ) : (
720
+ <MarkdownToolbar
721
+ editor={monaco}
722
+ actions={trailing}
723
+ insertCommands={toolbarInsertCommands}
724
+ />
725
+ )}
726
+
727
+ <div className="min-h-0 flex-1">
728
+ {activeMode === "source" ? sourcePane : null}
729
+
730
+ {activeMode === "wysiwyg" ? (
731
+ <div
732
+ ref={wysiwygPaneRef}
733
+ data-focus-writing={focusWritingEnabled && focusWritingOn ? "" : undefined}
734
+ className="h-full overflow-auto p-4"
735
+ >
736
+ {/* UNCONTROLLED while in wysiwyg: feeding the merged buffer back
737
+ would replaceAll on every keystroke (cursor loss + echo
738
+ loops). The buffer receives merged text via onWysiwygChange;
739
+ mode switches remount the editor from the buffer. */}
740
+ <MarkdownEditor
741
+ ref={wysiwygRef}
742
+ defaultValue={markdown}
743
+ onChange={onWysiwygChange}
744
+ slashMenu={slashMenu}
745
+ calc={calc}
746
+ completions={completions}
747
+ onEmbedAsset={onEmbedAsset}
748
+ className="border-0"
749
+ />
750
+ </div>
751
+ ) : null}
752
+
753
+ {activeMode === "split" ? (
754
+ <ResizablePanelGroup direction="horizontal">
755
+ <ResizablePanel defaultSize={50} minSize={25}>
756
+ {sourcePane}
757
+ </ResizablePanel>
758
+ <ResizableHandle withHandle />
759
+ <ResizablePanel defaultSize={50} minSize={25}>
760
+ <div
761
+ ref={previewPaneRef}
762
+ onScroll={onPreviewScroll}
763
+ className="h-full overflow-auto p-5"
764
+ >
765
+ <MarkdownPreview>{markdown}</MarkdownPreview>
766
+ </div>
767
+ </ResizablePanel>
768
+ </ResizablePanelGroup>
769
+ ) : null}
770
+ </div>
771
+
772
+ {/* Source-pane slash popup (#271): shown when the Layer-1 action fires
773
+ in source or split mode. Uses fixed positioning anchored to the caret
774
+ via getScrolledVisiblePosition, so it works in both layouts. */}
775
+ {monaco && (activeMode === "source" || activeMode === "split") && slashEnabled ? (
776
+ <MonacoSlashMenu
777
+ editor={monaco}
778
+ commands={sourceCommands}
779
+ open={sourceSlashOpen}
780
+ onOpenChange={handleSourceSlashOpenChange}
781
+ triggerRange={typedTrigger}
782
+ />
783
+ ) : null}
784
+ </div>
785
+ );
786
+ },
787
+ );