@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
|
@@ -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
|
});
|
|
@@ -144,3 +162,87 @@ describe("CodeWorkspace — CodeWorkspaceHandle via ref", () => {
|
|
|
144
162
|
expect(listener).not.toHaveBeenCalled(); // not called yet, only on selection change
|
|
145
163
|
});
|
|
146
164
|
});
|
|
165
|
+
|
|
166
|
+
describe("CodeWorkspace — tab/panel a11y (#154)", () => {
|
|
167
|
+
it("gives every tab an aria-controls that resolves to a real element, for paths with '/', '.', a space and non-ASCII", () => {
|
|
168
|
+
const trickyFiles: EditorFile[] = [
|
|
169
|
+
{ path: "src/hello.ts", value: "a" },
|
|
170
|
+
{ path: "a b/c.d.json", value: "b" },
|
|
171
|
+
{ path: "café/résumé.md", value: "c" },
|
|
172
|
+
];
|
|
173
|
+
render(<CodeWorkspace files={trickyFiles} />);
|
|
174
|
+
const tabs = screen.getAllByRole("tab");
|
|
175
|
+
expect(tabs).toHaveLength(trickyFiles.length);
|
|
176
|
+
for (const tab of tabs) {
|
|
177
|
+
const controls = tab.getAttribute("aria-controls");
|
|
178
|
+
expect(controls).toBeTruthy();
|
|
179
|
+
expect(document.getElementById(controls!)).not.toBeNull();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("CodeWorkspace — force-mounted panels stay out of the layout", () => {
|
|
185
|
+
const threeFiles: EditorFile[] = [
|
|
186
|
+
{ path: "a.ts", value: "A" },
|
|
187
|
+
{ path: "b.ts", value: "B" },
|
|
188
|
+
{ path: "c.ts", value: "C" },
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
it("hides every inactive tabpanel so it takes no layout space and no tab stop", () => {
|
|
192
|
+
render(<CodeWorkspace files={threeFiles} />);
|
|
193
|
+
const panels = screen.getAllByRole("tabpanel", { hidden: true });
|
|
194
|
+
expect(panels).toHaveLength(3);
|
|
195
|
+
const active = panels.filter((p) => p.getAttribute("data-state") === "active");
|
|
196
|
+
const inactive = panels.filter((p) => p.getAttribute("data-state") !== "active");
|
|
197
|
+
expect(active).toHaveLength(1);
|
|
198
|
+
expect(active[0]!.hasAttribute("hidden")).toBe(false);
|
|
199
|
+
expect(active[0]!.getAttribute("tabindex")).toBe("0");
|
|
200
|
+
for (const panel of inactive) {
|
|
201
|
+
expect(panel.hasAttribute("hidden")).toBe(true);
|
|
202
|
+
expect(panel.getAttribute("tabindex")).toBe("-1");
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("keeps aria-controls resolvable while the inactive panels are hidden", () => {
|
|
207
|
+
render(<CodeWorkspace files={threeFiles} />);
|
|
208
|
+
for (const tab of screen.getAllByRole("tab")) {
|
|
209
|
+
const controls = tab.getAttribute("aria-controls");
|
|
210
|
+
expect(controls).toBeTruthy();
|
|
211
|
+
expect(document.getElementById(controls!)).not.toBeNull();
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("CodeWorkspace — tab identity survives a file-list reorder (#412 review)", () => {
|
|
217
|
+
it("keeps the active panel's id stable and does not remount Monaco when a file is prepended", async () => {
|
|
218
|
+
const initial: EditorFile[] = [
|
|
219
|
+
{ path: "src/a.ts", value: "AAA" },
|
|
220
|
+
{ path: "b.json", value: "{}" },
|
|
221
|
+
];
|
|
222
|
+
const { rerender } = render(<CodeWorkspace files={initial} />);
|
|
223
|
+
await flush();
|
|
224
|
+
const idBefore = screen
|
|
225
|
+
.getAllByRole("tabpanel", { hidden: true })
|
|
226
|
+
.find((p) => p.getAttribute("data-state") === "active")!.id;
|
|
227
|
+
const createdBefore = h.create.mock.calls.length;
|
|
228
|
+
|
|
229
|
+
rerender(<CodeWorkspace files={[{ path: "zz/new.ts", value: "N" }, ...initial]} />);
|
|
230
|
+
|
|
231
|
+
const idAfter = screen
|
|
232
|
+
.getAllByRole("tabpanel", { hidden: true })
|
|
233
|
+
.find((p) => p.getAttribute("data-state") === "active")!.id;
|
|
234
|
+
expect(idAfter).toBe(idBefore);
|
|
235
|
+
expect(h.create.mock.calls.length).toBe(createdBefore);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("gives colliding paths distinct tab values", () => {
|
|
239
|
+
const colliding: EditorFile[] = [
|
|
240
|
+
{ path: "a/b", value: "1" },
|
|
241
|
+
{ path: "a.b", value: "2" },
|
|
242
|
+
{ path: "a-b", value: "3" },
|
|
243
|
+
];
|
|
244
|
+
render(<CodeWorkspace files={colliding} />);
|
|
245
|
+
const ids = screen.getAllByRole("tabpanel", { hidden: true }).map((p) => p.id);
|
|
246
|
+
expect(new Set(ids).size).toBe(colliding.length);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Tabs, TabsList, TabsTrigger } from "@elabs-ai/components-ui";
|
|
3
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@elabs-ai/components-ui";
|
|
4
4
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
5
5
|
import {
|
|
6
6
|
forwardRef,
|
|
@@ -74,6 +74,31 @@ const EXT_LANGUAGE: Record<string, string> = {
|
|
|
74
74
|
|
|
75
75
|
const basename = (path: string) => path.split("/").pop() || path;
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Radix composes each `TabsContent`'s DOM id from the `Tabs`/`TabsTrigger`
|
|
79
|
+
* `value` (see #154): a raw `file.path` leaks `/`, `.`, spaces and non-ASCII
|
|
80
|
+
* characters straight into that id, which is what a screen reader gets back
|
|
81
|
+
* via `aria-controls`. `path` stays the display label and the lookup key in
|
|
82
|
+
* component state (`active`); this is only ever a DOM-id-safe Tabs value.
|
|
83
|
+
*
|
|
84
|
+
* The encoding is INJECTIVE and depends only on the path — never on the
|
|
85
|
+
* file's position in `files`. Every character outside `[A-Za-z0-9-]` becomes
|
|
86
|
+
* `_<hex code point>_`, and `_` itself is never emitted literally, so the
|
|
87
|
+
* mapping is reversible and two distinct paths can never produce the same
|
|
88
|
+
* value ("a/b" → `tab-a_2f_b`, "a.b" → `tab-a_2e_b`). An index-derived value
|
|
89
|
+
* would have been collision-safe too, but it changes when the list is
|
|
90
|
+
* reordered or a file is inserted, which re-keys the active `TabsContent` and
|
|
91
|
+
* throws away Monaco's selection, scroll position and undo history on an
|
|
92
|
+
* otherwise harmless `files` update.
|
|
93
|
+
*/
|
|
94
|
+
function tabIdForFile(path: string): string {
|
|
95
|
+
let out = "";
|
|
96
|
+
for (const char of path) {
|
|
97
|
+
out += /[A-Za-z0-9-]/.test(char) ? char : `_${char.codePointAt(0)!.toString(16)}_`;
|
|
98
|
+
}
|
|
99
|
+
return `tab-${out}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
77
102
|
function inferLanguage(file: EditorFile): string {
|
|
78
103
|
if (file.language) return file.language;
|
|
79
104
|
const ext = file.path.split(".").pop()?.toLowerCase() ?? "";
|
|
@@ -143,6 +168,10 @@ export const CodeWorkspace = forwardRef<CodeWorkspaceHandle, CodeWorkspaceProps>
|
|
|
143
168
|
const activeFile = files.find((f) => f.path === requested) ?? files[0];
|
|
144
169
|
const active = activeFile?.path ?? "";
|
|
145
170
|
|
|
171
|
+
// DOM-id-safe Tabs value per file (#154); `path` stays the lookup key.
|
|
172
|
+
const tabs = files.map((file) => ({ file, id: tabIdForFile(file.path) }));
|
|
173
|
+
const activeTabId = tabs.find((t) => t.file.path === active)?.id ?? "";
|
|
174
|
+
|
|
146
175
|
// The root div ref (getElement() preserves old HTMLDivElement access).
|
|
147
176
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
148
177
|
|
|
@@ -213,16 +242,19 @@ export const CodeWorkspace = forwardRef<CodeWorkspaceHandle, CodeWorkspaceProps>
|
|
|
213
242
|
{...props}
|
|
214
243
|
>
|
|
215
244
|
<Tabs
|
|
216
|
-
value={
|
|
217
|
-
onValueChange={
|
|
245
|
+
value={activeTabId}
|
|
246
|
+
onValueChange={(id) => {
|
|
247
|
+
const found = tabs.find((t) => t.id === id);
|
|
248
|
+
if (found) selectTab(found.file.path);
|
|
249
|
+
}}
|
|
218
250
|
className="flex min-h-0 flex-1 flex-col gap-0"
|
|
219
251
|
>
|
|
220
252
|
<div className="flex items-center border-b border-border bg-surface">
|
|
221
253
|
<TabsList className="h-9 flex-1 justify-start gap-0 rounded-none bg-transparent p-0">
|
|
222
|
-
{
|
|
254
|
+
{tabs.map(({ file, id }) => (
|
|
223
255
|
<TabsTrigger
|
|
224
|
-
key={
|
|
225
|
-
value={
|
|
256
|
+
key={id}
|
|
257
|
+
value={id}
|
|
226
258
|
className="h-9 rounded-none border-e border-border px-3 text-xs data-[state=active]:bg-background data-[state=active]:shadow-none"
|
|
227
259
|
>
|
|
228
260
|
{basename(file.path)}
|
|
@@ -232,20 +264,41 @@ export const CodeWorkspace = forwardRef<CodeWorkspaceHandle, CodeWorkspaceProps>
|
|
|
232
264
|
{activeFile ? <CopyButton value={activeFile.value} className="me-1.5" /> : null}
|
|
233
265
|
</div>
|
|
234
266
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
267
|
+
{tabs.map(({ file, id }) => {
|
|
268
|
+
const isActive = file.path === activeFile?.path;
|
|
269
|
+
return (
|
|
270
|
+
// `forceMount` keeps every tab's content in the DOM so each
|
|
271
|
+
// trigger's `aria-controls` resolves to a real element, not only
|
|
272
|
+
// the active one (#154). Radix derives its own `hidden` from
|
|
273
|
+
// `forceMount || isSelected`, so under `forceMount` it hides
|
|
274
|
+
// NOTHING: without the explicit `hidden` below every inactive
|
|
275
|
+
// panel stays a visible `flex-1` child and the editor height is
|
|
276
|
+
// split across all of them. `tabIndex={-1}` keeps the empty
|
|
277
|
+
// panels out of the tab sequence for the same reason. Only the
|
|
278
|
+
// active file mounts a (single) Monaco instance.
|
|
279
|
+
<TabsContent
|
|
280
|
+
key={id}
|
|
281
|
+
value={id}
|
|
282
|
+
forceMount
|
|
283
|
+
hidden={!isActive}
|
|
284
|
+
tabIndex={isActive ? 0 : -1}
|
|
285
|
+
className="mt-0 min-h-0 flex-1"
|
|
286
|
+
>
|
|
287
|
+
{isActive ? (
|
|
288
|
+
<CodeEditor
|
|
289
|
+
key={file.path}
|
|
290
|
+
path={file.path}
|
|
291
|
+
language={inferLanguage(file)}
|
|
292
|
+
value={file.value}
|
|
293
|
+
readOnly={readOnly}
|
|
294
|
+
onChange={(next) => onFileChange?.(file.path, next)}
|
|
295
|
+
onMount={(editor) => setActiveEditor(editor)}
|
|
296
|
+
height="100%"
|
|
297
|
+
/>
|
|
298
|
+
) : null}
|
|
299
|
+
</TabsContent>
|
|
300
|
+
);
|
|
301
|
+
})}
|
|
249
302
|
</Tabs>
|
|
250
303
|
</div>
|
|
251
304
|
);
|
|
@@ -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
|
}
|
|
@@ -5,7 +5,15 @@ const meta = {
|
|
|
5
5
|
title: "Editor/DiffEditor",
|
|
6
6
|
component: DiffEditor,
|
|
7
7
|
tags: ["autodocs"],
|
|
8
|
-
parameters: {
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: "fullscreen",
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
"The only diff surface that can be TYPED INTO — Monaco’s side-by-side editor. Reading lines of a patch is `AI/DiffView`, accepting or rejecting per hunk is `AI/ChangeReview`, and reading a patch in a console transcript is `Terminal/TerminalDiffHunk`. See [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs).",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
9
17
|
} satisfies Meta<typeof DiffEditor>;
|
|
10
18
|
|
|
11
19
|
export default meta;
|
|
@@ -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";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambient declarations for the `monaco-editor` deep ESM paths used by
|
|
3
|
+
* `monaco-theme-bridge.test.ts` to resolve syntax colours through Monaco's
|
|
4
|
+
* REAL token-theme trie (#90) — the only way to prove the bridge's rules
|
|
5
|
+
* actually beat the inherited `vs`/`vs-dark` base themes, since the bug this
|
|
6
|
+
* locks is invisible to a check that only reads `buildBrandThemeData()`'s
|
|
7
|
+
* returned array.
|
|
8
|
+
*
|
|
9
|
+
* `monaco-editor`'s package `exports` map (`"./*": "./*"`) makes these paths
|
|
10
|
+
* resolvable at runtime/bundle time, but ships no `.d.ts` for anything below
|
|
11
|
+
* the top-level `editor.main.d.ts` barrel — hence the shims. Typed only as
|
|
12
|
+
* far as the test needs; internals (`ThemeTrieElementRule`'s private-by-
|
|
13
|
+
* convention `_foreground`/`_background` fields) are read structurally
|
|
14
|
+
* because that's the real shape `tokenization.js` constructs at runtime (see
|
|
15
|
+
* `ThemeTrieElementRule` in that file for the source of truth).
|
|
16
|
+
*/
|
|
17
|
+
declare module "monaco-editor/esm/vs/editor/common/languages/supports/tokenization.js" {
|
|
18
|
+
import type * as Monaco from "monaco-editor";
|
|
19
|
+
|
|
20
|
+
/** Mirrors `ThemeTrieElementRule` — the resolved node `_match` returns. */
|
|
21
|
+
interface ResolvedThemeTrieRule {
|
|
22
|
+
readonly _fontStyle: number;
|
|
23
|
+
readonly _foreground: number;
|
|
24
|
+
readonly _background: number;
|
|
25
|
+
readonly metadata: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Mirrors `Color` (`base/common/color.js`) — enough to read RGB channels. */
|
|
29
|
+
interface MonacoColor {
|
|
30
|
+
readonly rgba: { r: number; g: number; b: number; a: number };
|
|
31
|
+
toString(): string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class TokenTheme {
|
|
35
|
+
static createFromRawTokenTheme(
|
|
36
|
+
source: Monaco.editor.ITokenThemeRule[],
|
|
37
|
+
customTokenColors: string[],
|
|
38
|
+
): TokenTheme;
|
|
39
|
+
/** Internal (underscore) but exercised directly — see file header. */
|
|
40
|
+
_match(token: string): ResolvedThemeTrieRule;
|
|
41
|
+
getColorMap(): MonacoColor[];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
declare module "monaco-editor/esm/vs/editor/standalone/common/themes.js" {
|
|
46
|
+
import type * as Monaco from "monaco-editor";
|
|
47
|
+
|
|
48
|
+
interface BuiltinRawTheme {
|
|
49
|
+
readonly base: string;
|
|
50
|
+
readonly inherit: boolean;
|
|
51
|
+
readonly rules: Monaco.editor.ITokenThemeRule[];
|
|
52
|
+
readonly colors: Record<string, string>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const vs: BuiltinRawTheme;
|
|
56
|
+
export const vs_dark: BuiltinRawTheme;
|
|
57
|
+
export const hc_black: BuiltinRawTheme;
|
|
58
|
+
export const hc_light: BuiltinRawTheme;
|
|
59
|
+
}
|