@elabs-ai/components-editor 4.1.0 → 4.2.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 (45) hide show
  1. package/dist/{chunk-C62O7IOQ.js → chunk-FO5S3YZM.js} +241 -158
  2. package/dist/chunk-FO5S3YZM.js.map +1 -0
  3. package/dist/index.d.ts +5 -4
  4. package/dist/index.js +57 -39
  5. package/dist/index.js.map +1 -1
  6. package/dist/markdown/index.d.ts +2 -2
  7. package/dist/markdown/index.js +264 -187
  8. package/dist/markdown/index.js.map +1 -1
  9. package/dist/{markdown-editor-CBp_4eDv.d.ts → markdown-editor-Dn-0L_MM.d.ts} +8 -1
  10. package/dist/monaco.d.ts +2 -0
  11. package/dist/monaco.js +9 -0
  12. package/dist/monaco.js.map +1 -0
  13. package/package.json +9 -5
  14. package/src/ai-objects/decision-card.tsx +15 -9
  15. package/src/ai-objects/knowledge-card.tsx +18 -9
  16. package/src/barrel-monaco-lazy.test.ts +50 -0
  17. package/src/calc-block/calc-block.tsx +10 -4
  18. package/src/code-editor/code-editor.test.tsx +141 -14
  19. package/src/code-editor/code-editor.tsx +166 -35
  20. package/src/code-workspace/code-workspace.test.tsx +31 -12
  21. package/src/copy-button/copy-button.tsx +6 -3
  22. package/src/diff-editor/diff-editor.test.tsx +9 -3
  23. package/src/diff-editor/diff-editor.tsx +47 -23
  24. package/src/editor-toolbar/editor-toolbar.tsx +8 -2
  25. package/src/index.ts +4 -3
  26. package/src/markdown-academic/citations.tsx +14 -7
  27. package/src/markdown-academic/footnotes.tsx +1 -1
  28. package/src/markdown-academic/math.tsx +7 -4
  29. package/src/markdown-editor/completions/completions-menu.tsx +5 -2
  30. package/src/markdown-editor/directive-views.tsx +33 -19
  31. package/src/markdown-editor/slash/slash-menu.tsx +5 -2
  32. package/src/markdown-editor/table-view.tsx +28 -15
  33. package/src/markdown-iteration/iteration-builder-dialog.tsx +39 -18
  34. package/src/markdown-iteration/template-dialog.tsx +25 -7
  35. package/src/markdown-outline/document-outline.tsx +6 -2
  36. package/src/markdown-preview/markdown-preview-academic.test.tsx +3 -3
  37. package/src/markdown-toolbar/markdown-toolbar.tsx +42 -24
  38. package/src/markdown-workspace/markdown-workspace.test.tsx +22 -3
  39. package/src/markdown-workspace/markdown-workspace.tsx +6 -3
  40. package/src/mermaid-diagram/mermaid-diagram-fixes.test.tsx +130 -0
  41. package/src/mermaid-diagram/mermaid-diagram.test.tsx +2 -1
  42. package/src/mermaid-diagram/mermaid-diagram.tsx +89 -40
  43. package/src/mermaid-diagram/mermaid-viewer.tsx +21 -20
  44. package/src/monaco.ts +21 -0
  45. package/dist/chunk-C62O7IOQ.js.map +0 -1
@@ -1,6 +1,13 @@
1
1
  "use client";
2
2
 
3
- import * as monaco from "monaco-editor";
3
+ // Type-only: the barrel (`.`) exports this component alongside lightweight
4
+ // chrome (`CopyButton`, `EDITOR_LANGUAGES`) that must stay import-safe without
5
+ // Monaco. A `monaco-editor` VALUE import here would be evaluated the moment
6
+ // anything imports the barrel, pulling megabytes of Monaco + touching browser
7
+ // globals even for a consumer that only wants `CopyButton`. The engine is
8
+ // loaded at RUNTIME via `import("monaco-editor")` inside the mount effect
9
+ // below — see `monacoRef`.
10
+ import type * as monaco from "monaco-editor";
4
11
  import { cn } from "@elabs-ai/components-ui/lib/cn";
5
12
  import {
6
13
  forwardRef,
@@ -20,6 +27,14 @@ export type MonacoCodeEditor = monaco.editor.IStandaloneCodeEditor;
20
27
  /** A Monaco editor action (system command + optional hotkey + palette/menu entry). */
21
28
  export type EditorAction = monaco.editor.IActionDescriptor;
22
29
 
30
+ /**
31
+ * The runtime value type handed back by `import("monaco-editor")` — same
32
+ * namespace as the type-only `monaco` import above (`typeof` a type-only
33
+ * namespace import resolves to the module's own type), used for `monacoRef`
34
+ * and `onMount`'s second argument.
35
+ */
36
+ type MonacoNamespace = typeof monaco;
37
+
23
38
  export interface CodeEditorProps extends Omit<
24
39
  HTMLAttributes<HTMLDivElement>,
25
40
  "onChange" | "defaultValue"
@@ -58,7 +73,7 @@ export interface CodeEditorProps extends Omit<
58
73
  */
59
74
  contextMenu?: "brand" | "monaco" | "none";
60
75
  /** Called once the editor instance + monaco namespace are ready. */
61
- onMount?: (editor: MonacoCodeEditor, monacoApi: typeof monaco) => void;
76
+ onMount?: (editor: MonacoCodeEditor, monacoApi: MonacoNamespace) => void;
62
77
  /**
63
78
  * Declarative Monaco editor actions — each registers a command (run on its
64
79
  * `keybindings`, in the command palette, and optionally the context menu via
@@ -73,6 +88,38 @@ export interface CodeEditorProps extends Omit<
73
88
  actions?: EditorAction[];
74
89
  }
75
90
 
91
+ /**
92
+ * The smallest single edit that turns `oldValue` into `newValue`, found by
93
+ * trimming the common prefix and suffix. Used instead of a wholesale
94
+ * `editor.setValue()` when syncing a controlled `value`: `setValue` replaces
95
+ * the entire model in one shot, which wipes the undo stack and always resets
96
+ * the cursor to the start of the document. Routing the same change through
97
+ * `editor.executeEdits` with just the differing middle span keeps it a single
98
+ * coalescable undo entry and leaves the cursor/selection outside the edited
99
+ * span untouched by Monaco's own position mapping.
100
+ */
101
+ function computeMinimalEdit(
102
+ oldValue: string,
103
+ newValue: string,
104
+ ): { start: number; endOld: number; text: string } {
105
+ const maxCommon = Math.min(oldValue.length, newValue.length);
106
+ let start = 0;
107
+ while (start < maxCommon && oldValue.charCodeAt(start) === newValue.charCodeAt(start)) {
108
+ start++;
109
+ }
110
+ let endOld = oldValue.length;
111
+ let endNew = newValue.length;
112
+ while (
113
+ endOld > start &&
114
+ endNew > start &&
115
+ oldValue.charCodeAt(endOld - 1) === newValue.charCodeAt(endNew - 1)
116
+ ) {
117
+ endOld--;
118
+ endNew--;
119
+ }
120
+ return { start, endOld, text: newValue.slice(start, endNew) };
121
+ }
122
+
76
123
  const BASE_OPTIONS: monaco.editor.IStandaloneEditorConstructionOptions = {
77
124
  automaticLayout: true,
78
125
  minimap: { enabled: false },
@@ -119,6 +166,15 @@ export const CodeEditor = forwardRef<MonacoCodeEditor | null, CodeEditorProps>(f
119
166
  const containerRef = useRef<HTMLDivElement>(null);
120
167
  const [editor, setEditor] = useState<MonacoCodeEditor | null>(null);
121
168
  const { theme, revision } = useDataTheme();
169
+ // The CURRENT model, tracked outside React state: a `path` change swaps it
170
+ // (see the effect below) without waiting on a re-render, and unmount must
171
+ // dispose whichever model is live at that point, not the one from mount.
172
+ const modelRef = useRef<monaco.editor.ITextModel | null>(null);
173
+ // The dynamically-imported `monaco-editor` module, once loaded. `editor`
174
+ // (React state) is only ever set AFTER this ref is populated (see the mount
175
+ // effect), so every other effect below that reads both may assume: `editor`
176
+ // truthy implies `monacoRef.current` truthy.
177
+ const monacoRef = useRef<MonacoNamespace | null>(null);
122
178
 
123
179
  // Latest callbacks via refs so the mount effect can run exactly once.
124
180
  const onChangeRef = useRef(onChange);
@@ -130,64 +186,139 @@ export const CodeEditor = forwardRef<MonacoCodeEditor | null, CodeEditorProps>(f
130
186
  editor,
131
187
  ]);
132
188
 
133
- // Mount once.
189
+ // Mount once. Monaco itself loads lazily (`import("monaco-editor")`) so the
190
+ // engine is only fetched/evaluated once a `CodeEditor` actually mounts, never
191
+ // merely by importing this module (see the top-of-file note). `cancelled`
192
+ // guards against the component unmounting (or `container` going away) before
193
+ // the dynamic import resolves.
134
194
  useEffect(() => {
135
195
  const container = containerRef.current;
136
196
  if (!container) return;
197
+ let cancelled = false;
198
+ let instance: MonacoCodeEditor | null = null;
199
+ let model: monaco.editor.ITextModel | null = null;
200
+ let sub: { dispose(): void } | null = null;
137
201
 
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,
202
+ import("monaco-editor").then((monacoApi) => {
203
+ if (cancelled) return;
204
+ monacoRef.current = monacoApi;
205
+ model = monacoApi.editor.createModel(
206
+ value ?? defaultValue ?? "",
207
+ language,
208
+ path ? monacoApi.Uri.parse(`inmemory://brand/${path}`) : undefined,
209
+ );
210
+ modelRef.current = model;
211
+ instance = monacoApi.editor.create(container, {
212
+ ...BASE_OPTIONS,
213
+ readOnly,
214
+ // Disable Monaco's own menu unless explicitly opted into; "brand" renders
215
+ // brand-ui's ContextMenu around the editor instead.
216
+ contextmenu: contextMenu === "monaco",
217
+ // Monaco's accessible name comes from this construction option (it writes it
218
+ // onto its inner screen-reader <textarea>), not from a wrapper-div attribute.
219
+ ...(ariaLabel !== undefined ? { ariaLabel } : null),
220
+ model,
221
+ ...options,
222
+ });
223
+ sub = instance.onDidChangeModelContent(() => {
224
+ onChangeRef.current?.(instance!.getValue());
225
+ });
226
+ // Monaco now mounts asynchronously, so stamp the initial aria-* onto its
227
+ // textarea right away — otherwise it is briefly exposed without a name
228
+ // until the aria sync effect below runs on the next commit.
229
+ const textarea = instance.getDomNode()?.querySelector("textarea");
230
+ if (textarea) {
231
+ if (ariaLabel !== undefined) textarea.setAttribute("aria-label", ariaLabel);
232
+ if (ariaInvalid !== undefined) textarea.setAttribute("aria-invalid", String(ariaInvalid));
233
+ if (ariaDescribedBy !== undefined)
234
+ textarea.setAttribute("aria-describedby", ariaDescribedBy);
235
+ }
236
+ // `setEditor` triggers the theming effect below; keeping theme application
237
+ // there (not here) guarantees it never blocks editor setup.
238
+ setEditor(instance);
239
+ onMountRef.current?.(instance, monacoApi);
154
240
  });
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
241
 
163
242
  return () => {
164
- sub.dispose();
165
- instance.dispose();
166
- model.dispose();
243
+ cancelled = true;
244
+ sub?.dispose();
245
+ instance?.dispose();
246
+ model?.dispose();
247
+ modelRef.current = null;
248
+ monacoRef.current = null;
167
249
  setEditor(null);
168
250
  };
169
251
  // eslint-disable-next-line react-hooks/exhaustive-deps
170
252
  }, []);
171
253
 
172
- // Controlled value sync (only when it diverges, to preserve cursor/undo).
254
+ // `path` drives the model's URI, which Monaco never lets you change on an
255
+ // existing model — a later `path` change (e.g. `CodeWorkspace` switching
256
+ // files) was previously just ignored. Swap in a fresh model carrying the
257
+ // CURRENT value/language under the new URI, and dispose the old one; a
258
+ // per-file undo stack is Monaco's normal behavior for a model swap.
173
259
  useEffect(() => {
174
- if (!editor || value === undefined) return;
175
- if (value !== editor.getValue()) editor.setValue(value);
260
+ const monacoApi = monacoRef.current;
261
+ if (!editor || !monacoApi) return;
262
+ const current = modelRef.current;
263
+ const currentUri = current?.uri?.toString();
264
+ const nextUri = path ? monacoApi.Uri.parse(`inmemory://brand/${path}`).toString() : undefined;
265
+ if (currentUri === nextUri) return;
266
+
267
+ const nextModel = monacoApi.editor.createModel(
268
+ current?.getValue() ?? value ?? defaultValue ?? "",
269
+ language,
270
+ path ? monacoApi.Uri.parse(`inmemory://brand/${path}`) : undefined,
271
+ );
272
+ editor.setModel(nextModel);
273
+ modelRef.current = nextModel;
274
+ current?.dispose();
275
+ // `value`/`defaultValue`/`language` are read once, at the moment of the
276
+ // swap, to seed the new model — not tracked as reactive deps here; the
277
+ // controlled-value and language effects below correct them independently.
278
+ // eslint-disable-next-line react-hooks/exhaustive-deps
279
+ }, [editor, path]);
280
+
281
+ // Controlled value sync — only when it diverges, and via the smallest
282
+ // `executeEdits` span rather than `setValue()`: a full-document `setValue`
283
+ // wipes the undo stack and always resets the cursor to the start.
284
+ useEffect(() => {
285
+ const monacoApi = monacoRef.current;
286
+ if (!editor || !monacoApi || value === undefined) return;
287
+ const model = editor.getModel();
288
+ if (!model) return;
289
+ const current = model.getValue();
290
+ if (value === current) return;
291
+ const { start, endOld, text } = computeMinimalEdit(current, value);
292
+ const range = monacoApi.Range.fromPositions(
293
+ model.getPositionAt(start),
294
+ model.getPositionAt(endOld),
295
+ );
296
+ editor.executeEdits("controlled-value-sync", [{ range, text }]);
176
297
  }, [editor, value]);
177
298
 
178
299
  useEffect(() => {
300
+ const monacoApi = monacoRef.current;
179
301
  const model = editor?.getModel();
180
- if (model) monaco.editor.setModelLanguage(model, language);
302
+ if (model && monacoApi) monacoApi.editor.setModelLanguage(model, language);
181
303
  }, [editor, language]);
182
304
 
183
305
  useEffect(() => {
184
306
  editor?.updateOptions({ readOnly, contextmenu: contextMenu === "monaco" });
185
307
  }, [editor, readOnly, contextMenu]);
186
308
 
309
+ // `options` after mount: the construction-time spread only ever applied it
310
+ // once. A caller changing `options` (e.g. toggling `minimap`) now reaches
311
+ // the live editor via `updateOptions`, same as `readOnly`/`contextMenu` above.
187
312
  useEffect(() => {
188
- if (!editor) return;
313
+ if (!editor || !options) return;
314
+ editor.updateOptions(options);
315
+ }, [editor, options]);
316
+
317
+ useEffect(() => {
318
+ const monacoApi = monacoRef.current;
319
+ if (!editor || !monacoApi) return;
189
320
  try {
190
- applyBrandTheme(monaco, theme);
321
+ applyBrandTheme(monacoApi, theme);
191
322
  } catch (err) {
192
323
  console.error("[@elabs-ai/components-editor] failed to apply brand theme", err);
193
324
  }
@@ -14,7 +14,14 @@ const h = vi.hoisted(() => {
14
14
  setValue: vi.fn(),
15
15
  getModel: vi.fn(() => ({
16
16
  getValueInRange: vi.fn(() => "selected"),
17
+ // Deliberately reports the SAME content the controlled `value` prop
18
+ // carries in this suite's fixtures, so the controlled-value-sync
19
+ // effect (added for the executeEdits-over-setValue fix) is a no-op
20
+ // here and doesn't perturb these unrelated tab/selection assertions.
21
+ getValue: vi.fn(() => "AAA"),
22
+ getPositionAt: vi.fn((offset: number) => ({ lineNumber: 1, column: offset })),
17
23
  })),
24
+ setModel: vi.fn(),
18
25
  getSelection: vi.fn(() => ({ isEmpty: () => false })),
19
26
  executeEdits: vi.fn(),
20
27
  pushUndoStop: vi.fn(),
@@ -32,7 +39,11 @@ const h = vi.hoisted(() => {
32
39
  selectionHandlers,
33
40
  selectionDisposable,
34
41
  create: vi.fn(() => editor),
35
- createModel: vi.fn(() => ({ dispose: vi.fn() })),
42
+ createModel: vi.fn((value: string) => ({
43
+ dispose: vi.fn(),
44
+ getValue: vi.fn(() => value),
45
+ getPositionAt: vi.fn((offset: number) => ({ lineNumber: 1, column: offset })),
46
+ })),
36
47
  };
37
48
  });
38
49
 
@@ -45,6 +56,9 @@ vi.mock("monaco-editor", () => ({
45
56
  setTheme: vi.fn(),
46
57
  },
47
58
  Uri: { parse: (s: string) => ({ toString: () => s }) },
59
+ Range: {
60
+ fromPositions: (start: unknown, end: unknown) => ({ start, end }),
61
+ },
48
62
  }));
49
63
 
50
64
  import { createRef } from "react";
@@ -55,20 +69,28 @@ const FILES: EditorFile[] = [
55
69
  { path: "b.json", value: "{}" },
56
70
  ];
57
71
 
72
+ // CodeEditor's underlying engine now loads via a dynamic `import("monaco-editor")`
73
+ // (kept lazy so the barrel never evaluates Monaco just for `CopyButton`/
74
+ // `EDITOR_LANGUAGES` — see barrel-monaco-lazy.test.ts). Flush that microtask
75
+ // before asserting on anything the mount effect populates.
76
+ const flush = () => act(async () => {});
77
+
58
78
  beforeEach(() => vi.clearAllMocks());
59
79
  afterEach(cleanup);
60
80
 
61
81
  describe("CodeWorkspace — tabs", () => {
62
- it("renders a tab per file and opens the first file with its inferred language", () => {
82
+ it("renders a tab per file and opens the first file with its inferred language", async () => {
63
83
  render(<CodeWorkspace files={FILES} />);
64
84
  expect(screen.getByRole("tab", { name: "a.ts" })).toBeInTheDocument();
65
85
  expect(screen.getByRole("tab", { name: "b.json" })).toBeInTheDocument();
86
+ await flush();
66
87
  expect(h.createModel).toHaveBeenCalledWith("AAA", "typescript", expect.anything());
67
88
  });
68
89
 
69
90
  it("switches the editor to the file whose tab is activated", async () => {
70
91
  const onActivePathChange = vi.fn();
71
92
  render(<CodeWorkspace files={FILES} onActivePathChange={onActivePathChange} />);
93
+ await flush();
72
94
  await userEvent.click(screen.getByRole("tab", { name: "b.json" }));
73
95
  expect(onActivePathChange).toHaveBeenCalledWith("b.json");
74
96
  expect(h.createModel).toHaveBeenCalledWith("{}", "json", expect.anything());
@@ -84,18 +106,14 @@ describe("CodeWorkspace — CodeWorkspaceHandle via ref", () => {
84
106
  expect(el).toBeInstanceOf(HTMLDivElement);
85
107
  });
86
108
 
87
- it("exposes getActiveEditor() returning the Monaco instance after mount", () => {
109
+ it("exposes getActiveEditor() returning the Monaco instance after mount", async () => {
88
110
  const ref = createRef<CodeWorkspaceHandle>();
89
111
  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).
112
+ // The engine loads via `import("monaco-editor")`, resolved as a microtask
113
+ // (mocked module, no real network) flush it, then the handle reflects
114
+ // the mounted instance.
115
+ await flush();
95
116
  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
117
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
118
  expect(active === null || (active as any) === h.editor).toBe(true);
101
119
  });
@@ -196,12 +214,13 @@ describe("CodeWorkspace — force-mounted panels stay out of the layout", () =>
196
214
  });
197
215
 
198
216
  describe("CodeWorkspace — tab identity survives a file-list reorder (#412 review)", () => {
199
- it("keeps the active panel's id stable and does not remount Monaco when a file is prepended", () => {
217
+ it("keeps the active panel's id stable and does not remount Monaco when a file is prepended", async () => {
200
218
  const initial: EditorFile[] = [
201
219
  { path: "src/a.ts", value: "AAA" },
202
220
  { path: "b.json", value: "{}" },
203
221
  ];
204
222
  const { rerender } = render(<CodeWorkspace files={initial} />);
223
+ await flush();
205
224
  const idBefore = screen
206
225
  .getAllByRole("tabpanel", { hidden: true })
207
226
  .find((p) => p.getAttribute("data-state") === "active")!.id;
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Button, useCopyToClipboard } from "@elabs-ai/components-ui";
3
+ import { Button, useCopyToClipboard, useLocale } from "@elabs-ai/components-ui";
4
4
  import { cn } from "@elabs-ai/components-ui/lib/cn";
5
5
  import { CheckIcon, CopyIcon } from "lucide-react";
6
6
  import { useCallback, type ComponentProps } from "react";
@@ -21,11 +21,14 @@ export function CopyButton({ value, label = true, className, ...props }: CopyBut
21
21
  // hook, so this button and `CopyableValue` cannot drift on timing or on what
22
22
  // happens where there is no clipboard.
23
23
  const { copied, copy } = useCopyToClipboard();
24
+ const { t } = useLocale();
24
25
 
25
26
  const onClick = useCallback(() => {
26
27
  void copy(value);
27
28
  }, [copy, value]);
28
29
 
30
+ const text = copied ? t("editor.copyButton.copied") : t("copy");
31
+
29
32
  return (
30
33
  <Button
31
34
  variant="ghost"
@@ -34,7 +37,7 @@ export function CopyButton({ value, label = true, className, ...props }: CopyBut
34
37
  type="button"
35
38
  className={cn("h-7 gap-1.5", className)}
36
39
  onClick={onClick}
37
- aria-label={copied ? "Copied" : "Copy"}
40
+ aria-label={text}
38
41
  >
39
42
  {copied ? (
40
43
  <CheckIcon
@@ -45,7 +48,7 @@ export function CopyButton({ value, label = true, className, ...props }: CopyBut
45
48
  ) : (
46
49
  <CopyIcon className="size-4" aria-hidden="true" />
47
50
  )}
48
- {label ? <span className="text-xs">{copied ? "Copied" : "Copy"}</span> : null}
51
+ {label ? <span className="text-xs">{text}</span> : null}
49
52
  </Button>
50
53
  );
51
54
  }
@@ -1,4 +1,4 @@
1
- import { cleanup, render } from "@testing-library/react";
1
+ import { act, cleanup, render } from "@testing-library/react";
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
3
 
4
4
  // Monaco can't mount in jsdom — mock it and assert the wrapper's lifecycle.
@@ -38,6 +38,10 @@ vi.mock("monaco-editor", () => ({
38
38
 
39
39
  import { DiffEditor } from "./diff-editor";
40
40
 
41
+ // See the matching note in `../code-editor/code-editor.test.tsx` — the engine
42
+ // loads via a dynamic `import("monaco-editor")`; flush it before asserting.
43
+ const flush = () => act(async () => {});
44
+
41
45
  beforeEach(() => {
42
46
  h.models.length = 0;
43
47
  vi.clearAllMocks();
@@ -45,9 +49,10 @@ beforeEach(() => {
45
49
  afterEach(cleanup);
46
50
 
47
51
  describe("DiffEditor", () => {
48
- it("creates a diff editor with original + modified models", () => {
52
+ it("creates a diff editor with original + modified models", async () => {
49
53
  const { getByTestId } = render(<DiffEditor original="a" modified="b" language="typescript" />);
50
54
  expect(getByTestId("diff-editor")).toBeInTheDocument();
55
+ await flush();
51
56
  expect(h.createDiffEditor).toHaveBeenCalledTimes(1);
52
57
  expect(h.createModel).toHaveBeenNthCalledWith(1, "a", "typescript");
53
58
  expect(h.createModel).toHaveBeenNthCalledWith(2, "b", "typescript");
@@ -57,8 +62,9 @@ describe("DiffEditor", () => {
57
62
  });
58
63
  });
59
64
 
60
- it("disposes the editor + both models on unmount", () => {
65
+ it("disposes the editor + both models on unmount", async () => {
61
66
  const { unmount } = render(<DiffEditor original="a" modified="b" />);
67
+ await flush();
62
68
  const [original, modified] = h.models;
63
69
  unmount();
64
70
  expect(h.diff.dispose).toHaveBeenCalledTimes(1);
@@ -1,6 +1,10 @@
1
1
  "use client";
2
2
 
3
- import * as monaco from "monaco-editor";
3
+ // Type-only see the matching note in `../code-editor/code-editor.tsx`. The
4
+ // engine loads at RUNTIME via `import("monaco-editor")` in the mount effect
5
+ // below (`monacoRef`), so importing this module (e.g. transitively, via the
6
+ // barrel) never evaluates Monaco.
7
+ import type * as monaco from "monaco-editor";
4
8
  import { cn } from "@elabs-ai/components-ui/lib/cn";
5
9
  import {
6
10
  forwardRef,
@@ -16,6 +20,9 @@ import { useDataTheme } from "../lib/use-data-theme";
16
20
 
17
21
  export type MonacoDiffEditor = monaco.editor.IStandaloneDiffEditor;
18
22
 
23
+ /** Same pattern as `code-editor.tsx`'s `MonacoNamespace`. */
24
+ type MonacoNamespace = typeof monaco;
25
+
19
26
  export interface DiffEditorProps extends Omit<HTMLAttributes<HTMLDivElement>, "defaultValue"> {
20
27
  /** Left/original document. */
21
28
  original: string;
@@ -32,7 +39,7 @@ export interface DiffEditorProps extends Omit<HTMLAttributes<HTMLDivElement>, "d
32
39
  /** Passthrough Monaco diff options (merged over the defaults). */
33
40
  options?: monaco.editor.IStandaloneDiffEditorConstructionOptions;
34
41
  /** Called once the diff editor + monaco namespace are ready. */
35
- onMount?: (editor: MonacoDiffEditor, monacoApi: typeof monaco) => void;
42
+ onMount?: (editor: MonacoDiffEditor, monacoApi: MonacoNamespace) => void;
36
43
  }
37
44
 
38
45
  const BASE_OPTIONS: monaco.editor.IStandaloneDiffEditorConstructionOptions = {
@@ -68,6 +75,10 @@ export const DiffEditor = forwardRef<MonacoDiffEditor | null, DiffEditorProps>(f
68
75
  const containerRef = useRef<HTMLDivElement>(null);
69
76
  const [editor, setEditor] = useState<MonacoDiffEditor | null>(null);
70
77
  const { theme, revision } = useDataTheme();
78
+ // Populated once the dynamic `import("monaco-editor")` below resolves.
79
+ // `editor` (React state) is only ever set AFTER this ref, so every other
80
+ // effect that reads both may assume: `editor` truthy implies this truthy.
81
+ const monacoRef = useRef<MonacoNamespace | null>(null);
71
82
 
72
83
  const onMountRef = useRef(onMount);
73
84
  onMountRef.current = onMount;
@@ -76,28 +87,39 @@ export const DiffEditor = forwardRef<MonacoDiffEditor | null, DiffEditorProps>(f
76
87
  editor,
77
88
  ]);
78
89
 
79
- // Mount once.
90
+ // Mount once. Monaco itself loads lazily (see the top-of-file note) — merely
91
+ // importing this module never evaluates the engine.
80
92
  useEffect(() => {
81
93
  const container = containerRef.current;
82
94
  if (!container) return;
83
-
84
- const instance = monaco.editor.createDiffEditor(container, {
85
- ...BASE_OPTIONS,
86
- readOnly,
87
- renderSideBySide,
88
- ...options,
95
+ let cancelled = false;
96
+ let instance: MonacoDiffEditor | null = null;
97
+ let originalModel: monaco.editor.ITextModel | null = null;
98
+ let modifiedModel: monaco.editor.ITextModel | null = null;
99
+
100
+ import("monaco-editor").then((monacoApi) => {
101
+ if (cancelled) return;
102
+ monacoRef.current = monacoApi;
103
+ instance = monacoApi.editor.createDiffEditor(container, {
104
+ ...BASE_OPTIONS,
105
+ readOnly,
106
+ renderSideBySide,
107
+ ...options,
108
+ });
109
+ originalModel = monacoApi.editor.createModel(original, language);
110
+ modifiedModel = monacoApi.editor.createModel(modified, language);
111
+ instance.setModel({ original: originalModel, modified: modifiedModel });
112
+ // Theme is applied by the effect below once `setEditor` runs.
113
+ setEditor(instance);
114
+ onMountRef.current?.(instance, monacoApi);
89
115
  });
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
116
 
97
117
  return () => {
98
- instance.dispose();
99
- originalModel.dispose();
100
- modifiedModel.dispose();
118
+ cancelled = true;
119
+ instance?.dispose();
120
+ originalModel?.dispose();
121
+ modifiedModel?.dispose();
122
+ monacoRef.current = null;
101
123
  setEditor(null);
102
124
  };
103
125
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -114,10 +136,11 @@ export const DiffEditor = forwardRef<MonacoDiffEditor | null, DiffEditorProps>(f
114
136
  }, [editor, modified]);
115
137
 
116
138
  useEffect(() => {
139
+ const monacoApi = monacoRef.current;
117
140
  const models = editor?.getModel();
118
- if (!models) return;
119
- monaco.editor.setModelLanguage(models.original, language);
120
- monaco.editor.setModelLanguage(models.modified, language);
141
+ if (!models || !monacoApi) return;
142
+ monacoApi.editor.setModelLanguage(models.original, language);
143
+ monacoApi.editor.setModelLanguage(models.modified, language);
121
144
  }, [editor, language]);
122
145
 
123
146
  useEffect(() => {
@@ -125,9 +148,10 @@ export const DiffEditor = forwardRef<MonacoDiffEditor | null, DiffEditorProps>(f
125
148
  }, [editor, readOnly, renderSideBySide]);
126
149
 
127
150
  useEffect(() => {
128
- if (!editor) return;
151
+ const monacoApi = monacoRef.current;
152
+ if (!editor || !monacoApi) return;
129
153
  try {
130
- applyBrandTheme(monaco, theme);
154
+ applyBrandTheme(monacoApi, theme);
131
155
  } catch (err) {
132
156
  console.error("[@elabs-ai/components-editor] failed to apply brand theme", err);
133
157
  }
@@ -6,6 +6,7 @@ import {
6
6
  SelectItem,
7
7
  SelectTrigger,
8
8
  SelectValue,
9
+ useLocale,
9
10
  } from "@elabs-ai/components-ui";
10
11
  import { cn } from "@elabs-ai/components-ui/lib/cn";
11
12
  import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
@@ -45,6 +46,7 @@ export const EditorToolbar = forwardRef<HTMLDivElement, EditorToolbarProps>(func
45
46
  },
46
47
  ref,
47
48
  ) {
49
+ const { t } = useLocale();
48
50
  return (
49
51
  <div
50
52
  ref={ref}
@@ -63,8 +65,12 @@ export const EditorToolbar = forwardRef<HTMLDivElement, EditorToolbarProps>(func
63
65
 
64
66
  {onLanguageChange ? (
65
67
  <Select value={language} onValueChange={onLanguageChange}>
66
- <SelectTrigger size="sm" className="h-7 w-[140px]" aria-label="Language">
67
- <SelectValue placeholder="Language" />
68
+ <SelectTrigger
69
+ size="sm"
70
+ className="h-7 w-[140px]"
71
+ aria-label={t("editor.editorToolbar.language")}
72
+ >
73
+ <SelectValue placeholder={t("editor.editorToolbar.language")} />
68
74
  </SelectTrigger>
69
75
  <SelectContent>
70
76
  {languages.map((l) => (
package/src/index.ts CHANGED
@@ -43,6 +43,7 @@ export { applyBrandTheme, buildBrandThemeData, brandThemeId } from "./lib/monaco
43
43
  export { useDataTheme, type DataThemeState } from "./lib/use-data-theme";
44
44
  export { EDITOR_LANGUAGES, languageLabel, type EditorLanguage } from "./lib/languages";
45
45
 
46
- // Re-export the monaco namespace so consumers can build custom editors / wire
47
- // commands without adding a direct dependency.
48
- export * as monaco from "monaco-editor";
46
+ // The `monaco-editor` namespace moved to its own subpath see `./monaco.ts`.
47
+ // Re-exporting it here pulled Monaco into every consumer of this barrel, even
48
+ // one only importing `CopyButton`/`EDITOR_LANGUAGES`:
49
+ // import { monaco } from "@elabs-ai/components-editor/monaco";