@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,253 @@
1
+ "use client";
2
+
3
+ import { Tabs, TabsList, TabsTrigger } from "@elabs-ai/components-ui";
4
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
5
+ import {
6
+ forwardRef,
7
+ useCallback,
8
+ useEffect,
9
+ useImperativeHandle,
10
+ useRef,
11
+ useState,
12
+ type CSSProperties,
13
+ type HTMLAttributes,
14
+ } from "react";
15
+ import { CodeEditor, type MonacoCodeEditor } from "../code-editor";
16
+ import { CopyButton } from "../copy-button";
17
+ import {
18
+ monacoContentAccess,
19
+ type EditorContentAccess,
20
+ type EditorSelection,
21
+ } from "../lib/editor-content-access";
22
+
23
+ export interface EditorFile {
24
+ /** Unique path/name; shown (basename) on the tab and used as the model path. */
25
+ path: string;
26
+ /** Monaco language id. Inferred from the extension when omitted. */
27
+ language?: string;
28
+ /** File content. */
29
+ value: string;
30
+ }
31
+
32
+ export interface CodeWorkspaceProps extends Omit<
33
+ HTMLAttributes<HTMLDivElement>,
34
+ "onChange" | "defaultValue"
35
+ > {
36
+ /** Open files, one tab each. */
37
+ files: EditorFile[];
38
+ /** Controlled active file path. */
39
+ activePath?: string;
40
+ /** Initial active path for uncontrolled use. Defaults to the first file. */
41
+ defaultActivePath?: string;
42
+ /** Called when the active tab changes. */
43
+ onActivePathChange?: (path: string) => void;
44
+ /** Called on every edit with the file path + new content. */
45
+ onFileChange?: (path: string, value: string) => void;
46
+ /** Render all files read-only. */
47
+ readOnly?: boolean;
48
+ /** Workspace height. Number → px. Defaults to "100%". */
49
+ height?: number | string;
50
+ }
51
+
52
+ const EXT_LANGUAGE: Record<string, string> = {
53
+ ts: "typescript",
54
+ tsx: "typescript",
55
+ mts: "typescript",
56
+ cts: "typescript",
57
+ js: "javascript",
58
+ jsx: "javascript",
59
+ mjs: "javascript",
60
+ cjs: "javascript",
61
+ json: "json",
62
+ css: "css",
63
+ scss: "scss",
64
+ less: "less",
65
+ html: "html",
66
+ md: "markdown",
67
+ py: "python",
68
+ sql: "sql",
69
+ yml: "yaml",
70
+ yaml: "yaml",
71
+ xml: "xml",
72
+ sh: "shell",
73
+ };
74
+
75
+ const basename = (path: string) => path.split("/").pop() || path;
76
+
77
+ function inferLanguage(file: EditorFile): string {
78
+ if (file.language) return file.language;
79
+ const ext = file.path.split(".").pop()?.toLowerCase() ?? "";
80
+ return EXT_LANGUAGE[ext] ?? "plaintext";
81
+ }
82
+
83
+ /**
84
+ * Imperative handle exposed via `CodeWorkspace`'s `ref`.
85
+ *
86
+ * **Migration note:** the forwarded `ref` type changed from `HTMLDivElement` to
87
+ * `CodeWorkspaceHandle` (mirrors the #273 `MarkdownWorkspaceHandle` precedent).
88
+ * Replace any `ref.current` / `ref.current.scrollIntoView()` DOM access with
89
+ * `ref.current?.getElement()`. This is the only breaking change in this work.
90
+ */
91
+ export interface CodeWorkspaceHandle extends EditorContentAccess {
92
+ /**
93
+ * The live Monaco editor for the currently active tab, or `null` while booting
94
+ * or when no files are open. Consumers needing the full Monaco API can use this
95
+ * and pass it to `monacoContentAccess(handle.getActiveEditor()!)`.
96
+ */
97
+ getActiveEditor(): MonacoCodeEditor | null;
98
+ /**
99
+ * The workspace root DOM element. Preserves the old `HTMLDivElement` ref access
100
+ * that existed before the ref-type change (#273 `MarkdownWorkspace` playbook).
101
+ */
102
+ getElement(): HTMLDivElement | null;
103
+ }
104
+
105
+ /** No-op EditorContentAccess returned when no Monaco editor is mounted. */
106
+ const NULL_ACCESS: EditorContentAccess = {
107
+ getText: () => "",
108
+ getSelection: () => ({ text: "", empty: true }),
109
+ replaceSelection: () => undefined,
110
+ insertAtCursor: () => undefined,
111
+ focus: () => undefined,
112
+ onSelectionChange: () => () => undefined,
113
+ };
114
+
115
+ /**
116
+ * Multi-file editor shell: brand-ui `Tabs` for the file bar above a single
117
+ * {@link CodeEditor} that swaps to the active file. This is the "reuse our
118
+ * components for the chrome" surface — Tabs/Button are ours; the editing surface
119
+ * is Monaco. Edits bubble through `onFileChange`.
120
+ *
121
+ * The forwarded `ref` exposes a {@link CodeWorkspaceHandle} (not `HTMLDivElement`).
122
+ * Use `ref.current?.getElement()` to access the root DOM node.
123
+ */
124
+ export const CodeWorkspace = forwardRef<CodeWorkspaceHandle, CodeWorkspaceProps>(
125
+ function CodeWorkspace(
126
+ {
127
+ files,
128
+ activePath,
129
+ defaultActivePath,
130
+ onActivePathChange,
131
+ onFileChange,
132
+ readOnly = false,
133
+ height = "100%",
134
+ className,
135
+ style,
136
+ ...props
137
+ },
138
+ ref,
139
+ ) {
140
+ const [internalActive, setInternalActive] = useState(defaultActivePath ?? files[0]?.path ?? "");
141
+ const isControlled = activePath !== undefined;
142
+ const requested = isControlled ? activePath : internalActive;
143
+ const activeFile = files.find((f) => f.path === requested) ?? files[0];
144
+ const active = activeFile?.path ?? "";
145
+
146
+ // The root div ref (getElement() preserves old HTMLDivElement access).
147
+ const rootRef = useRef<HTMLDivElement | null>(null);
148
+
149
+ // The live Monaco editor for the active tab. Nulled when there is no active file.
150
+ const [activeEditor, setActiveEditor] = useState<MonacoCodeEditor | null>(null);
151
+
152
+ // Defensive: if the active file is removed (no `activeFile`), null the held instance
153
+ // so getActiveEditor() and content-access methods don't act on a disposed editor.
154
+ useEffect(() => {
155
+ if (!activeFile) setActiveEditor(null);
156
+ }, [activeFile]);
157
+
158
+ // A STABLE listener set for onSelectionChange. The handle adds/removes here;
159
+ // the binding effect below forwards the ACTIVE editor's selection changes into
160
+ // it. This is what makes a subscribe-in-mount-effect survive the editor's async
161
+ // mount (and tab swaps): the consumer subscribes once; binding re-attaches as
162
+ // `activeEditor` changes. (Delegating onSelectionChange to a call-time snapshot
163
+ // would bind to the not-yet-mounted editor and silently never fire.)
164
+ const [selectionListeners] = useState(() => new Set<(sel: EditorSelection) => void>());
165
+ useEffect(() => {
166
+ if (!activeEditor) return;
167
+ const unsub = monacoContentAccess(activeEditor).onSelectionChange((sel) =>
168
+ selectionListeners.forEach((l) => l(sel)),
169
+ );
170
+ return unsub;
171
+ }, [activeEditor, selectionListeners]);
172
+
173
+ const selectTab = useCallback(
174
+ (path: string) => {
175
+ if (!isControlled) setInternalActive(path);
176
+ onActivePathChange?.(path);
177
+ },
178
+ [isControlled, onActivePathChange],
179
+ );
180
+
181
+ useImperativeHandle(ref, () => {
182
+ const access = activeEditor ? monacoContentAccess(activeEditor) : NULL_ACCESS;
183
+ return {
184
+ getActiveEditor: () => activeEditor,
185
+ getElement: () => rootRef.current,
186
+ getText: () => access.getText(),
187
+ getSelection: () => access.getSelection(),
188
+ replaceSelection: (text: string) => access.replaceSelection(text),
189
+ insertAtCursor: (text: string) => access.insertAtCursor(text),
190
+ focus: () => access.focus(),
191
+ // Stable set (not the call-time `access`) so a subscription survives the
192
+ // editor's async mount + tab swaps; the binding effect forwards events.
193
+ onSelectionChange: (listener) => {
194
+ selectionListeners.add(listener);
195
+ return () => selectionListeners.delete(listener);
196
+ },
197
+ };
198
+ }, [activeEditor, selectionListeners]);
199
+
200
+ const resolvedStyle: CSSProperties = {
201
+ height: typeof height === "number" ? `${height}px` : height,
202
+ ...style,
203
+ };
204
+
205
+ return (
206
+ <div
207
+ ref={rootRef}
208
+ className={cn(
209
+ "flex flex-col overflow-hidden rounded-lg border border-border bg-surface",
210
+ className,
211
+ )}
212
+ style={resolvedStyle}
213
+ {...props}
214
+ >
215
+ <Tabs
216
+ value={active}
217
+ onValueChange={selectTab}
218
+ className="flex min-h-0 flex-1 flex-col gap-0"
219
+ >
220
+ <div className="flex items-center border-b border-border bg-surface">
221
+ <TabsList className="h-9 flex-1 justify-start gap-0 rounded-none bg-transparent p-0">
222
+ {files.map((file) => (
223
+ <TabsTrigger
224
+ key={file.path}
225
+ value={file.path}
226
+ className="h-9 rounded-none border-e border-border px-3 text-xs data-[state=active]:bg-background data-[state=active]:shadow-none"
227
+ >
228
+ {basename(file.path)}
229
+ </TabsTrigger>
230
+ ))}
231
+ </TabsList>
232
+ {activeFile ? <CopyButton value={activeFile.value} className="me-1.5" /> : null}
233
+ </div>
234
+
235
+ <div className="min-h-0 flex-1">
236
+ {activeFile ? (
237
+ <CodeEditor
238
+ key={activeFile.path}
239
+ path={activeFile.path}
240
+ language={inferLanguage(activeFile)}
241
+ value={activeFile.value}
242
+ readOnly={readOnly}
243
+ onChange={(next) => onFileChange?.(activeFile.path, next)}
244
+ onMount={(editor) => setActiveEditor(editor)}
245
+ height="100%"
246
+ />
247
+ ) : null}
248
+ </div>
249
+ </Tabs>
250
+ </div>
251
+ );
252
+ },
253
+ );
@@ -0,0 +1,6 @@
1
+ export {
2
+ CodeWorkspace,
3
+ type CodeWorkspaceHandle,
4
+ type CodeWorkspaceProps,
5
+ type EditorFile,
6
+ } from "./code-workspace";
@@ -0,0 +1,51 @@
1
+ "use client";
2
+
3
+ import { Button, useCopyToClipboard } from "@elabs-ai/components-ui";
4
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
5
+ import { CheckIcon, CopyIcon } from "lucide-react";
6
+ import { useCallback, type ComponentProps } from "react";
7
+
8
+ export interface CopyButtonProps extends Omit<ComponentProps<typeof Button>, "value"> {
9
+ /** Text written to the clipboard on click. */
10
+ value: string;
11
+ /** Show the "Copy" / "Copied" label next to the icon. Defaults to true. */
12
+ label?: boolean;
13
+ }
14
+
15
+ /**
16
+ * Brand-ui copy-to-clipboard button with a transient "Copied" state. Shared by
17
+ * the editor toolbar and workspace so the copy affordance is defined once.
18
+ */
19
+ export function CopyButton({ value, label = true, className, ...props }: CopyButtonProps) {
20
+ // Clipboard write + transient flag come from the shared `@elabs-ai/components-ui`
21
+ // hook, so this button and `CopyableValue` cannot drift on timing or on what
22
+ // happens where there is no clipboard.
23
+ const { copied, copy } = useCopyToClipboard();
24
+
25
+ const onClick = useCallback(() => {
26
+ void copy(value);
27
+ }, [copy, value]);
28
+
29
+ return (
30
+ <Button
31
+ variant="ghost"
32
+ size="sm"
33
+ {...props}
34
+ type="button"
35
+ className={cn("h-7 gap-1.5", className)}
36
+ onClick={onClick}
37
+ aria-label={copied ? "Copied" : "Copy"}
38
+ >
39
+ {copied ? (
40
+ <CheckIcon
41
+ key={String(copied)}
42
+ className="size-4 text-success animate-in fade-in zoom-in-95 duration-fast ease-entrance"
43
+ aria-hidden="true"
44
+ />
45
+ ) : (
46
+ <CopyIcon className="size-4" aria-hidden="true" />
47
+ )}
48
+ {label ? <span className="text-xs">{copied ? "Copied" : "Copy"}</span> : null}
49
+ </Button>
50
+ );
51
+ }
@@ -0,0 +1 @@
1
+ export { CopyButton, type CopyButtonProps } from "./copy-button";
package/src/css.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ /** Ambient declaration so `tsc` accepts CSS side-effect imports (resolved by the
2
+ * consuming bundler — Vite for apps/Storybook). */
3
+ declare module "*.css";
@@ -0,0 +1,44 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { DiffEditor } from "./diff-editor";
3
+
4
+ const meta = {
5
+ title: "Editor/DiffEditor",
6
+ component: DiffEditor,
7
+ tags: ["autodocs"],
8
+ parameters: { layout: "fullscreen" },
9
+ } satisfies Meta<typeof DiffEditor>;
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof meta>;
13
+
14
+ const ORIGINAL = `export function greet(name) {
15
+ return "Hello " + name;
16
+ }
17
+ `;
18
+
19
+ const MODIFIED = `export function greet(name: string): string {
20
+ // Use a template literal.
21
+ return \`Hello \${name}!\`;
22
+ }
23
+ `;
24
+
25
+ export const SideBySide: Story = {
26
+ render: () => (
27
+ <div className="h-[360px] w-full border border-border">
28
+ <DiffEditor language="typescript" original={ORIGINAL} modified={MODIFIED} />
29
+ </div>
30
+ ),
31
+ };
32
+
33
+ export const Inline: Story = {
34
+ render: () => (
35
+ <div className="h-[360px] w-full border border-border">
36
+ <DiffEditor
37
+ language="typescript"
38
+ original={ORIGINAL}
39
+ modified={MODIFIED}
40
+ renderSideBySide={false}
41
+ />
42
+ </div>
43
+ ),
44
+ };
@@ -0,0 +1,68 @@
1
+ import { cleanup, render } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ // Monaco can't mount in jsdom — mock it and assert the wrapper's lifecycle.
5
+ const h = vi.hoisted(() => {
6
+ interface MockModel {
7
+ value: string;
8
+ language: string;
9
+ getValue: () => string;
10
+ setValue: ReturnType<typeof vi.fn>;
11
+ dispose: ReturnType<typeof vi.fn>;
12
+ }
13
+ const models: MockModel[] = [];
14
+ const createModel = vi.fn((value: string, language: string) => {
15
+ const m = { value, language, getValue: () => value, setValue: vi.fn(), dispose: vi.fn() };
16
+ models.push(m);
17
+ return m;
18
+ });
19
+ const diff = {
20
+ setModel: vi.fn(),
21
+ getModel: vi.fn(() => ({ original: models[0], modified: models[1] })),
22
+ updateOptions: vi.fn(),
23
+ dispose: vi.fn(),
24
+ };
25
+ return { models, createModel, diff, createDiffEditor: vi.fn(() => diff) };
26
+ });
27
+
28
+ vi.mock("monaco-editor", () => ({
29
+ editor: {
30
+ createDiffEditor: h.createDiffEditor,
31
+ createModel: h.createModel,
32
+ setModelLanguage: vi.fn(),
33
+ defineTheme: vi.fn(),
34
+ setTheme: vi.fn(),
35
+ },
36
+ Uri: { parse: (s: string) => ({ toString: () => s }) },
37
+ }));
38
+
39
+ import { DiffEditor } from "./diff-editor";
40
+
41
+ beforeEach(() => {
42
+ h.models.length = 0;
43
+ vi.clearAllMocks();
44
+ });
45
+ afterEach(cleanup);
46
+
47
+ describe("DiffEditor", () => {
48
+ it("creates a diff editor with original + modified models", () => {
49
+ const { getByTestId } = render(<DiffEditor original="a" modified="b" language="typescript" />);
50
+ expect(getByTestId("diff-editor")).toBeInTheDocument();
51
+ expect(h.createDiffEditor).toHaveBeenCalledTimes(1);
52
+ expect(h.createModel).toHaveBeenNthCalledWith(1, "a", "typescript");
53
+ expect(h.createModel).toHaveBeenNthCalledWith(2, "b", "typescript");
54
+ expect(h.diff.setModel).toHaveBeenCalledWith({
55
+ original: h.models[0],
56
+ modified: h.models[1],
57
+ });
58
+ });
59
+
60
+ it("disposes the editor + both models on unmount", () => {
61
+ const { unmount } = render(<DiffEditor original="a" modified="b" />);
62
+ const [original, modified] = h.models;
63
+ unmount();
64
+ expect(h.diff.dispose).toHaveBeenCalledTimes(1);
65
+ expect(original!.dispose).toHaveBeenCalledTimes(1);
66
+ expect(modified!.dispose).toHaveBeenCalledTimes(1);
67
+ });
68
+ });
@@ -0,0 +1,151 @@
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 { applyBrandTheme } from "../lib/monaco-theme-bridge";
15
+ import { useDataTheme } from "../lib/use-data-theme";
16
+
17
+ export type MonacoDiffEditor = monaco.editor.IStandaloneDiffEditor;
18
+
19
+ export interface DiffEditorProps extends Omit<HTMLAttributes<HTMLDivElement>, "defaultValue"> {
20
+ /** Left/original document. */
21
+ original: string;
22
+ /** Right/modified document. */
23
+ modified: string;
24
+ /** Monaco language id applied to both sides. Defaults to "typescript". */
25
+ language?: string;
26
+ /** Read-only (diffs are read-only by default). */
27
+ readOnly?: boolean;
28
+ /** Side-by-side (true) vs inline (false) diff. Defaults to side-by-side. */
29
+ renderSideBySide?: boolean;
30
+ /** Editor height. Number → px. Defaults to "100%". */
31
+ height?: number | string;
32
+ /** Passthrough Monaco diff options (merged over the defaults). */
33
+ options?: monaco.editor.IStandaloneDiffEditorConstructionOptions;
34
+ /** Called once the diff editor + monaco namespace are ready. */
35
+ onMount?: (editor: MonacoDiffEditor, monacoApi: typeof monaco) => void;
36
+ }
37
+
38
+ const BASE_OPTIONS: monaco.editor.IStandaloneDiffEditorConstructionOptions = {
39
+ automaticLayout: true,
40
+ minimap: { enabled: false },
41
+ scrollBeyondLastLine: false,
42
+ fontSize: 13,
43
+ ignoreTrimWhitespace: false,
44
+ padding: { top: 12, bottom: 12 },
45
+ scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10 },
46
+ };
47
+
48
+ /**
49
+ * A token-themed Monaco diff editor (original ↔ modified). Same theming bridge
50
+ * and chrome story as {@link CodeEditor}; reads as a side-by-side or inline diff.
51
+ */
52
+ export const DiffEditor = forwardRef<MonacoDiffEditor | null, DiffEditorProps>(function DiffEditor(
53
+ {
54
+ original,
55
+ modified,
56
+ language = "typescript",
57
+ readOnly = true,
58
+ renderSideBySide = true,
59
+ height = "100%",
60
+ options,
61
+ onMount,
62
+ className,
63
+ style,
64
+ ...props
65
+ },
66
+ ref,
67
+ ) {
68
+ const containerRef = useRef<HTMLDivElement>(null);
69
+ const [editor, setEditor] = useState<MonacoDiffEditor | null>(null);
70
+ const { theme, revision } = useDataTheme();
71
+
72
+ const onMountRef = useRef(onMount);
73
+ onMountRef.current = onMount;
74
+
75
+ useImperativeHandle<MonacoDiffEditor | null, MonacoDiffEditor | null>(ref, () => editor, [
76
+ editor,
77
+ ]);
78
+
79
+ // Mount once.
80
+ useEffect(() => {
81
+ const container = containerRef.current;
82
+ if (!container) return;
83
+
84
+ const instance = monaco.editor.createDiffEditor(container, {
85
+ ...BASE_OPTIONS,
86
+ readOnly,
87
+ renderSideBySide,
88
+ ...options,
89
+ });
90
+ const originalModel = monaco.editor.createModel(original, language);
91
+ const modifiedModel = monaco.editor.createModel(modified, language);
92
+ instance.setModel({ original: originalModel, modified: modifiedModel });
93
+ // Theme is applied by the effect below once `setEditor` runs.
94
+ setEditor(instance);
95
+ onMountRef.current?.(instance, monaco);
96
+
97
+ return () => {
98
+ instance.dispose();
99
+ originalModel.dispose();
100
+ modifiedModel.dispose();
101
+ setEditor(null);
102
+ };
103
+ // eslint-disable-next-line react-hooks/exhaustive-deps
104
+ }, []);
105
+
106
+ useEffect(() => {
107
+ const original_ = editor?.getModel()?.original;
108
+ if (original_ && original_.getValue() !== original) original_.setValue(original);
109
+ }, [editor, original]);
110
+
111
+ useEffect(() => {
112
+ const modified_ = editor?.getModel()?.modified;
113
+ if (modified_ && modified_.getValue() !== modified) modified_.setValue(modified);
114
+ }, [editor, modified]);
115
+
116
+ useEffect(() => {
117
+ const models = editor?.getModel();
118
+ if (!models) return;
119
+ monaco.editor.setModelLanguage(models.original, language);
120
+ monaco.editor.setModelLanguage(models.modified, language);
121
+ }, [editor, language]);
122
+
123
+ useEffect(() => {
124
+ editor?.updateOptions({ readOnly, renderSideBySide });
125
+ }, [editor, readOnly, renderSideBySide]);
126
+
127
+ useEffect(() => {
128
+ if (!editor) return;
129
+ try {
130
+ applyBrandTheme(monaco, theme);
131
+ } catch (err) {
132
+ console.error("[@elabs-ai/components-editor] failed to apply brand theme", err);
133
+ }
134
+ // `revision` re-applies once the data-theme attribute settles. See CodeEditor.
135
+ }, [editor, theme, revision]);
136
+
137
+ const resolvedStyle: CSSProperties = {
138
+ height: typeof height === "number" ? `${height}px` : height,
139
+ ...style,
140
+ };
141
+
142
+ return (
143
+ <div
144
+ ref={containerRef}
145
+ data-testid="diff-editor"
146
+ className={cn("h-full w-full overflow-hidden bg-background text-foreground", className)}
147
+ style={resolvedStyle}
148
+ {...props}
149
+ />
150
+ );
151
+ });
@@ -0,0 +1 @@
1
+ export { DiffEditor, type DiffEditorProps, type MonacoDiffEditor } from "./diff-editor";