@elabs-ai/components-editor 4.0.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 (77) hide show
  1. package/README.md +6 -7
  2. package/dist/{chunk-LBC5VJBD.js → chunk-FO5S3YZM.js} +349 -177
  3. package/dist/chunk-FO5S3YZM.js.map +1 -0
  4. package/dist/index.css +4 -2
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.ts +5 -4
  7. package/dist/index.js +112 -57
  8. package/dist/index.js.map +1 -1
  9. package/dist/markdown/index.css +4 -2
  10. package/dist/markdown/index.css.map +1 -1
  11. package/dist/markdown/index.d.ts +2 -2
  12. package/dist/markdown/index.js +268 -191
  13. package/dist/markdown/index.js.map +1 -1
  14. package/dist/{markdown-editor-DfBZibAn.d.ts → markdown-editor-Dn-0L_MM.d.ts} +9 -2
  15. package/dist/monaco.d.ts +2 -0
  16. package/dist/monaco.js +9 -0
  17. package/dist/monaco.js.map +1 -0
  18. package/package.json +10 -6
  19. package/src/ai-objects/decision-card.tsx +15 -9
  20. package/src/ai-objects/entity.tsx +1 -1
  21. package/src/ai-objects/knowledge-card.tsx +18 -9
  22. package/src/barrel-monaco-lazy.test.ts +50 -0
  23. package/src/calc-block/calc-block.tsx +10 -4
  24. package/src/calc-block/calc-editor.css +1 -1
  25. package/src/code-editor/code-editor.stories.tsx +76 -1
  26. package/src/code-editor/code-editor.test.tsx +141 -14
  27. package/src/code-editor/code-editor.tsx +167 -36
  28. package/src/code-workspace/code-workspace.test.tsx +113 -11
  29. package/src/code-workspace/code-workspace.tsx +73 -20
  30. package/src/copy-button/copy-button.tsx +6 -3
  31. package/src/diff-editor/diff-editor.stories.tsx +9 -1
  32. package/src/diff-editor/diff-editor.test.tsx +9 -3
  33. package/src/diff-editor/diff-editor.tsx +47 -23
  34. package/src/editor-toolbar/editor-toolbar.tsx +8 -2
  35. package/src/index.ts +4 -3
  36. package/src/lib/monaco-deep-imports.d.ts +59 -0
  37. package/src/lib/monaco-theme-bridge.test.ts +307 -0
  38. package/src/lib/monaco-theme-bridge.ts +154 -10
  39. package/src/markdown-academic/citations.tsx +14 -7
  40. package/src/markdown-academic/footnotes.tsx +2 -2
  41. package/src/markdown-academic/math.tsx +7 -4
  42. package/src/markdown-academic/toc.tsx +1 -1
  43. package/src/markdown-editor/completions/completions-menu.tsx +5 -2
  44. package/src/markdown-editor/directive-views.tsx +35 -24
  45. package/src/markdown-editor/markdown-editor.css +26 -3
  46. package/src/markdown-editor/markdown-editor.focus.test.ts +49 -0
  47. package/src/markdown-editor/markdown-editor.stories.tsx +128 -7
  48. package/src/markdown-editor/markdown-editor.tsx +8 -5
  49. package/src/markdown-editor/milkdown-react/use-get-editor.timer-leak.test.ts +80 -0
  50. package/src/markdown-editor/milkdown-react/use-get-editor.ts +37 -1
  51. package/src/markdown-editor/paste-embed.ts +2 -1
  52. package/src/markdown-editor/slash/slash-menu.stories.tsx +8 -3
  53. package/src/markdown-editor/slash/slash-menu.test.tsx +27 -0
  54. package/src/markdown-editor/slash/slash-menu.tsx +18 -3
  55. package/src/markdown-editor/table-view.tsx +31 -17
  56. package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +57 -19
  57. package/src/markdown-iteration/iteration-builder-dialog.tsx +39 -18
  58. package/src/markdown-iteration/template-dialog.tsx +25 -7
  59. package/src/markdown-outline/document-outline.stories.tsx +1 -1
  60. package/src/markdown-outline/document-outline.tsx +7 -3
  61. package/src/markdown-preview/markdown-preview-academic.test.tsx +3 -3
  62. package/src/markdown-preview/markdown-preview-transclusion.test.tsx +1 -1
  63. package/src/markdown-preview/markdown-preview.stories.tsx +24 -2
  64. package/src/markdown-preview/markdown-preview.test.tsx +20 -3
  65. package/src/markdown-toolbar/markdown-toolbar.stories.tsx +3 -0
  66. package/src/markdown-toolbar/markdown-toolbar.tsx +42 -24
  67. package/src/markdown-workspace/markdown-workspace.test.tsx +53 -4
  68. package/src/markdown-workspace/markdown-workspace.tsx +6 -3
  69. package/src/mermaid-diagram/mermaid-diagram-fixes.test.tsx +130 -0
  70. package/src/mermaid-diagram/mermaid-diagram.test.tsx +2 -1
  71. package/src/mermaid-diagram/mermaid-diagram.tsx +89 -40
  72. package/src/mermaid-diagram/mermaid-viewer.tsx +22 -21
  73. package/src/monaco.ts +21 -0
  74. package/src/prose/prose.stories.tsx +3 -0
  75. package/src/prose/prose.test.ts +54 -0
  76. package/src/prose/prose.tsx +7 -0
  77. package/dist/chunk-LBC5VJBD.js.map +0 -1
@@ -1,13 +1,23 @@
1
1
  import { act, cleanup, render } from "@testing-library/react";
2
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
3
3
 
4
4
  // Monaco needs real layout/canvas, so it cannot mount in jsdom — mock the engine
5
5
  // and assert the wrapper's lifecycle contract. Real rendering + a11y are covered
6
6
  // by the Storybook interaction tests.
7
7
  const h = vi.hoisted(() => {
8
8
  const state = { changeHandler: undefined as undefined | (() => void), value: "" };
9
- const model = { dispose: vi.fn() };
9
+ const models: {
10
+ dispose: Mock;
11
+ uri: { toString(): string } | undefined;
12
+ getValue: () => string;
13
+ getPositionAt: (offset: number) => { lineNumber: number; column: number };
14
+ _value: string;
15
+ }[] = [];
10
16
  const actionDisposable = { dispose: vi.fn() };
17
+ // The most recently created/set model — a simple single-document stand-in
18
+ // (columns are 1-based character OFFSETS, not real Monaco line/column) good
19
+ // enough to exercise `executeEdits`'s edit shape without a real Monaco model.
20
+ let currentModel: (typeof models)[number] | undefined;
11
21
  const editor = {
12
22
  onDidChangeModelContent: vi.fn((cb: () => void) => {
13
23
  state.changeHandler = cb;
@@ -17,7 +27,19 @@ const h = vi.hoisted(() => {
17
27
  setValue: vi.fn((v: string) => {
18
28
  state.value = v;
19
29
  }),
20
- getModel: vi.fn(() => model),
30
+ getModel: vi.fn(() => currentModel),
31
+ setModel: vi.fn((m: (typeof models)[number]) => {
32
+ currentModel = m;
33
+ }),
34
+ executeEdits: vi.fn(
35
+ (_source: string, edits: { range: { start: number; end: number }; text: string }[]) => {
36
+ const edit = edits[0]!;
37
+ const next =
38
+ state.value.slice(0, edit.range.start) + edit.text + state.value.slice(edit.range.end);
39
+ state.value = next;
40
+ if (currentModel) currentModel._value = next;
41
+ },
42
+ ),
21
43
  updateOptions: vi.fn(),
22
44
  addAction: vi.fn(() => actionDisposable),
23
45
  // Minimal DOM node so the aria-forwarding effect can find (or not) a textarea.
@@ -26,13 +48,29 @@ const h = vi.hoisted(() => {
26
48
  };
27
49
  return {
28
50
  state,
29
- model,
51
+ models,
52
+ get model() {
53
+ return models[0];
54
+ },
30
55
  actionDisposable,
31
56
  editor,
32
57
  create: vi.fn(() => editor),
33
- createModel: vi.fn((value: string) => {
58
+ createModel: vi.fn((value: string, _language: string, uri?: { toString(): string }) => {
34
59
  state.value = value;
35
- return model;
60
+ const m = {
61
+ dispose: vi.fn(),
62
+ uri,
63
+ _value: value,
64
+ getValue(this: { _value: string }) {
65
+ return this._value;
66
+ },
67
+ getPositionAt(offset: number) {
68
+ return { lineNumber: 1, column: offset };
69
+ },
70
+ };
71
+ models.push(m);
72
+ currentModel = m;
73
+ return m;
36
74
  }),
37
75
  };
38
76
  });
@@ -46,38 +84,56 @@ vi.mock("monaco-editor", () => ({
46
84
  setTheme: vi.fn(),
47
85
  },
48
86
  Uri: { parse: (s: string) => ({ toString: () => s }) },
87
+ Range: {
88
+ fromPositions: (start: { column: number }, end: { column: number }) => ({
89
+ start: start.column,
90
+ end: end.column,
91
+ }),
92
+ },
49
93
  }));
50
94
 
51
95
  import { CodeEditor } from "./code-editor";
52
96
 
97
+ // The engine now loads via a dynamic `import("monaco-editor")` inside the
98
+ // mount effect (kept lazy so the barrel never evaluates Monaco just for
99
+ // `CopyButton`/`EDITOR_LANGUAGES` — see barrel-monaco-lazy.test.ts). The mock
100
+ // module still resolves through that dynamic import, but only after a
101
+ // microtask: flush it with `await act(async () => {})` after every
102
+ // render/rerender that expects the editor to already exist.
103
+ const flush = () => act(async () => {});
104
+
53
105
  beforeEach(() => {
54
106
  h.state.changeHandler = undefined;
55
107
  h.state.value = "";
108
+ h.models.length = 0;
56
109
  vi.clearAllMocks();
57
110
  });
58
111
  afterEach(cleanup);
59
112
 
60
113
  describe("CodeEditor", () => {
61
- it("creates a Monaco editor with the initial value + language", () => {
114
+ it("creates a Monaco editor with the initial value + language", async () => {
62
115
  const { getByTestId } = render(
63
116
  <CodeEditor defaultValue="const a = 1;" language="typescript" />,
64
117
  );
65
118
  expect(getByTestId("code-editor")).toBeInTheDocument();
119
+ await flush();
66
120
  expect(h.create).toHaveBeenCalledTimes(1);
67
121
  // No `path` → no model URI.
68
122
  expect(h.createModel).toHaveBeenCalledWith("const a = 1;", "typescript", undefined);
69
123
  });
70
124
 
71
- it("creates the model with a URI derived from `path`", () => {
125
+ it("creates the model with a URI derived from `path`", async () => {
72
126
  render(<CodeEditor defaultValue="x" language="json" path="config/app.json" />);
127
+ await flush();
73
128
  expect(h.createModel).toHaveBeenCalledWith("x", "json", {
74
129
  toString: expect.any(Function),
75
130
  });
76
131
  });
77
132
 
78
- it("forwards content edits to onChange", () => {
133
+ it("forwards content edits to onChange", async () => {
79
134
  const onChange = vi.fn();
80
135
  render(<CodeEditor defaultValue="" onChange={onChange} />);
136
+ await flush();
81
137
  expect(h.state.changeHandler).toBeTypeOf("function");
82
138
  act(() => {
83
139
  h.state.value = "typed";
@@ -86,32 +142,35 @@ describe("CodeEditor", () => {
86
142
  expect(onChange).toHaveBeenCalledWith("typed");
87
143
  });
88
144
 
89
- it("disposes the editor + model on unmount", () => {
145
+ it("disposes the editor + model on unmount", async () => {
90
146
  const { unmount } = render(<CodeEditor defaultValue="x" />);
147
+ await flush();
91
148
  unmount();
92
149
  expect(h.editor.dispose).toHaveBeenCalledTimes(1);
93
- expect(h.model.dispose).toHaveBeenCalledTimes(1);
150
+ expect(h.model!.dispose).toHaveBeenCalledTimes(1);
94
151
  });
95
152
 
96
153
  describe("actions prop", () => {
97
- it("calls addAction once per descriptor on mount", () => {
154
+ it("calls addAction once per descriptor on mount", async () => {
98
155
  const actions = [
99
156
  { id: "test.one", label: "One", run: vi.fn() },
100
157
  { id: "test.two", label: "Two", run: vi.fn() },
101
158
  ];
102
159
  render(<CodeEditor defaultValue="" actions={actions} />);
160
+ await flush();
103
161
  expect(h.editor.addAction).toHaveBeenCalledTimes(2);
104
162
  expect(h.editor.addAction).toHaveBeenCalledWith(actions[0]);
105
163
  expect(h.editor.addAction).toHaveBeenCalledWith(actions[1]);
106
164
  });
107
165
 
108
- it("disposes old disposables and re-adds when the actions array identity changes", () => {
166
+ it("disposes old disposables and re-adds when the actions array identity changes", async () => {
109
167
  const actionsV1 = [{ id: "test.a", label: "A", run: vi.fn() }];
110
168
  const actionsV2 = [
111
169
  { id: "test.a", label: "A", run: vi.fn() },
112
170
  { id: "test.b", label: "B", run: vi.fn() },
113
171
  ];
114
172
  const { rerender } = render(<CodeEditor defaultValue="" actions={actionsV1} />);
173
+ await flush();
115
174
  expect(h.editor.addAction).toHaveBeenCalledTimes(1);
116
175
 
117
176
  rerender(<CodeEditor defaultValue="" actions={actionsV2} />);
@@ -120,15 +179,83 @@ describe("CodeEditor", () => {
120
179
  expect(h.editor.addAction).toHaveBeenCalledTimes(3); // 1 + 2
121
180
  });
122
181
 
123
- it("disposes all action disposables on unmount", () => {
182
+ it("disposes all action disposables on unmount", async () => {
124
183
  const actions = [
125
184
  { id: "test.x", label: "X", run: vi.fn() },
126
185
  { id: "test.y", label: "Y", run: vi.fn() },
127
186
  ];
128
187
  const { unmount } = render(<CodeEditor defaultValue="" actions={actions} />);
188
+ await flush();
129
189
  expect(h.editor.addAction).toHaveBeenCalledTimes(2);
130
190
  unmount();
131
191
  expect(h.actionDisposable.dispose).toHaveBeenCalledTimes(2);
132
192
  });
133
193
  });
194
+
195
+ describe("path changes after mount", () => {
196
+ it("swaps the model instead of ignoring the new path (Monaco has no URI setter)", async () => {
197
+ const { rerender } = render(
198
+ <CodeEditor defaultValue="x" path="a.ts" language="typescript" />,
199
+ );
200
+ await flush();
201
+ expect(h.createModel).toHaveBeenCalledTimes(1);
202
+ const firstModel = h.models[0]!;
203
+
204
+ rerender(<CodeEditor defaultValue="x" path="b.ts" language="typescript" />);
205
+
206
+ expect(h.createModel).toHaveBeenCalledTimes(2);
207
+ expect(h.editor.setModel).toHaveBeenCalledWith(h.models[1]);
208
+ expect(firstModel.dispose).toHaveBeenCalledTimes(1);
209
+ });
210
+
211
+ it("does not rebuild the model when path is unchanged across a re-render", async () => {
212
+ const { rerender } = render(<CodeEditor defaultValue="x" path="a.ts" />);
213
+ await flush();
214
+ expect(h.createModel).toHaveBeenCalledTimes(1);
215
+
216
+ rerender(<CodeEditor defaultValue="x" path="a.ts" ariaLabel="renamed" />);
217
+
218
+ expect(h.createModel).toHaveBeenCalledTimes(1);
219
+ expect(h.editor.setModel).not.toHaveBeenCalled();
220
+ });
221
+ });
222
+
223
+ describe("controlled value sync", () => {
224
+ it("applies a single-character change via executeEdits, never setValue", async () => {
225
+ const { rerender } = render(<CodeEditor value="abc" onChange={vi.fn()} />);
226
+ await flush();
227
+ expect(h.state.value).toBe("abc");
228
+
229
+ rerender(<CodeEditor value="abd" onChange={vi.fn()} />);
230
+
231
+ expect(h.editor.executeEdits).toHaveBeenCalledTimes(1);
232
+ expect(h.editor.setValue).not.toHaveBeenCalled();
233
+ expect(h.state.value).toBe("abd");
234
+ });
235
+
236
+ it("does nothing when the controlled value already matches the model", async () => {
237
+ render(<CodeEditor value="same" onChange={vi.fn()} />);
238
+ await flush();
239
+ h.editor.executeEdits.mockClear();
240
+
241
+ render(<CodeEditor value="same" onChange={vi.fn()} />);
242
+ await flush();
243
+
244
+ expect(h.editor.executeEdits).not.toHaveBeenCalled();
245
+ });
246
+ });
247
+
248
+ describe("options changes after mount", () => {
249
+ it("re-applies a changed `options` prop via updateOptions", async () => {
250
+ const { rerender } = render(
251
+ <CodeEditor defaultValue="" options={{ minimap: { enabled: false } }} />,
252
+ );
253
+ await flush();
254
+ h.editor.updateOptions.mockClear();
255
+
256
+ rerender(<CodeEditor defaultValue="" options={{ minimap: { enabled: true } }} />);
257
+
258
+ expect(h.editor.updateOptions).toHaveBeenCalledWith({ minimap: { enabled: true } });
259
+ });
260
+ });
134
261
  });
@@ -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 },
@@ -89,7 +136,7 @@ const BASE_OPTIONS: monaco.editor.IStandaloneEditorConstructionOptions = {
89
136
  * A token-themed Monaco editor wrapped as a brand-ui React component. Monaco
90
137
  * renders its own editing surface + widgets; this wrapper owns lifecycle,
91
138
  * controlled/uncontrolled value, and applies the brand theme bridge so the
92
- * editor matches the active `data-theme` (all three themes).
139
+ * editor matches the active `data-theme` (every theme).
93
140
  *
94
141
  * Workers (for completions/diagnostics) are wired by importing
95
142
  * `@elabs-ai/components-editor/monaco-environment` once at the app entry.
@@ -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
  }