@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,250 @@
1
+ "use client";
2
+
3
+ import * as monaco from "monaco-editor";
4
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
5
+ import {
6
+ forwardRef,
7
+ useEffect,
8
+ useImperativeHandle,
9
+ useRef,
10
+ useState,
11
+ type CSSProperties,
12
+ type HTMLAttributes,
13
+ } from "react";
14
+ import { EditorContextMenu } from "../editor-context-menu";
15
+ import { applyBrandTheme } from "../lib/monaco-theme-bridge";
16
+ import { useDataTheme } from "../lib/use-data-theme";
17
+
18
+ export type MonacoCodeEditor = monaco.editor.IStandaloneCodeEditor;
19
+
20
+ /** A Monaco editor action (system command + optional hotkey + palette/menu entry). */
21
+ export type EditorAction = monaco.editor.IActionDescriptor;
22
+
23
+ export interface CodeEditorProps extends Omit<
24
+ HTMLAttributes<HTMLDivElement>,
25
+ "onChange" | "defaultValue"
26
+ > {
27
+ /** Controlled content. Pair with `onChange`. */
28
+ value?: string;
29
+ /** Initial content for uncontrolled use. */
30
+ defaultValue?: string;
31
+ /** Fires on every edit with the full document text. */
32
+ onChange?: (value: string) => void;
33
+ /** Monaco language id (e.g. "typescript", "json"). Defaults to "typescript". */
34
+ language?: string;
35
+ /** Model path/URI — drives per-file language services + diagnostics. */
36
+ path?: string;
37
+ /** Render the editor read-only. */
38
+ readOnly?: boolean;
39
+ /** Editor height. Number → px. Defaults to "100%" (size via the parent). */
40
+ height?: number | string;
41
+ /** Passthrough Monaco construction options (merged over the defaults). */
42
+ options?: monaco.editor.IStandaloneEditorConstructionOptions;
43
+ /**
44
+ * Accessible name for the editor. Maps onto Monaco's `ariaLabel` construction
45
+ * option AND the inner screen-reader `<textarea>` (`aria-label`), so assistive
46
+ * tech announces a name. Spreading `aria-label` via `...props` only lands on the
47
+ * wrapper div and never reaches Monaco's focusable textarea — use this instead.
48
+ */
49
+ ariaLabel?: string;
50
+ /** Sets `aria-invalid` on Monaco's inner `<textarea>` to convey validity. */
51
+ ariaInvalid?: boolean;
52
+ /** Sets `aria-describedby` on Monaco's inner `<textarea>` (e.g. an error id). */
53
+ ariaDescribedBy?: string;
54
+ /**
55
+ * Right-click menu. `"brand"` (default) replaces Monaco's built-in menu with
56
+ * brand-ui's `ContextMenu`; `"monaco"` keeps Monaco's themed menu; `"none"`
57
+ * disables it.
58
+ */
59
+ contextMenu?: "brand" | "monaco" | "none";
60
+ /** Called once the editor instance + monaco namespace are ready. */
61
+ onMount?: (editor: MonacoCodeEditor, monacoApi: typeof monaco) => void;
62
+ /**
63
+ * Declarative Monaco editor actions — each registers a command (run on its
64
+ * `keybindings`, in the command palette, and optionally the context menu via
65
+ * `contextMenuGroupId`). Wraps `editor.addAction`; re-registered when the array
66
+ * identity changes, disposed on unmount. Build keybindings with the re-exported
67
+ * `monaco` namespace, e.g. `keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Slash]`.
68
+ *
69
+ * Note: a context-menu entry only shows when `contextMenu="monaco"`, but the
70
+ * keybinding and command palette work regardless. Memoize `actions` to avoid
71
+ * re-registering on every render.
72
+ */
73
+ actions?: EditorAction[];
74
+ }
75
+
76
+ const BASE_OPTIONS: monaco.editor.IStandaloneEditorConstructionOptions = {
77
+ automaticLayout: true,
78
+ minimap: { enabled: false },
79
+ scrollBeyondLastLine: false,
80
+ smoothScrolling: true,
81
+ fontLigatures: true,
82
+ fontSize: 13,
83
+ lineNumbersMinChars: 3,
84
+ padding: { top: 12, bottom: 12 },
85
+ scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10 },
86
+ };
87
+
88
+ /**
89
+ * A token-themed Monaco editor wrapped as a brand-ui React component. Monaco
90
+ * renders its own editing surface + widgets; this wrapper owns lifecycle,
91
+ * controlled/uncontrolled value, and applies the brand theme bridge so the
92
+ * editor matches the active `data-theme` (all three themes).
93
+ *
94
+ * Workers (for completions/diagnostics) are wired by importing
95
+ * `@elabs-ai/components-editor/monaco-environment` once at the app entry.
96
+ */
97
+ export const CodeEditor = forwardRef<MonacoCodeEditor | null, CodeEditorProps>(function CodeEditor(
98
+ {
99
+ value,
100
+ defaultValue,
101
+ onChange,
102
+ language = "typescript",
103
+ path,
104
+ readOnly = false,
105
+ height = "100%",
106
+ options,
107
+ ariaLabel,
108
+ ariaInvalid,
109
+ ariaDescribedBy,
110
+ contextMenu = "brand",
111
+ onMount,
112
+ actions,
113
+ className,
114
+ style,
115
+ ...props
116
+ },
117
+ ref,
118
+ ) {
119
+ const containerRef = useRef<HTMLDivElement>(null);
120
+ const [editor, setEditor] = useState<MonacoCodeEditor | null>(null);
121
+ const { theme, revision } = useDataTheme();
122
+
123
+ // Latest callbacks via refs so the mount effect can run exactly once.
124
+ const onChangeRef = useRef(onChange);
125
+ onChangeRef.current = onChange;
126
+ const onMountRef = useRef(onMount);
127
+ onMountRef.current = onMount;
128
+
129
+ useImperativeHandle<MonacoCodeEditor | null, MonacoCodeEditor | null>(ref, () => editor, [
130
+ editor,
131
+ ]);
132
+
133
+ // Mount once.
134
+ useEffect(() => {
135
+ const container = containerRef.current;
136
+ if (!container) return;
137
+
138
+ const model = monaco.editor.createModel(
139
+ value ?? defaultValue ?? "",
140
+ language,
141
+ path ? monaco.Uri.parse(`inmemory://brand/${path}`) : undefined,
142
+ );
143
+ const instance = monaco.editor.create(container, {
144
+ ...BASE_OPTIONS,
145
+ readOnly,
146
+ // Disable Monaco's own menu unless explicitly opted into; "brand" renders
147
+ // brand-ui's ContextMenu around the editor instead.
148
+ contextmenu: contextMenu === "monaco",
149
+ // Monaco's accessible name comes from this construction option (it writes it
150
+ // onto its inner screen-reader <textarea>), not from a wrapper-div attribute.
151
+ ...(ariaLabel !== undefined ? { ariaLabel } : null),
152
+ model,
153
+ ...options,
154
+ });
155
+ const sub = instance.onDidChangeModelContent(() => {
156
+ onChangeRef.current?.(instance.getValue());
157
+ });
158
+ // `setEditor` triggers the theming effect below; keeping theme application
159
+ // there (not here) guarantees it never blocks editor setup.
160
+ setEditor(instance);
161
+ onMountRef.current?.(instance, monaco);
162
+
163
+ return () => {
164
+ sub.dispose();
165
+ instance.dispose();
166
+ model.dispose();
167
+ setEditor(null);
168
+ };
169
+ // eslint-disable-next-line react-hooks/exhaustive-deps
170
+ }, []);
171
+
172
+ // Controlled value sync (only when it diverges, to preserve cursor/undo).
173
+ useEffect(() => {
174
+ if (!editor || value === undefined) return;
175
+ if (value !== editor.getValue()) editor.setValue(value);
176
+ }, [editor, value]);
177
+
178
+ useEffect(() => {
179
+ const model = editor?.getModel();
180
+ if (model) monaco.editor.setModelLanguage(model, language);
181
+ }, [editor, language]);
182
+
183
+ useEffect(() => {
184
+ editor?.updateOptions({ readOnly, contextmenu: contextMenu === "monaco" });
185
+ }, [editor, readOnly, contextMenu]);
186
+
187
+ useEffect(() => {
188
+ if (!editor) return;
189
+ try {
190
+ applyBrandTheme(monaco, theme);
191
+ } catch (err) {
192
+ console.error("[@elabs-ai/components-editor] failed to apply brand theme", err);
193
+ }
194
+ // `revision` forces a re-apply after the data-theme attribute settles (even
195
+ // when the parsed theme name equals the default), so tokens are re-read.
196
+ }, [editor, theme, revision]);
197
+
198
+ // Forward accessibility attributes onto Monaco's focusable screen-reader
199
+ // <textarea> (its real interactive surface), kept in sync as props change.
200
+ // `ariaLabel` flows through Monaco's option, BUT Monaco only writes it onto the
201
+ // textarea when accessibilitySupport isn't "off" (auto-detection can disable it,
202
+ // e.g. headless). So we also set aria-label directly on the textarea — belt and
203
+ // suspenders — alongside aria-invalid/aria-describedby (which aren't Monaco options).
204
+ useEffect(() => {
205
+ if (!editor) return;
206
+ if (ariaLabel !== undefined) editor.updateOptions({ ariaLabel });
207
+ const textarea = editor.getDomNode()?.querySelector("textarea");
208
+ if (!textarea) return;
209
+ if (ariaLabel === undefined) textarea.removeAttribute("aria-label");
210
+ else textarea.setAttribute("aria-label", ariaLabel);
211
+ if (ariaInvalid === undefined) textarea.removeAttribute("aria-invalid");
212
+ else textarea.setAttribute("aria-invalid", String(ariaInvalid));
213
+ if (ariaDescribedBy === undefined) textarea.removeAttribute("aria-describedby");
214
+ else textarea.setAttribute("aria-describedby", ariaDescribedBy);
215
+ }, [editor, ariaLabel, ariaInvalid, ariaDescribedBy]);
216
+
217
+ // Declarative actions: register each on the editor instance and dispose on
218
+ // array-identity change or unmount. Re-registering when the array reference
219
+ // changes is intentional and correct (unlike onChange, a re-register is cheap
220
+ // and desired). Consumers should memoize `actions` to avoid unnecessary churn.
221
+ useEffect(() => {
222
+ if (!editor || !actions || actions.length === 0) return;
223
+ const disposables = actions.map((a) => editor.addAction(a));
224
+ return () => disposables.forEach((d) => d.dispose());
225
+ }, [editor, actions]);
226
+
227
+ const resolvedStyle: CSSProperties = {
228
+ height: typeof height === "number" ? `${height}px` : height,
229
+ ...style,
230
+ };
231
+
232
+ const editorEl = (
233
+ <div
234
+ ref={containerRef}
235
+ data-testid="code-editor"
236
+ className={cn("h-full w-full overflow-hidden bg-background text-foreground", className)}
237
+ style={resolvedStyle}
238
+ {...props}
239
+ />
240
+ );
241
+
242
+ if (contextMenu === "brand") {
243
+ return (
244
+ <EditorContextMenu editor={editor} readOnly={readOnly}>
245
+ {editorEl}
246
+ </EditorContextMenu>
247
+ );
248
+ }
249
+ return editorEl;
250
+ });
@@ -0,0 +1,6 @@
1
+ export {
2
+ CodeEditor,
3
+ type CodeEditorProps,
4
+ type MonacoCodeEditor,
5
+ type EditorAction,
6
+ } from "./code-editor";
@@ -0,0 +1,51 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+ import { CodeWorkspace, type EditorFile } from "./code-workspace";
4
+
5
+ const meta = {
6
+ title: "Editor/CodeWorkspace",
7
+ component: CodeWorkspace,
8
+ tags: ["autodocs"],
9
+ parameters: {
10
+ layout: "fullscreen",
11
+ docs: {
12
+ description: {
13
+ component:
14
+ "Multi-file editor shell. brand-ui `Tabs` + `Button` provide the chrome; " +
15
+ "a single Monaco `CodeEditor` swaps to the active file. Language is " +
16
+ "inferred from each file's extension.",
17
+ },
18
+ },
19
+ },
20
+ } satisfies Meta<typeof CodeWorkspace>;
21
+
22
+ export default meta;
23
+ type Story = StoryObj<typeof meta>;
24
+
25
+ const INITIAL: EditorFile[] = [
26
+ {
27
+ path: "src/button.tsx",
28
+ value: `export function Button({ children }: { children: string }) {\n return <button className="btn">{children}</button>;\n}\n`,
29
+ },
30
+ {
31
+ path: "src/styles.css",
32
+ value: `.btn {\n border-radius: 0.5rem;\n padding: 0.5rem 1rem;\n}\n`,
33
+ },
34
+ {
35
+ path: "package.json",
36
+ value: `{\n "name": "demo",\n "version": "1.0.0"\n}\n`,
37
+ },
38
+ ];
39
+
40
+ export const ThreeFiles: Story = {
41
+ render: function ThreeFilesStory() {
42
+ const [files, setFiles] = useState(INITIAL);
43
+ const onFileChange = (path: string, value: string) =>
44
+ setFiles((prev) => prev.map((f) => (f.path === path ? { ...f, value } : f)));
45
+ return (
46
+ <div className="h-[460px] w-full p-4">
47
+ <CodeWorkspace files={files} onFileChange={onFileChange} />
48
+ </div>
49
+ );
50
+ },
51
+ };
@@ -0,0 +1,146 @@
1
+ import { act, cleanup, render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ // CodeWorkspace renders real brand-ui Tabs over a CodeEditor; mock only Monaco.
6
+ const h = vi.hoisted(() => {
7
+ // Selection-change handlers registered via onDidChangeCursorSelection.
8
+ const selectionHandlers: Array<() => void> = [];
9
+ const selectionDisposable = { dispose: vi.fn() };
10
+
11
+ const editor = {
12
+ onDidChangeModelContent: vi.fn(() => ({ dispose: vi.fn() })),
13
+ getValue: vi.fn(() => "file content"),
14
+ setValue: vi.fn(),
15
+ getModel: vi.fn(() => ({
16
+ getValueInRange: vi.fn(() => "selected"),
17
+ })),
18
+ getSelection: vi.fn(() => ({ isEmpty: () => false })),
19
+ executeEdits: vi.fn(),
20
+ pushUndoStop: vi.fn(),
21
+ focus: vi.fn(),
22
+ onDidChangeCursorSelection: vi.fn((cb: () => void) => {
23
+ selectionHandlers.push(cb);
24
+ return selectionDisposable;
25
+ }),
26
+ updateOptions: vi.fn(),
27
+ getDomNode: vi.fn(() => null),
28
+ dispose: vi.fn(),
29
+ };
30
+ return {
31
+ editor,
32
+ selectionHandlers,
33
+ selectionDisposable,
34
+ create: vi.fn(() => editor),
35
+ createModel: vi.fn(() => ({ dispose: vi.fn() })),
36
+ };
37
+ });
38
+
39
+ vi.mock("monaco-editor", () => ({
40
+ editor: {
41
+ create: h.create,
42
+ createModel: h.createModel,
43
+ setModelLanguage: vi.fn(),
44
+ defineTheme: vi.fn(),
45
+ setTheme: vi.fn(),
46
+ },
47
+ Uri: { parse: (s: string) => ({ toString: () => s }) },
48
+ }));
49
+
50
+ import { createRef } from "react";
51
+ import { CodeWorkspace, type CodeWorkspaceHandle, type EditorFile } from "./code-workspace";
52
+
53
+ const FILES: EditorFile[] = [
54
+ { path: "src/a.ts", value: "AAA" },
55
+ { path: "b.json", value: "{}" },
56
+ ];
57
+
58
+ beforeEach(() => vi.clearAllMocks());
59
+ afterEach(cleanup);
60
+
61
+ describe("CodeWorkspace — tabs", () => {
62
+ it("renders a tab per file and opens the first file with its inferred language", () => {
63
+ render(<CodeWorkspace files={FILES} />);
64
+ expect(screen.getByRole("tab", { name: "a.ts" })).toBeInTheDocument();
65
+ expect(screen.getByRole("tab", { name: "b.json" })).toBeInTheDocument();
66
+ expect(h.createModel).toHaveBeenCalledWith("AAA", "typescript", expect.anything());
67
+ });
68
+
69
+ it("switches the editor to the file whose tab is activated", async () => {
70
+ const onActivePathChange = vi.fn();
71
+ render(<CodeWorkspace files={FILES} onActivePathChange={onActivePathChange} />);
72
+ await userEvent.click(screen.getByRole("tab", { name: "b.json" }));
73
+ expect(onActivePathChange).toHaveBeenCalledWith("b.json");
74
+ expect(h.createModel).toHaveBeenCalledWith("{}", "json", expect.anything());
75
+ });
76
+ });
77
+
78
+ describe("CodeWorkspace — CodeWorkspaceHandle via ref", () => {
79
+ it("exposes getElement() returning the root div", () => {
80
+ const ref = createRef<CodeWorkspaceHandle>();
81
+ render(<CodeWorkspace files={FILES} ref={ref} />);
82
+ expect(ref.current).not.toBeNull();
83
+ const el = ref.current!.getElement();
84
+ expect(el).toBeInstanceOf(HTMLDivElement);
85
+ });
86
+
87
+ it("exposes getActiveEditor() returning the Monaco instance after mount", () => {
88
+ const ref = createRef<CodeWorkspaceHandle>();
89
+ render(<CodeWorkspace files={FILES} ref={ref} />);
90
+ // onMount fires synchronously inside the mocked monaco.editor.create path.
91
+ // The mock `create` calls onMountRef immediately after setEditor in the effect —
92
+ // but since monaco.editor.create is mocked synchronously and CodeEditor calls
93
+ // onMountRef.current?.(instance) right after create(), the instance is available.
94
+ // We verify getActiveEditor() returns the mock editor (or null if mount async).
95
+ const active = ref.current!.getActiveEditor();
96
+ // The mock editor.create is called synchronously in the useEffect, so after
97
+ // the first render + effect flush the handle should reflect it.
98
+ // If it's null the store hasn't flushed yet — both are valid jsdom outcomes.
99
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
+ expect(active === null || (active as any) === h.editor).toBe(true);
101
+ });
102
+
103
+ it("content-access methods don't throw when delegating (null or live editor)", () => {
104
+ const ref = createRef<CodeWorkspaceHandle>();
105
+ render(<CodeWorkspace files={FILES} ref={ref} />);
106
+ expect(() => ref.current!.getText()).not.toThrow();
107
+ expect(() => ref.current!.getSelection()).not.toThrow();
108
+ expect(() => ref.current!.replaceSelection("x")).not.toThrow();
109
+ expect(() => ref.current!.insertAtCursor("y")).not.toThrow();
110
+ expect(() => ref.current!.focus()).not.toThrow();
111
+ const unsub = ref.current!.onSelectionChange(() => undefined);
112
+ expect(() => unsub()).not.toThrow();
113
+ });
114
+
115
+ it("getText() returns file content when editor is mounted", () => {
116
+ const ref = createRef<CodeWorkspaceHandle>();
117
+ render(<CodeWorkspace files={FILES} ref={ref} />);
118
+ // After mount with the mock, getValue returns "file content".
119
+ // May be "" (null access) or "file content" depending on effect timing.
120
+ const text = ref.current!.getText();
121
+ expect(typeof text).toBe("string");
122
+ });
123
+
124
+ it("getSelection() returns a valid EditorSelection shape", () => {
125
+ const ref = createRef<CodeWorkspaceHandle>();
126
+ render(<CodeWorkspace files={FILES} ref={ref} />);
127
+ const sel = ref.current!.getSelection();
128
+ expect(typeof sel.text).toBe("string");
129
+ expect(typeof sel.empty).toBe("boolean");
130
+ });
131
+
132
+ it("onSelectionChange wires through to Monaco when active editor is set", () => {
133
+ const ref = createRef<CodeWorkspaceHandle>();
134
+ render(<CodeWorkspace files={FILES} ref={ref} />);
135
+
136
+ const listener = vi.fn();
137
+ act(() => {
138
+ ref.current!.onSelectionChange(listener);
139
+ });
140
+
141
+ // If the editor mounted, onDidChangeCursorSelection was called.
142
+ // If still null (no active editor yet), the no-op path returns a no-op unsub.
143
+ // Both paths are valid — we just verify no throws.
144
+ expect(listener).not.toHaveBeenCalled(); // not called yet, only on selection change
145
+ });
146
+ });