@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.
- package/README.md +6 -7
- package/dist/{chunk-LBC5VJBD.js → chunk-FO5S3YZM.js} +349 -177
- package/dist/chunk-FO5S3YZM.js.map +1 -0
- package/dist/index.css +4 -2
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.js +112 -57
- package/dist/index.js.map +1 -1
- package/dist/markdown/index.css +4 -2
- package/dist/markdown/index.css.map +1 -1
- package/dist/markdown/index.d.ts +2 -2
- package/dist/markdown/index.js +268 -191
- package/dist/markdown/index.js.map +1 -1
- package/dist/{markdown-editor-DfBZibAn.d.ts → markdown-editor-Dn-0L_MM.d.ts} +9 -2
- package/dist/monaco.d.ts +2 -0
- package/dist/monaco.js +9 -0
- package/dist/monaco.js.map +1 -0
- package/package.json +10 -6
- package/src/ai-objects/decision-card.tsx +15 -9
- package/src/ai-objects/entity.tsx +1 -1
- package/src/ai-objects/knowledge-card.tsx +18 -9
- package/src/barrel-monaco-lazy.test.ts +50 -0
- package/src/calc-block/calc-block.tsx +10 -4
- package/src/calc-block/calc-editor.css +1 -1
- package/src/code-editor/code-editor.stories.tsx +76 -1
- package/src/code-editor/code-editor.test.tsx +141 -14
- package/src/code-editor/code-editor.tsx +167 -36
- package/src/code-workspace/code-workspace.test.tsx +113 -11
- package/src/code-workspace/code-workspace.tsx +73 -20
- package/src/copy-button/copy-button.tsx +6 -3
- package/src/diff-editor/diff-editor.stories.tsx +9 -1
- package/src/diff-editor/diff-editor.test.tsx +9 -3
- package/src/diff-editor/diff-editor.tsx +47 -23
- package/src/editor-toolbar/editor-toolbar.tsx +8 -2
- package/src/index.ts +4 -3
- package/src/lib/monaco-deep-imports.d.ts +59 -0
- package/src/lib/monaco-theme-bridge.test.ts +307 -0
- package/src/lib/monaco-theme-bridge.ts +154 -10
- package/src/markdown-academic/citations.tsx +14 -7
- package/src/markdown-academic/footnotes.tsx +2 -2
- package/src/markdown-academic/math.tsx +7 -4
- package/src/markdown-academic/toc.tsx +1 -1
- package/src/markdown-editor/completions/completions-menu.tsx +5 -2
- package/src/markdown-editor/directive-views.tsx +35 -24
- package/src/markdown-editor/markdown-editor.css +26 -3
- package/src/markdown-editor/markdown-editor.focus.test.ts +49 -0
- package/src/markdown-editor/markdown-editor.stories.tsx +128 -7
- package/src/markdown-editor/markdown-editor.tsx +8 -5
- package/src/markdown-editor/milkdown-react/use-get-editor.timer-leak.test.ts +80 -0
- package/src/markdown-editor/milkdown-react/use-get-editor.ts +37 -1
- package/src/markdown-editor/paste-embed.ts +2 -1
- package/src/markdown-editor/slash/slash-menu.stories.tsx +8 -3
- package/src/markdown-editor/slash/slash-menu.test.tsx +27 -0
- package/src/markdown-editor/slash/slash-menu.tsx +18 -3
- package/src/markdown-editor/table-view.tsx +31 -17
- package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +57 -19
- package/src/markdown-iteration/iteration-builder-dialog.tsx +39 -18
- package/src/markdown-iteration/template-dialog.tsx +25 -7
- package/src/markdown-outline/document-outline.stories.tsx +1 -1
- package/src/markdown-outline/document-outline.tsx +7 -3
- package/src/markdown-preview/markdown-preview-academic.test.tsx +3 -3
- package/src/markdown-preview/markdown-preview-transclusion.test.tsx +1 -1
- package/src/markdown-preview/markdown-preview.stories.tsx +24 -2
- package/src/markdown-preview/markdown-preview.test.tsx +20 -3
- package/src/markdown-toolbar/markdown-toolbar.stories.tsx +3 -0
- package/src/markdown-toolbar/markdown-toolbar.tsx +42 -24
- package/src/markdown-workspace/markdown-workspace.test.tsx +53 -4
- package/src/markdown-workspace/markdown-workspace.tsx +6 -3
- package/src/mermaid-diagram/mermaid-diagram-fixes.test.tsx +130 -0
- package/src/mermaid-diagram/mermaid-diagram.test.tsx +2 -1
- package/src/mermaid-diagram/mermaid-diagram.tsx +89 -40
- package/src/mermaid-diagram/mermaid-viewer.tsx +22 -21
- package/src/monaco.ts +21 -0
- package/src/prose/prose.stories.tsx +3 -0
- package/src/prose/prose.test.ts +54 -0
- package/src/prose/prose.tsx +7 -0
- 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
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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` (
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
175
|
-
if (
|
|
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)
|
|
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(
|
|
321
|
+
applyBrandTheme(monacoApi, theme);
|
|
191
322
|
} catch (err) {
|
|
192
323
|
console.error("[@elabs-ai/components-editor] failed to apply brand theme", err);
|
|
193
324
|
}
|