@elabs-ai/components-editor 4.0.0 → 4.1.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-C62O7IOQ.js} +110 -21
- package/dist/chunk-C62O7IOQ.js.map +1 -0
- package/dist/index.css +4 -2
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +57 -20
- 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 +9 -9
- package/dist/markdown/index.js.map +1 -1
- package/dist/{markdown-editor-DfBZibAn.d.ts → markdown-editor-CBp_4eDv.d.ts} +1 -1
- package/package.json +6 -6
- package/src/ai-objects/entity.tsx +1 -1
- package/src/ai-objects/knowledge-card.tsx +1 -1
- 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.tsx +1 -1
- package/src/code-workspace/code-workspace.test.tsx +83 -0
- package/src/code-workspace/code-workspace.tsx +73 -20
- package/src/diff-editor/diff-editor.stories.tsx +9 -1
- 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 +2 -2
- package/src/markdown-academic/footnotes.tsx +2 -2
- package/src/markdown-academic/toc.tsx +1 -1
- package/src/markdown-editor/directive-views.tsx +2 -5
- 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 +13 -1
- package/src/markdown-editor/table-view.tsx +3 -2
- package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +57 -19
- package/src/markdown-outline/document-outline.stories.tsx +1 -1
- package/src/markdown-outline/document-outline.tsx +1 -1
- 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-workspace/markdown-workspace.test.tsx +31 -1
- package/src/mermaid-diagram/mermaid-viewer.tsx +1 -1
- 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
|
@@ -62,7 +62,7 @@ interface CodeEditorProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange
|
|
|
62
62
|
* A token-themed Monaco editor wrapped as a brand-ui React component. Monaco
|
|
63
63
|
* renders its own editing surface + widgets; this wrapper owns lifecycle,
|
|
64
64
|
* controlled/uncontrolled value, and applies the brand theme bridge so the
|
|
65
|
-
* editor matches the active `data-theme` (
|
|
65
|
+
* editor matches the active `data-theme` (every theme).
|
|
66
66
|
*
|
|
67
67
|
* Workers (for completions/diagnostics) are wired by importing
|
|
68
68
|
* `@elabs-ai/components-editor/monaco-environment` once at the app entry.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elabs-ai/components-editor",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
"monaco-editor": "^0.55.1",
|
|
66
66
|
"react": "^18.2.0 || ^19.0.0",
|
|
67
67
|
"react-dom": "^18.2.0 || ^19.0.0",
|
|
68
|
-
"@elabs-ai/components-tokens": "4.
|
|
69
|
-
"@elabs-ai/components-ui": "4.
|
|
68
|
+
"@elabs-ai/components-tokens": "4.1.0",
|
|
69
|
+
"@elabs-ai/components-ui": "4.1.0"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"@testing-library/jest-dom": "^6.6.3",
|
|
@@ -85,10 +85,10 @@
|
|
|
85
85
|
"tsup": "^8.3.5",
|
|
86
86
|
"typescript": "^5.7.3",
|
|
87
87
|
"vitest": "^3.0.2",
|
|
88
|
+
"@elabs-ai/components-eslint-config": "0.1.0",
|
|
89
|
+
"@elabs-ai/components-tokens": "4.1.0",
|
|
88
90
|
"@elabs-ai/components-typescript-config": "0.1.0",
|
|
89
|
-
"@elabs-ai/components-
|
|
90
|
-
"@elabs-ai/components-ui": "4.0.0",
|
|
91
|
-
"@elabs-ai/components-eslint-config": "0.1.0"
|
|
91
|
+
"@elabs-ai/components-ui": "4.1.0"
|
|
92
92
|
},
|
|
93
93
|
"scripts": {
|
|
94
94
|
"build": "rm -rf dist && tsup && node ../../scripts/link-dist-css.mjs",
|
|
@@ -62,7 +62,7 @@ function kindMeta(kind: string | undefined): KindMeta {
|
|
|
62
62
|
export const entityChipVariants = cva(
|
|
63
63
|
// inline-flex + align-middle keeps the chip in the text baseline;
|
|
64
64
|
// no `block` wrapper so it is valid inside <p>.
|
|
65
|
-
"inline-flex items-center gap-1 rounded-sm border px-1.5 py-0.5 text-meta font-medium align-middle focus-
|
|
65
|
+
"inline-flex items-center gap-1 rounded-sm border px-1.5 py-0.5 text-meta font-medium align-middle focus-ring",
|
|
66
66
|
{
|
|
67
67
|
variants: {
|
|
68
68
|
kind: {
|
|
@@ -59,7 +59,7 @@ function SourceRow({ path, resolve }: SourceRowProps) {
|
|
|
59
59
|
rel="noopener noreferrer"
|
|
60
60
|
target="_blank"
|
|
61
61
|
// #399 — a source link is TEXT: on-surface `-text` rung, not the fill.
|
|
62
|
-
className="flex min-w-0 items-center gap-1.5 text-meta text-primary-text underline-offset-2 hover:underline focus-
|
|
62
|
+
className="flex min-w-0 items-center gap-1.5 text-meta text-primary-text underline-offset-2 hover:underline focus-ring"
|
|
63
63
|
aria-label={`Source: ${display}`}
|
|
64
64
|
>
|
|
65
65
|
<FileText className="size-3 shrink-0" aria-hidden="true" />
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Colors come from the `--calc-*` syntax tokens — the SAME tokens `CalcBlock`
|
|
7
7
|
* paints with (#221), already AA-verified against `--background` (the editor
|
|
8
|
-
* surface) and `--card` in
|
|
8
|
+
* surface) and `--card` in every theme — so highlighting tracks the theme via
|
|
9
9
|
* the cascade with no hardcoded hex. The compound `.brand-calc-tok.brand-calc-tok--x`
|
|
10
10
|
* selector raises specificity above Monaco's own token color class (`.mtkN`) so
|
|
11
11
|
* the calc color wins regardless of stylesheet load order (no `!important`).
|
|
@@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
|
|
|
4
4
|
import * as monaco from "monaco-editor";
|
|
5
5
|
import { CodeEditor } from "./code-editor";
|
|
6
6
|
import { EditorToolbar } from "../editor-toolbar";
|
|
7
|
+
import { buildBrandThemeData } from "../lib/monaco-theme-bridge";
|
|
7
8
|
|
|
8
9
|
const meta = {
|
|
9
10
|
title: "Editor/CodeEditor",
|
|
@@ -14,9 +15,12 @@ const meta = {
|
|
|
14
15
|
docs: {
|
|
15
16
|
description: {
|
|
16
17
|
component:
|
|
18
|
+
"The real Monaco EDITING surface; a read-only highlighted block is `AI/CodeBlock` " +
|
|
19
|
+
"and a tool-output viewer is `AI/Sandbox` — see " +
|
|
20
|
+
"[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
|
|
17
21
|
"A token-themed Monaco editor. The editing surface + widgets are Monaco; " +
|
|
18
22
|
"brand-ui supplies the chrome (toolbar/Select/Button). Colors track the " +
|
|
19
|
-
"active `data-theme` across
|
|
23
|
+
"active `data-theme` across every theme via the theming bridge.",
|
|
20
24
|
},
|
|
21
25
|
},
|
|
22
26
|
},
|
|
@@ -76,6 +80,15 @@ export const WithToolbar: Story = {
|
|
|
76
80
|
},
|
|
77
81
|
};
|
|
78
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Locks #90: Monaco's `vs`/`vs-dark` base themes ship LANGUAGE-SUFFIXED JSON
|
|
85
|
+
* scopes (`string.key.json`, `string.value.json`, `keyword.json`) that beat the
|
|
86
|
+
* bridge's shorter, unsuffixed rules in Monaco's token-theme trie (deepest scope
|
|
87
|
+
* wins) — so JSON rendered in stock VS Code colors instead of brand colors. The
|
|
88
|
+
* play function reads REAL computed colors off the rendered `.mtk*` token spans
|
|
89
|
+
* and asserts none of them is a stock JSON color, in whichever theme this story
|
|
90
|
+
* is rendered under (`globals=theme:light|dark`).
|
|
91
|
+
*/
|
|
79
92
|
export const JsonReadOnly: Story = {
|
|
80
93
|
name: "JSON (read-only)",
|
|
81
94
|
render: () => (
|
|
@@ -83,6 +96,68 @@ export const JsonReadOnly: Story = {
|
|
|
83
96
|
<CodeEditor language="json" defaultValue={JSON_SAMPLE} readOnly />
|
|
84
97
|
</div>
|
|
85
98
|
),
|
|
99
|
+
play: async ({ canvasElement }) => {
|
|
100
|
+
// Monaco mounts asynchronously, and syntax tokenization is a SEPARATE,
|
|
101
|
+
// later pass: the editor first paints one un-highlighted `.mtk*` span per
|
|
102
|
+
// line, then re-renders with per-scope spans once the JSON monarch
|
|
103
|
+
// tokenizer finishes (observed to take ~1-3s in a headless test browser).
|
|
104
|
+
// Waiting for "any `.mtk*` span" resolves on that FIRST, un-highlighted
|
|
105
|
+
// paint — a false green that passes identically whether the fix is
|
|
106
|
+
// present or not (#90 fix-round-1 finding). Wait for the actual `"name"`
|
|
107
|
+
// key to have tokenized into its own span instead — that only exists once
|
|
108
|
+
// real per-scope highlighting has landed.
|
|
109
|
+
const surface = await within(canvasElement).findByTestId("code-editor", {}, { timeout: 10000 });
|
|
110
|
+
let spans: HTMLSpanElement[] = [];
|
|
111
|
+
await waitFor(
|
|
112
|
+
() => {
|
|
113
|
+
spans = Array.from(
|
|
114
|
+
surface.querySelectorAll<HTMLSpanElement>('.view-line span[class*="mtk"]'),
|
|
115
|
+
);
|
|
116
|
+
const keySpan = spans.some((span) => span.textContent?.trim() === '"name"');
|
|
117
|
+
if (!keySpan) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Monaco has not tokenized the "name" key into its own span yet (spans=${spans.length})`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
{ timeout: 20000, interval: 100 },
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// Stock `vs`/`vs-dark` colors for string.key.json / string.value.json /
|
|
127
|
+
// keyword.json (extracted straight from monaco-editor's own base themes —
|
|
128
|
+
// see monaco-theme-bridge.test.ts's "Monaco real trie resolution" block for
|
|
129
|
+
// the same values as raw hex). If the bridge's brand rules ever stop beating
|
|
130
|
+
// the base theme's suffixed scopes, a token renders one of these again.
|
|
131
|
+
const stockColors = new Set([
|
|
132
|
+
"rgb(163, 21, 21)", // vs: string.key.json / string (maroon)
|
|
133
|
+
"rgb(4, 81, 165)", // vs: string.value.json / keyword.json (navy)
|
|
134
|
+
"rgb(156, 220, 254)", // vs-dark: string.key.json (cyan)
|
|
135
|
+
"rgb(206, 145, 120)", // vs-dark: string.value.json / keyword.json / string (orange)
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
const offenders = spans
|
|
139
|
+
.map((span) => getComputedStyle(span).color)
|
|
140
|
+
.filter((color) => stockColors.has(color));
|
|
141
|
+
await expect(offenders).toEqual([]);
|
|
142
|
+
|
|
143
|
+
// Positive check, not just an absence check: the "name" key span must be
|
|
144
|
+
// colored with the bridge's OWN live `string.key.json` brand ink (read
|
|
145
|
+
// straight off the currently-applied CSS custom properties), not merely
|
|
146
|
+
// "some color that isn't stock" — a fix that mapped the scope to the
|
|
147
|
+
// wrong token would pass the negative check above but fail this one.
|
|
148
|
+
const hexToRgb = (hex: string) => {
|
|
149
|
+
const n = Number.parseInt(hex.replace("#", ""), 16);
|
|
150
|
+
return `rgb(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255})`;
|
|
151
|
+
};
|
|
152
|
+
const liveRules = buildBrandThemeData().rules ?? [];
|
|
153
|
+
const expectedKeyForeground = liveRules.find(
|
|
154
|
+
(rule) => rule.token === "string.key.json",
|
|
155
|
+
)?.foreground;
|
|
156
|
+
await expect(expectedKeyForeground).toBeDefined();
|
|
157
|
+
const keySpan = spans.find((span) => span.textContent?.trim() === '"name"');
|
|
158
|
+
await expect(keySpan).toBeDefined();
|
|
159
|
+
await expect(getComputedStyle(keySpan!).color).toEqual(hexToRgb(expectedKeyForeground!));
|
|
160
|
+
},
|
|
86
161
|
};
|
|
87
162
|
|
|
88
163
|
/**
|
|
@@ -89,7 +89,7 @@ const BASE_OPTIONS: monaco.editor.IStandaloneEditorConstructionOptions = {
|
|
|
89
89
|
* A token-themed Monaco editor wrapped as a brand-ui React component. Monaco
|
|
90
90
|
* renders its own editing surface + widgets; this wrapper owns lifecycle,
|
|
91
91
|
* controlled/uncontrolled value, and applies the brand theme bridge so the
|
|
92
|
-
* editor matches the active `data-theme` (
|
|
92
|
+
* editor matches the active `data-theme` (every theme).
|
|
93
93
|
*
|
|
94
94
|
* Workers (for completions/diagnostics) are wired by importing
|
|
95
95
|
* `@elabs-ai/components-editor/monaco-environment` once at the app entry.
|
|
@@ -144,3 +144,86 @@ describe("CodeWorkspace — CodeWorkspaceHandle via ref", () => {
|
|
|
144
144
|
expect(listener).not.toHaveBeenCalled(); // not called yet, only on selection change
|
|
145
145
|
});
|
|
146
146
|
});
|
|
147
|
+
|
|
148
|
+
describe("CodeWorkspace — tab/panel a11y (#154)", () => {
|
|
149
|
+
it("gives every tab an aria-controls that resolves to a real element, for paths with '/', '.', a space and non-ASCII", () => {
|
|
150
|
+
const trickyFiles: EditorFile[] = [
|
|
151
|
+
{ path: "src/hello.ts", value: "a" },
|
|
152
|
+
{ path: "a b/c.d.json", value: "b" },
|
|
153
|
+
{ path: "café/résumé.md", value: "c" },
|
|
154
|
+
];
|
|
155
|
+
render(<CodeWorkspace files={trickyFiles} />);
|
|
156
|
+
const tabs = screen.getAllByRole("tab");
|
|
157
|
+
expect(tabs).toHaveLength(trickyFiles.length);
|
|
158
|
+
for (const tab of tabs) {
|
|
159
|
+
const controls = tab.getAttribute("aria-controls");
|
|
160
|
+
expect(controls).toBeTruthy();
|
|
161
|
+
expect(document.getElementById(controls!)).not.toBeNull();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("CodeWorkspace — force-mounted panels stay out of the layout", () => {
|
|
167
|
+
const threeFiles: EditorFile[] = [
|
|
168
|
+
{ path: "a.ts", value: "A" },
|
|
169
|
+
{ path: "b.ts", value: "B" },
|
|
170
|
+
{ path: "c.ts", value: "C" },
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
it("hides every inactive tabpanel so it takes no layout space and no tab stop", () => {
|
|
174
|
+
render(<CodeWorkspace files={threeFiles} />);
|
|
175
|
+
const panels = screen.getAllByRole("tabpanel", { hidden: true });
|
|
176
|
+
expect(panels).toHaveLength(3);
|
|
177
|
+
const active = panels.filter((p) => p.getAttribute("data-state") === "active");
|
|
178
|
+
const inactive = panels.filter((p) => p.getAttribute("data-state") !== "active");
|
|
179
|
+
expect(active).toHaveLength(1);
|
|
180
|
+
expect(active[0]!.hasAttribute("hidden")).toBe(false);
|
|
181
|
+
expect(active[0]!.getAttribute("tabindex")).toBe("0");
|
|
182
|
+
for (const panel of inactive) {
|
|
183
|
+
expect(panel.hasAttribute("hidden")).toBe(true);
|
|
184
|
+
expect(panel.getAttribute("tabindex")).toBe("-1");
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("keeps aria-controls resolvable while the inactive panels are hidden", () => {
|
|
189
|
+
render(<CodeWorkspace files={threeFiles} />);
|
|
190
|
+
for (const tab of screen.getAllByRole("tab")) {
|
|
191
|
+
const controls = tab.getAttribute("aria-controls");
|
|
192
|
+
expect(controls).toBeTruthy();
|
|
193
|
+
expect(document.getElementById(controls!)).not.toBeNull();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
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", () => {
|
|
200
|
+
const initial: EditorFile[] = [
|
|
201
|
+
{ path: "src/a.ts", value: "AAA" },
|
|
202
|
+
{ path: "b.json", value: "{}" },
|
|
203
|
+
];
|
|
204
|
+
const { rerender } = render(<CodeWorkspace files={initial} />);
|
|
205
|
+
const idBefore = screen
|
|
206
|
+
.getAllByRole("tabpanel", { hidden: true })
|
|
207
|
+
.find((p) => p.getAttribute("data-state") === "active")!.id;
|
|
208
|
+
const createdBefore = h.create.mock.calls.length;
|
|
209
|
+
|
|
210
|
+
rerender(<CodeWorkspace files={[{ path: "zz/new.ts", value: "N" }, ...initial]} />);
|
|
211
|
+
|
|
212
|
+
const idAfter = screen
|
|
213
|
+
.getAllByRole("tabpanel", { hidden: true })
|
|
214
|
+
.find((p) => p.getAttribute("data-state") === "active")!.id;
|
|
215
|
+
expect(idAfter).toBe(idBefore);
|
|
216
|
+
expect(h.create.mock.calls.length).toBe(createdBefore);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("gives colliding paths distinct tab values", () => {
|
|
220
|
+
const colliding: EditorFile[] = [
|
|
221
|
+
{ path: "a/b", value: "1" },
|
|
222
|
+
{ path: "a.b", value: "2" },
|
|
223
|
+
{ path: "a-b", value: "3" },
|
|
224
|
+
];
|
|
225
|
+
render(<CodeWorkspace files={colliding} />);
|
|
226
|
+
const ids = screen.getAllByRole("tabpanel", { hidden: true }).map((p) => p.id);
|
|
227
|
+
expect(new Set(ids).size).toBe(colliding.length);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -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
|
);
|
|
@@ -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;
|
|
@@ -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
|
+
}
|