@elabs-ai/components-editor 4.1.0 → 5.0.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 +265 -188
- 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 +11 -7
- 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/markdown-editor.stories.tsx +9 -3
- 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
|
@@ -9,6 +9,13 @@ import { Command } from '@milkdown/kit/prose/state';
|
|
|
9
9
|
type MonacoCodeEditor = monaco.editor.IStandaloneCodeEditor;
|
|
10
10
|
/** A Monaco editor action (system command + optional hotkey + palette/menu entry). */
|
|
11
11
|
type EditorAction = monaco.editor.IActionDescriptor;
|
|
12
|
+
/**
|
|
13
|
+
* The runtime value type handed back by `import("monaco-editor")` — same
|
|
14
|
+
* namespace as the type-only `monaco` import above (`typeof` a type-only
|
|
15
|
+
* namespace import resolves to the module's own type), used for `monacoRef`
|
|
16
|
+
* and `onMount`'s second argument.
|
|
17
|
+
*/
|
|
18
|
+
type MonacoNamespace = typeof monaco;
|
|
12
19
|
interface CodeEditorProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue"> {
|
|
13
20
|
/** Controlled content. Pair with `onChange`. */
|
|
14
21
|
value?: string;
|
|
@@ -44,7 +51,7 @@ interface CodeEditorProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange
|
|
|
44
51
|
*/
|
|
45
52
|
contextMenu?: "brand" | "monaco" | "none";
|
|
46
53
|
/** Called once the editor instance + monaco namespace are ready. */
|
|
47
|
-
onMount?: (editor: MonacoCodeEditor, monacoApi:
|
|
54
|
+
onMount?: (editor: MonacoCodeEditor, monacoApi: MonacoNamespace) => void;
|
|
48
55
|
/**
|
|
49
56
|
* Declarative Monaco editor actions — each registers a command (run on its
|
|
50
57
|
* `keybindings`, in the command palette, and optionally the context menu via
|
package/dist/monaco.d.ts
ADDED
package/dist/monaco.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/monaco.ts"],"sourcesContent":["\"use client\";\n\n/**\n * The `monaco-editor` namespace, as its own subpath (ADR 0006 subpath exports).\n *\n * The root barrel (`.`) exports lightweight chrome — `CopyButton`,\n * `EDITOR_LANGUAGES`, `languageLabel` — that a consumer should be able to\n * import WITHOUT pulling Monaco in at all: the editing surfaces\n * (`CodeEditor`, `DiffEditor`, `MarkdownEditor`) already own lazy-loading the\n * engine themselves. Re-exporting the whole `monaco-editor` namespace from\n * that SAME barrel defeated that split — `import { CopyButton } from\n * \"@elabs-ai/components-editor\"` pulled megabytes of Monaco and touched\n * browser globals at import time, breaking SSR/RSC for a component that never\n * renders the editor.\n *\n * A consumer that needs to build a custom editor, or wire Monaco commands\n * directly, imports this subpath instead:\n *\n * import { monaco } from \"@elabs-ai/components-editor/monaco\";\n */\nexport * as monaco from \"monaco-editor\";\n"],"mappings":";;;;AAoBA,YAAY,YAAY;","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elabs-ai/components-editor",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -36,6 +36,10 @@
|
|
|
36
36
|
"./monaco-environment": {
|
|
37
37
|
"types": "./dist/lib/monaco-environment.d.ts",
|
|
38
38
|
"default": "./dist/lib/monaco-environment.js"
|
|
39
|
+
},
|
|
40
|
+
"./monaco": {
|
|
41
|
+
"types": "./dist/monaco.d.ts",
|
|
42
|
+
"default": "./dist/monaco.js"
|
|
39
43
|
}
|
|
40
44
|
},
|
|
41
45
|
"publishConfig": {
|
|
@@ -65,8 +69,8 @@
|
|
|
65
69
|
"monaco-editor": "^0.55.1",
|
|
66
70
|
"react": "^18.2.0 || ^19.0.0",
|
|
67
71
|
"react-dom": "^18.2.0 || ^19.0.0",
|
|
68
|
-
"@elabs-ai/components-tokens": "
|
|
69
|
-
"@elabs-ai/components-ui": "
|
|
72
|
+
"@elabs-ai/components-tokens": "5.0.0",
|
|
73
|
+
"@elabs-ai/components-ui": "5.0.0"
|
|
70
74
|
},
|
|
71
75
|
"devDependencies": {
|
|
72
76
|
"@testing-library/jest-dom": "^6.6.3",
|
|
@@ -86,9 +90,9 @@
|
|
|
86
90
|
"typescript": "^5.7.3",
|
|
87
91
|
"vitest": "^3.0.2",
|
|
88
92
|
"@elabs-ai/components-eslint-config": "0.1.0",
|
|
89
|
-
"@elabs-ai/components-tokens": "
|
|
93
|
+
"@elabs-ai/components-tokens": "5.0.0",
|
|
90
94
|
"@elabs-ai/components-typescript-config": "0.1.0",
|
|
91
|
-
"@elabs-ai/components-ui": "
|
|
95
|
+
"@elabs-ai/components-ui": "5.0.0"
|
|
92
96
|
},
|
|
93
97
|
"scripts": {
|
|
94
98
|
"build": "rm -rf dist && tsup && node ../../scripts/link-dist-css.mjs",
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
CardContent,
|
|
25
25
|
CardHeader,
|
|
26
26
|
Separator,
|
|
27
|
+
useLocale,
|
|
27
28
|
type BadgeProps,
|
|
28
29
|
} from "@elabs-ai/components-ui";
|
|
29
30
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
@@ -51,11 +52,12 @@ const STATUS_BADGE_VARIANT: Record<DecisionStatus, BadgeProps["variant"]> = {
|
|
|
51
52
|
superseded: "secondary",
|
|
52
53
|
};
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
/** Status → translation key (see `editor.decisionCard.status.*` in messages.ts). */
|
|
56
|
+
const STATUS_LABEL_KEYS: Record<DecisionStatus, string> = {
|
|
57
|
+
accepted: "editor.decisionCard.statusAccepted",
|
|
58
|
+
rejected: "editor.decisionCard.statusRejected",
|
|
59
|
+
proposed: "editor.decisionCard.statusProposed",
|
|
60
|
+
superseded: "editor.decisionCard.statusSuperseded",
|
|
59
61
|
};
|
|
60
62
|
|
|
61
63
|
const STATUS_ICONS: Record<DecisionStatus, ComponentType<SVGProps<SVGSVGElement>>> = {
|
|
@@ -113,11 +115,12 @@ export const DecisionCard = forwardRef<HTMLElement, DecisionCardProps>(function
|
|
|
113
115
|
{ status: rawStatus, date, alternatives, children, className, ...props },
|
|
114
116
|
ref,
|
|
115
117
|
) {
|
|
118
|
+
const { t } = useLocale();
|
|
116
119
|
const status: DecisionStatus = isDecisionStatus(rawStatus ?? "")
|
|
117
120
|
? (rawStatus as DecisionStatus)
|
|
118
121
|
: "proposed";
|
|
119
122
|
const badgeVariant = STATUS_BADGE_VARIANT[status];
|
|
120
|
-
const label =
|
|
123
|
+
const label = t(STATUS_LABEL_KEYS[status]);
|
|
121
124
|
const Icon = STATUS_ICONS[status];
|
|
122
125
|
|
|
123
126
|
const altItems = alternatives
|
|
@@ -130,7 +133,7 @@ export const DecisionCard = forwardRef<HTMLElement, DecisionCardProps>(function
|
|
|
130
133
|
return (
|
|
131
134
|
<section
|
|
132
135
|
ref={ref}
|
|
133
|
-
aria-label={
|
|
136
|
+
aria-label={t("editor.decisionCard.label", { label })}
|
|
134
137
|
className={cn("not-prose", className)}
|
|
135
138
|
{...props}
|
|
136
139
|
>
|
|
@@ -158,9 +161,12 @@ export const DecisionCard = forwardRef<HTMLElement, DecisionCardProps>(function
|
|
|
158
161
|
<Separator />
|
|
159
162
|
<div className="px-6 py-4">
|
|
160
163
|
<p className="mb-2 text-meta font-medium text-muted-foreground">
|
|
161
|
-
|
|
164
|
+
{t("editor.decisionCard.alternativesConsidered")}
|
|
162
165
|
</p>
|
|
163
|
-
<ul
|
|
166
|
+
<ul
|
|
167
|
+
className="flex flex-wrap gap-1.5"
|
|
168
|
+
aria-label={t("editor.decisionCard.alternativesConsidered")}
|
|
169
|
+
>
|
|
164
170
|
{altItems.map((alt) => (
|
|
165
171
|
<li key={alt}>
|
|
166
172
|
<Badge variant="outline" className="text-meta">
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* `KnowledgeCard` is standalone (no `resolve` → sources rendered as plain labels).
|
|
19
19
|
* The `knowledgeDirective({ resolve })` factory wires the hook for directive use.
|
|
20
20
|
*/
|
|
21
|
-
import { Card, CardContent, CardFooter } from "@elabs-ai/components-ui";
|
|
21
|
+
import { Card, CardContent, CardFooter, useLocale } from "@elabs-ai/components-ui";
|
|
22
22
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
23
23
|
import { BookOpen, FileText } from "lucide-react";
|
|
24
24
|
import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
|
|
@@ -49,6 +49,7 @@ interface SourceRowProps {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
function SourceRow({ path, resolve }: SourceRowProps) {
|
|
52
|
+
const { t } = useLocale();
|
|
52
53
|
const resolved = resolve ? resolve(path) : null;
|
|
53
54
|
const display = resolved?.title ?? path;
|
|
54
55
|
|
|
@@ -58,9 +59,9 @@ function SourceRow({ path, resolve }: SourceRowProps) {
|
|
|
58
59
|
href={resolved.href}
|
|
59
60
|
rel="noopener noreferrer"
|
|
60
61
|
target="_blank"
|
|
61
|
-
// #399 — a source link is TEXT:
|
|
62
|
-
className="flex min-w-0 items-center gap-1.5 text-meta text-
|
|
63
|
-
aria-label={
|
|
62
|
+
// #399 — a source link is TEXT: the `--link` ink, not the fill.
|
|
63
|
+
className="flex min-w-0 items-center gap-1.5 text-meta text-link underline-offset-2 hover:underline focus-ring"
|
|
64
|
+
aria-label={t("editor.knowledgeCard.source", { name: display })}
|
|
64
65
|
>
|
|
65
66
|
<FileText className="size-3 shrink-0" aria-hidden="true" />
|
|
66
67
|
<span className="truncate">{display}</span>
|
|
@@ -71,7 +72,7 @@ function SourceRow({ path, resolve }: SourceRowProps) {
|
|
|
71
72
|
return (
|
|
72
73
|
<span
|
|
73
74
|
className="flex min-w-0 items-center gap-1.5 text-meta text-muted-foreground"
|
|
74
|
-
aria-label={
|
|
75
|
+
aria-label={t("editor.knowledgeCard.sourceUnresolved", { name: display })}
|
|
75
76
|
>
|
|
76
77
|
<FileText className="size-3 shrink-0" aria-hidden="true" />
|
|
77
78
|
<span className="truncate">{display}</span>
|
|
@@ -102,6 +103,7 @@ export const KnowledgeCard = forwardRef<HTMLElement, KnowledgeCardProps>(functio
|
|
|
102
103
|
{ sources, resolve, children, className, ...props },
|
|
103
104
|
ref,
|
|
104
105
|
) {
|
|
106
|
+
const { t } = useLocale();
|
|
105
107
|
const sourcePaths = sources
|
|
106
108
|
? sources
|
|
107
109
|
.split(",")
|
|
@@ -112,7 +114,7 @@ export const KnowledgeCard = forwardRef<HTMLElement, KnowledgeCardProps>(functio
|
|
|
112
114
|
return (
|
|
113
115
|
<section
|
|
114
116
|
ref={ref}
|
|
115
|
-
aria-label="
|
|
117
|
+
aria-label={t("editor.knowledgeCard.label")}
|
|
116
118
|
className={cn("not-prose", className)}
|
|
117
119
|
{...props}
|
|
118
120
|
>
|
|
@@ -120,15 +122,22 @@ export const KnowledgeCard = forwardRef<HTMLElement, KnowledgeCardProps>(functio
|
|
|
120
122
|
<CardContent className="pt-4">
|
|
121
123
|
<div className="mb-2 flex items-center gap-1.5">
|
|
122
124
|
<BookOpen className="size-3.5 shrink-0 text-info-text" aria-hidden="true" />
|
|
123
|
-
<span className="text-meta font-medium text-info-text">
|
|
125
|
+
<span className="text-meta font-medium text-info-text">
|
|
126
|
+
{t("editor.knowledgeCard.heading")}
|
|
127
|
+
</span>
|
|
124
128
|
</div>
|
|
125
129
|
<div className="text-body text-foreground">{children}</div>
|
|
126
130
|
</CardContent>
|
|
127
131
|
|
|
128
132
|
{sourcePaths.length > 0 ? (
|
|
129
133
|
<CardFooter className="flex-col items-start gap-1 border-t border-border pt-3">
|
|
130
|
-
<p className="text-meta font-medium text-muted-foreground">
|
|
131
|
-
|
|
134
|
+
<p className="text-meta font-medium text-muted-foreground">
|
|
135
|
+
{t("editor.knowledgeCard.sources")}
|
|
136
|
+
</p>
|
|
137
|
+
<ul
|
|
138
|
+
className="flex w-full flex-col gap-1"
|
|
139
|
+
aria-label={t("editor.knowledgeCard.sources")}
|
|
140
|
+
>
|
|
132
141
|
{sourcePaths.map((path) => (
|
|
133
142
|
<li key={path} className="min-w-0">
|
|
134
143
|
<SourceRow path={path} resolve={resolve} />
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The root barrel (`.`) exports lightweight chrome (`CopyButton`,
|
|
5
|
+
* `EDITOR_LANGUAGES`, `languageLabel`) alongside the Monaco-backed editing
|
|
6
|
+
* surfaces (`CodeEditor`, `DiffEditor`, `CodeWorkspace`). A consumer that only
|
|
7
|
+
* wants the chrome must never pay for Monaco: `monaco-editor` touches browser
|
|
8
|
+
* globals at import time and is megabytes on the wire, breaking SSR/RSC for a
|
|
9
|
+
* component that never renders an editor.
|
|
10
|
+
*
|
|
11
|
+
* `code-editor.tsx`/`diff-editor.tsx` used to `import * as monaco from
|
|
12
|
+
* "monaco-editor"` at the top of the module — a VALUE import, evaluated the
|
|
13
|
+
* moment anything imports the barrel (native ESM instantiates every module in
|
|
14
|
+
* the graph, regardless of which named export is actually read). This test
|
|
15
|
+
* locks the fix: mock `monaco-editor` to THROW on evaluation, then import the
|
|
16
|
+
* barrel and prove the lightweight exports still work. If either editing
|
|
17
|
+
* surface regresses to a top-level value import, this fails at the `import`
|
|
18
|
+
* statement itself.
|
|
19
|
+
*/
|
|
20
|
+
vi.mock("monaco-editor", () => {
|
|
21
|
+
throw new Error(
|
|
22
|
+
"monaco-editor must not be evaluated merely by importing the @elabs-ai/components-editor barrel",
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("@elabs-ai/components-editor barrel", () => {
|
|
27
|
+
it("importing the barrel never evaluates monaco-editor", async () => {
|
|
28
|
+
await expect(import("./index")).resolves.toBeDefined();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("CopyButton is usable without evaluating monaco-editor", async () => {
|
|
32
|
+
const { CopyButton } = await import("./index");
|
|
33
|
+
expect(CopyButton).toBeTypeOf("function");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("EDITOR_LANGUAGES / languageLabel are usable without evaluating monaco-editor", async () => {
|
|
37
|
+
const { EDITOR_LANGUAGES, languageLabel } = await import("./index");
|
|
38
|
+
expect(Array.isArray(EDITOR_LANGUAGES)).toBe(true);
|
|
39
|
+
expect(EDITOR_LANGUAGES.length).toBeGreaterThan(0);
|
|
40
|
+
expect(languageLabel(EDITOR_LANGUAGES[0]!.id)).toBeTypeOf("string");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("EditorToolbar + EditorContextMenu are usable (rendered) without evaluating monaco-editor", async () => {
|
|
44
|
+
const { EditorToolbar, EditorContextMenu } = await import("./index");
|
|
45
|
+
// Both are `forwardRef`/context components — objects, not plain functions;
|
|
46
|
+
// assert they're valid React element types instead of a bare `typeof`.
|
|
47
|
+
expect(EditorToolbar).toBeTruthy();
|
|
48
|
+
expect(EditorContextMenu).toBeTruthy();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
* color is never the only signal (var-def = weight, unresolved = dotted underline),
|
|
33
33
|
* so roles stay distinct in the high-contrast theme.
|
|
34
34
|
*/
|
|
35
|
+
import { useLocale } from "@elabs-ai/components-ui";
|
|
35
36
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
36
37
|
import { TriangleAlert } from "lucide-react";
|
|
37
38
|
import { forwardRef, useId, useMemo, type HTMLAttributes, type ReactNode } from "react";
|
|
@@ -239,6 +240,7 @@ function renderSource(text: string, tokens: CalcToken[]): ReactNode {
|
|
|
239
240
|
|
|
240
241
|
/** The right-hand cell: a value, a calm error marker, or nothing. */
|
|
241
242
|
function ResultCell({ result }: { result: CalcLineResult }): ReactNode {
|
|
243
|
+
const { t } = useLocale();
|
|
242
244
|
if (result.error) {
|
|
243
245
|
// Native `title` (not a Radix Tooltip) so CalcBlock is self-contained — it
|
|
244
246
|
// renders inside MarkdownPreview / a story with no TooltipProvider ancestor.
|
|
@@ -246,7 +248,7 @@ function ResultCell({ result }: { result: CalcLineResult }): ReactNode {
|
|
|
246
248
|
<span
|
|
247
249
|
className="inline-flex shrink-0 text-calc-warning"
|
|
248
250
|
title={result.error.message}
|
|
249
|
-
aria-label={
|
|
251
|
+
aria-label={t("editor.calcBlock.error", { message: result.error.message })}
|
|
250
252
|
>
|
|
251
253
|
<TriangleAlert className="size-3.5" aria-hidden="true" />
|
|
252
254
|
</span>
|
|
@@ -255,7 +257,7 @@ function ResultCell({ result }: { result: CalcLineResult }): ReactNode {
|
|
|
255
257
|
if (result.value) {
|
|
256
258
|
return (
|
|
257
259
|
<div className="brand-calc-tok--result shrink-0 tabular-nums text-calc-result">
|
|
258
|
-
<span className="sr-only">equals
|
|
260
|
+
<span className="sr-only">{t("editor.calcBlock.equals")}</span>
|
|
259
261
|
<span>{result.value.display}</span>
|
|
260
262
|
</div>
|
|
261
263
|
);
|
|
@@ -270,7 +272,7 @@ export const CalcBlock = forwardRef<HTMLDivElement, CalcBlockProps>(function Cal
|
|
|
270
272
|
title,
|
|
271
273
|
showTotal = true,
|
|
272
274
|
total,
|
|
273
|
-
totalLabel
|
|
275
|
+
totalLabel: totalLabelProp,
|
|
274
276
|
markers = true,
|
|
275
277
|
readOnly: _readOnly,
|
|
276
278
|
className,
|
|
@@ -278,6 +280,8 @@ export const CalcBlock = forwardRef<HTMLDivElement, CalcBlockProps>(function Cal
|
|
|
278
280
|
},
|
|
279
281
|
ref,
|
|
280
282
|
) {
|
|
283
|
+
const { t } = useLocale();
|
|
284
|
+
const totalLabel = totalLabelProp ?? t("editor.calcBlock.total");
|
|
281
285
|
// Author markers are stripped BEFORE evaluation: the math engine sees clean
|
|
282
286
|
// lines, and because markers are trailing, token columns stay aligned with the
|
|
283
287
|
// rendered (cleaned) text. `lines`/`evalSource` are the marker-free versions.
|
|
@@ -376,7 +380,9 @@ export const CalcBlock = forwardRef<HTMLDivElement, CalcBlockProps>(function Cal
|
|
|
376
380
|
</div>
|
|
377
381
|
|
|
378
382
|
{empty ? (
|
|
379
|
-
<p className="px-4 py-6 text-body text-muted-foreground">
|
|
383
|
+
<p className="px-4 py-6 text-body text-muted-foreground">
|
|
384
|
+
{t("editor.calcBlock.emptyBlock")}
|
|
385
|
+
</p>
|
|
380
386
|
) : (
|
|
381
387
|
<div className="flex flex-col gap-y-1 px-4 py-3 font-mono text-code leading-relaxed">
|
|
382
388
|
{rows}
|
|
@@ -385,9 +391,7 @@ export const CalcBlock = forwardRef<HTMLDivElement, CalcBlockProps>(function Cal
|
|
|
385
391
|
|
|
386
392
|
{showTotal && resolvedTotal != null && !empty ? (
|
|
387
393
|
<div className="flex items-center justify-between border-t border-border px-4 py-2">
|
|
388
|
-
<span className="text-
|
|
389
|
-
{totalLabel}
|
|
390
|
-
</span>
|
|
394
|
+
<span className="text-eyebrow uppercase text-muted-foreground">{totalLabel}</span>
|
|
391
395
|
<span className="font-mono text-code font-medium tabular-nums text-foreground">
|
|
392
396
|
{totalDisplay}
|
|
393
397
|
</span>
|
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import { act, cleanup, render } from "@testing-library/react";
|
|
2
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
|
3
3
|
|
|
4
4
|
// Monaco needs real layout/canvas, so it cannot mount in jsdom — mock the engine
|
|
5
5
|
// and assert the wrapper's lifecycle contract. Real rendering + a11y are covered
|
|
6
6
|
// by the Storybook interaction tests.
|
|
7
7
|
const h = vi.hoisted(() => {
|
|
8
8
|
const state = { changeHandler: undefined as undefined | (() => void), value: "" };
|
|
9
|
-
const
|
|
9
|
+
const models: {
|
|
10
|
+
dispose: Mock;
|
|
11
|
+
uri: { toString(): string } | undefined;
|
|
12
|
+
getValue: () => string;
|
|
13
|
+
getPositionAt: (offset: number) => { lineNumber: number; column: number };
|
|
14
|
+
_value: string;
|
|
15
|
+
}[] = [];
|
|
10
16
|
const actionDisposable = { dispose: vi.fn() };
|
|
17
|
+
// The most recently created/set model — a simple single-document stand-in
|
|
18
|
+
// (columns are 1-based character OFFSETS, not real Monaco line/column) good
|
|
19
|
+
// enough to exercise `executeEdits`'s edit shape without a real Monaco model.
|
|
20
|
+
let currentModel: (typeof models)[number] | undefined;
|
|
11
21
|
const editor = {
|
|
12
22
|
onDidChangeModelContent: vi.fn((cb: () => void) => {
|
|
13
23
|
state.changeHandler = cb;
|
|
@@ -17,7 +27,19 @@ const h = vi.hoisted(() => {
|
|
|
17
27
|
setValue: vi.fn((v: string) => {
|
|
18
28
|
state.value = v;
|
|
19
29
|
}),
|
|
20
|
-
getModel: vi.fn(() =>
|
|
30
|
+
getModel: vi.fn(() => currentModel),
|
|
31
|
+
setModel: vi.fn((m: (typeof models)[number]) => {
|
|
32
|
+
currentModel = m;
|
|
33
|
+
}),
|
|
34
|
+
executeEdits: vi.fn(
|
|
35
|
+
(_source: string, edits: { range: { start: number; end: number }; text: string }[]) => {
|
|
36
|
+
const edit = edits[0]!;
|
|
37
|
+
const next =
|
|
38
|
+
state.value.slice(0, edit.range.start) + edit.text + state.value.slice(edit.range.end);
|
|
39
|
+
state.value = next;
|
|
40
|
+
if (currentModel) currentModel._value = next;
|
|
41
|
+
},
|
|
42
|
+
),
|
|
21
43
|
updateOptions: vi.fn(),
|
|
22
44
|
addAction: vi.fn(() => actionDisposable),
|
|
23
45
|
// Minimal DOM node so the aria-forwarding effect can find (or not) a textarea.
|
|
@@ -26,13 +48,29 @@ const h = vi.hoisted(() => {
|
|
|
26
48
|
};
|
|
27
49
|
return {
|
|
28
50
|
state,
|
|
29
|
-
|
|
51
|
+
models,
|
|
52
|
+
get model() {
|
|
53
|
+
return models[0];
|
|
54
|
+
},
|
|
30
55
|
actionDisposable,
|
|
31
56
|
editor,
|
|
32
57
|
create: vi.fn(() => editor),
|
|
33
|
-
createModel: vi.fn((value: string) => {
|
|
58
|
+
createModel: vi.fn((value: string, _language: string, uri?: { toString(): string }) => {
|
|
34
59
|
state.value = value;
|
|
35
|
-
|
|
60
|
+
const m = {
|
|
61
|
+
dispose: vi.fn(),
|
|
62
|
+
uri,
|
|
63
|
+
_value: value,
|
|
64
|
+
getValue(this: { _value: string }) {
|
|
65
|
+
return this._value;
|
|
66
|
+
},
|
|
67
|
+
getPositionAt(offset: number) {
|
|
68
|
+
return { lineNumber: 1, column: offset };
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
models.push(m);
|
|
72
|
+
currentModel = m;
|
|
73
|
+
return m;
|
|
36
74
|
}),
|
|
37
75
|
};
|
|
38
76
|
});
|
|
@@ -46,38 +84,56 @@ vi.mock("monaco-editor", () => ({
|
|
|
46
84
|
setTheme: vi.fn(),
|
|
47
85
|
},
|
|
48
86
|
Uri: { parse: (s: string) => ({ toString: () => s }) },
|
|
87
|
+
Range: {
|
|
88
|
+
fromPositions: (start: { column: number }, end: { column: number }) => ({
|
|
89
|
+
start: start.column,
|
|
90
|
+
end: end.column,
|
|
91
|
+
}),
|
|
92
|
+
},
|
|
49
93
|
}));
|
|
50
94
|
|
|
51
95
|
import { CodeEditor } from "./code-editor";
|
|
52
96
|
|
|
97
|
+
// The engine now loads via a dynamic `import("monaco-editor")` inside the
|
|
98
|
+
// mount effect (kept lazy so the barrel never evaluates Monaco just for
|
|
99
|
+
// `CopyButton`/`EDITOR_LANGUAGES` — see barrel-monaco-lazy.test.ts). The mock
|
|
100
|
+
// module still resolves through that dynamic import, but only after a
|
|
101
|
+
// microtask: flush it with `await act(async () => {})` after every
|
|
102
|
+
// render/rerender that expects the editor to already exist.
|
|
103
|
+
const flush = () => act(async () => {});
|
|
104
|
+
|
|
53
105
|
beforeEach(() => {
|
|
54
106
|
h.state.changeHandler = undefined;
|
|
55
107
|
h.state.value = "";
|
|
108
|
+
h.models.length = 0;
|
|
56
109
|
vi.clearAllMocks();
|
|
57
110
|
});
|
|
58
111
|
afterEach(cleanup);
|
|
59
112
|
|
|
60
113
|
describe("CodeEditor", () => {
|
|
61
|
-
it("creates a Monaco editor with the initial value + language", () => {
|
|
114
|
+
it("creates a Monaco editor with the initial value + language", async () => {
|
|
62
115
|
const { getByTestId } = render(
|
|
63
116
|
<CodeEditor defaultValue="const a = 1;" language="typescript" />,
|
|
64
117
|
);
|
|
65
118
|
expect(getByTestId("code-editor")).toBeInTheDocument();
|
|
119
|
+
await flush();
|
|
66
120
|
expect(h.create).toHaveBeenCalledTimes(1);
|
|
67
121
|
// No `path` → no model URI.
|
|
68
122
|
expect(h.createModel).toHaveBeenCalledWith("const a = 1;", "typescript", undefined);
|
|
69
123
|
});
|
|
70
124
|
|
|
71
|
-
it("creates the model with a URI derived from `path`", () => {
|
|
125
|
+
it("creates the model with a URI derived from `path`", async () => {
|
|
72
126
|
render(<CodeEditor defaultValue="x" language="json" path="config/app.json" />);
|
|
127
|
+
await flush();
|
|
73
128
|
expect(h.createModel).toHaveBeenCalledWith("x", "json", {
|
|
74
129
|
toString: expect.any(Function),
|
|
75
130
|
});
|
|
76
131
|
});
|
|
77
132
|
|
|
78
|
-
it("forwards content edits to onChange", () => {
|
|
133
|
+
it("forwards content edits to onChange", async () => {
|
|
79
134
|
const onChange = vi.fn();
|
|
80
135
|
render(<CodeEditor defaultValue="" onChange={onChange} />);
|
|
136
|
+
await flush();
|
|
81
137
|
expect(h.state.changeHandler).toBeTypeOf("function");
|
|
82
138
|
act(() => {
|
|
83
139
|
h.state.value = "typed";
|
|
@@ -86,32 +142,35 @@ describe("CodeEditor", () => {
|
|
|
86
142
|
expect(onChange).toHaveBeenCalledWith("typed");
|
|
87
143
|
});
|
|
88
144
|
|
|
89
|
-
it("disposes the editor + model on unmount", () => {
|
|
145
|
+
it("disposes the editor + model on unmount", async () => {
|
|
90
146
|
const { unmount } = render(<CodeEditor defaultValue="x" />);
|
|
147
|
+
await flush();
|
|
91
148
|
unmount();
|
|
92
149
|
expect(h.editor.dispose).toHaveBeenCalledTimes(1);
|
|
93
|
-
expect(h.model
|
|
150
|
+
expect(h.model!.dispose).toHaveBeenCalledTimes(1);
|
|
94
151
|
});
|
|
95
152
|
|
|
96
153
|
describe("actions prop", () => {
|
|
97
|
-
it("calls addAction once per descriptor on mount", () => {
|
|
154
|
+
it("calls addAction once per descriptor on mount", async () => {
|
|
98
155
|
const actions = [
|
|
99
156
|
{ id: "test.one", label: "One", run: vi.fn() },
|
|
100
157
|
{ id: "test.two", label: "Two", run: vi.fn() },
|
|
101
158
|
];
|
|
102
159
|
render(<CodeEditor defaultValue="" actions={actions} />);
|
|
160
|
+
await flush();
|
|
103
161
|
expect(h.editor.addAction).toHaveBeenCalledTimes(2);
|
|
104
162
|
expect(h.editor.addAction).toHaveBeenCalledWith(actions[0]);
|
|
105
163
|
expect(h.editor.addAction).toHaveBeenCalledWith(actions[1]);
|
|
106
164
|
});
|
|
107
165
|
|
|
108
|
-
it("disposes old disposables and re-adds when the actions array identity changes", () => {
|
|
166
|
+
it("disposes old disposables and re-adds when the actions array identity changes", async () => {
|
|
109
167
|
const actionsV1 = [{ id: "test.a", label: "A", run: vi.fn() }];
|
|
110
168
|
const actionsV2 = [
|
|
111
169
|
{ id: "test.a", label: "A", run: vi.fn() },
|
|
112
170
|
{ id: "test.b", label: "B", run: vi.fn() },
|
|
113
171
|
];
|
|
114
172
|
const { rerender } = render(<CodeEditor defaultValue="" actions={actionsV1} />);
|
|
173
|
+
await flush();
|
|
115
174
|
expect(h.editor.addAction).toHaveBeenCalledTimes(1);
|
|
116
175
|
|
|
117
176
|
rerender(<CodeEditor defaultValue="" actions={actionsV2} />);
|
|
@@ -120,15 +179,83 @@ describe("CodeEditor", () => {
|
|
|
120
179
|
expect(h.editor.addAction).toHaveBeenCalledTimes(3); // 1 + 2
|
|
121
180
|
});
|
|
122
181
|
|
|
123
|
-
it("disposes all action disposables on unmount", () => {
|
|
182
|
+
it("disposes all action disposables on unmount", async () => {
|
|
124
183
|
const actions = [
|
|
125
184
|
{ id: "test.x", label: "X", run: vi.fn() },
|
|
126
185
|
{ id: "test.y", label: "Y", run: vi.fn() },
|
|
127
186
|
];
|
|
128
187
|
const { unmount } = render(<CodeEditor defaultValue="" actions={actions} />);
|
|
188
|
+
await flush();
|
|
129
189
|
expect(h.editor.addAction).toHaveBeenCalledTimes(2);
|
|
130
190
|
unmount();
|
|
131
191
|
expect(h.actionDisposable.dispose).toHaveBeenCalledTimes(2);
|
|
132
192
|
});
|
|
133
193
|
});
|
|
194
|
+
|
|
195
|
+
describe("path changes after mount", () => {
|
|
196
|
+
it("swaps the model instead of ignoring the new path (Monaco has no URI setter)", async () => {
|
|
197
|
+
const { rerender } = render(
|
|
198
|
+
<CodeEditor defaultValue="x" path="a.ts" language="typescript" />,
|
|
199
|
+
);
|
|
200
|
+
await flush();
|
|
201
|
+
expect(h.createModel).toHaveBeenCalledTimes(1);
|
|
202
|
+
const firstModel = h.models[0]!;
|
|
203
|
+
|
|
204
|
+
rerender(<CodeEditor defaultValue="x" path="b.ts" language="typescript" />);
|
|
205
|
+
|
|
206
|
+
expect(h.createModel).toHaveBeenCalledTimes(2);
|
|
207
|
+
expect(h.editor.setModel).toHaveBeenCalledWith(h.models[1]);
|
|
208
|
+
expect(firstModel.dispose).toHaveBeenCalledTimes(1);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("does not rebuild the model when path is unchanged across a re-render", async () => {
|
|
212
|
+
const { rerender } = render(<CodeEditor defaultValue="x" path="a.ts" />);
|
|
213
|
+
await flush();
|
|
214
|
+
expect(h.createModel).toHaveBeenCalledTimes(1);
|
|
215
|
+
|
|
216
|
+
rerender(<CodeEditor defaultValue="x" path="a.ts" ariaLabel="renamed" />);
|
|
217
|
+
|
|
218
|
+
expect(h.createModel).toHaveBeenCalledTimes(1);
|
|
219
|
+
expect(h.editor.setModel).not.toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("controlled value sync", () => {
|
|
224
|
+
it("applies a single-character change via executeEdits, never setValue", async () => {
|
|
225
|
+
const { rerender } = render(<CodeEditor value="abc" onChange={vi.fn()} />);
|
|
226
|
+
await flush();
|
|
227
|
+
expect(h.state.value).toBe("abc");
|
|
228
|
+
|
|
229
|
+
rerender(<CodeEditor value="abd" onChange={vi.fn()} />);
|
|
230
|
+
|
|
231
|
+
expect(h.editor.executeEdits).toHaveBeenCalledTimes(1);
|
|
232
|
+
expect(h.editor.setValue).not.toHaveBeenCalled();
|
|
233
|
+
expect(h.state.value).toBe("abd");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("does nothing when the controlled value already matches the model", async () => {
|
|
237
|
+
render(<CodeEditor value="same" onChange={vi.fn()} />);
|
|
238
|
+
await flush();
|
|
239
|
+
h.editor.executeEdits.mockClear();
|
|
240
|
+
|
|
241
|
+
render(<CodeEditor value="same" onChange={vi.fn()} />);
|
|
242
|
+
await flush();
|
|
243
|
+
|
|
244
|
+
expect(h.editor.executeEdits).not.toHaveBeenCalled();
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("options changes after mount", () => {
|
|
249
|
+
it("re-applies a changed `options` prop via updateOptions", async () => {
|
|
250
|
+
const { rerender } = render(
|
|
251
|
+
<CodeEditor defaultValue="" options={{ minimap: { enabled: false } }} />,
|
|
252
|
+
);
|
|
253
|
+
await flush();
|
|
254
|
+
h.editor.updateOptions.mockClear();
|
|
255
|
+
|
|
256
|
+
rerender(<CodeEditor defaultValue="" options={{ minimap: { enabled: true } }} />);
|
|
257
|
+
|
|
258
|
+
expect(h.editor.updateOptions).toHaveBeenCalledWith({ minimap: { enabled: true } });
|
|
259
|
+
});
|
|
260
|
+
});
|
|
134
261
|
});
|