@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.
- package/dist/{chunk-C62O7IOQ.js → chunk-FO5S3YZM.js} +241 -158
- package/dist/chunk-FO5S3YZM.js.map +1 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +57 -39
- package/dist/index.js.map +1 -1
- package/dist/markdown/index.d.ts +2 -2
- package/dist/markdown/index.js +264 -187
- package/dist/markdown/index.js.map +1 -1
- package/dist/{markdown-editor-CBp_4eDv.d.ts → markdown-editor-Dn-0L_MM.d.ts} +8 -1
- package/dist/monaco.d.ts +2 -0
- package/dist/monaco.js +9 -0
- package/dist/monaco.js.map +1 -0
- package/package.json +9 -5
- package/src/ai-objects/decision-card.tsx +15 -9
- 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/code-editor/code-editor.test.tsx +141 -14
- package/src/code-editor/code-editor.tsx +166 -35
- package/src/code-workspace/code-workspace.test.tsx +31 -12
- package/src/copy-button/copy-button.tsx +6 -3
- 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/markdown-academic/citations.tsx +14 -7
- package/src/markdown-academic/footnotes.tsx +1 -1
- package/src/markdown-academic/math.tsx +7 -4
- package/src/markdown-editor/completions/completions-menu.tsx +5 -2
- package/src/markdown-editor/directive-views.tsx +33 -19
- package/src/markdown-editor/slash/slash-menu.tsx +5 -2
- package/src/markdown-editor/table-view.tsx +28 -15
- 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.tsx +6 -2
- package/src/markdown-preview/markdown-preview-academic.test.tsx +3 -3
- package/src/markdown-toolbar/markdown-toolbar.tsx +42 -24
- package/src/markdown-workspace/markdown-workspace.test.tsx +22 -3
- 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 +21 -20
- package/src/monaco.ts +21 -0
- package/dist/chunk-C62O7IOQ.js.map +0 -1
|
@@ -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 },
|
|
@@ -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
|
}
|
|
@@ -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(() => ({
|
|
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
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
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={
|
|
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">{
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
151
|
+
const monacoApi = monacoRef.current;
|
|
152
|
+
if (!editor || !monacoApi) return;
|
|
129
153
|
try {
|
|
130
|
-
applyBrandTheme(
|
|
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
|
|
67
|
-
|
|
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
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
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";
|