@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.
Files changed (46) hide show
  1. package/dist/{chunk-C62O7IOQ.js → chunk-FO5S3YZM.js} +241 -158
  2. package/dist/chunk-FO5S3YZM.js.map +1 -0
  3. package/dist/index.d.ts +5 -4
  4. package/dist/index.js +57 -39
  5. package/dist/index.js.map +1 -1
  6. package/dist/markdown/index.d.ts +2 -2
  7. package/dist/markdown/index.js +265 -188
  8. package/dist/markdown/index.js.map +1 -1
  9. package/dist/{markdown-editor-CBp_4eDv.d.ts → markdown-editor-Dn-0L_MM.d.ts} +8 -1
  10. package/dist/monaco.d.ts +2 -0
  11. package/dist/monaco.js +9 -0
  12. package/dist/monaco.js.map +1 -0
  13. package/package.json +9 -5
  14. package/src/ai-objects/decision-card.tsx +15 -9
  15. package/src/ai-objects/knowledge-card.tsx +18 -9
  16. package/src/barrel-monaco-lazy.test.ts +50 -0
  17. package/src/calc-block/calc-block.tsx +11 -7
  18. package/src/code-editor/code-editor.test.tsx +141 -14
  19. package/src/code-editor/code-editor.tsx +166 -35
  20. package/src/code-workspace/code-workspace.test.tsx +31 -12
  21. package/src/copy-button/copy-button.tsx +6 -3
  22. package/src/diff-editor/diff-editor.test.tsx +9 -3
  23. package/src/diff-editor/diff-editor.tsx +47 -23
  24. package/src/editor-toolbar/editor-toolbar.tsx +8 -2
  25. package/src/index.ts +4 -3
  26. package/src/markdown-academic/citations.tsx +14 -7
  27. package/src/markdown-academic/footnotes.tsx +1 -1
  28. package/src/markdown-academic/math.tsx +7 -4
  29. package/src/markdown-editor/completions/completions-menu.tsx +5 -2
  30. package/src/markdown-editor/directive-views.tsx +33 -19
  31. package/src/markdown-editor/markdown-editor.stories.tsx +9 -3
  32. package/src/markdown-editor/slash/slash-menu.tsx +5 -2
  33. package/src/markdown-editor/table-view.tsx +28 -15
  34. package/src/markdown-iteration/iteration-builder-dialog.tsx +39 -18
  35. package/src/markdown-iteration/template-dialog.tsx +25 -7
  36. package/src/markdown-outline/document-outline.tsx +6 -2
  37. package/src/markdown-preview/markdown-preview-academic.test.tsx +3 -3
  38. package/src/markdown-toolbar/markdown-toolbar.tsx +42 -24
  39. package/src/markdown-workspace/markdown-workspace.test.tsx +22 -3
  40. package/src/markdown-workspace/markdown-workspace.tsx +6 -3
  41. package/src/mermaid-diagram/mermaid-diagram-fixes.test.tsx +130 -0
  42. package/src/mermaid-diagram/mermaid-diagram.test.tsx +2 -1
  43. package/src/mermaid-diagram/mermaid-diagram.tsx +89 -40
  44. package/src/mermaid-diagram/mermaid-viewer.tsx +21 -20
  45. package/src/monaco.ts +21 -0
  46. package/dist/chunk-C62O7IOQ.js.map +0 -1
@@ -28,6 +28,7 @@ import {
28
28
  TagInput,
29
29
  ToggleGroup,
30
30
  ToggleGroupItem,
31
+ useLocale,
31
32
  } from "@elabs-ai/components-ui";
32
33
  import { useEffect, useId, useMemo, useState, type ReactNode } from "react";
33
34
 
@@ -79,6 +80,7 @@ export function IterationBuilderDialog({
79
80
  evaluate,
80
81
  interpolate,
81
82
  }: IterationBuilderDialogProps) {
83
+ const { t } = useLocale();
82
84
  const kind = value?.kind ?? kindProp;
83
85
  const isPivot = kind === "pivot";
84
86
  const ids = useId();
@@ -130,19 +132,26 @@ export function IterationBuilderDialog({
130
132
  onOpenChange(false);
131
133
  };
132
134
 
133
- const noun = isPivot ? "pivot" : "iteration";
135
+ const noun = isPivot
136
+ ? t("editor.iterationBuilder.pivotNoun")
137
+ : t("editor.iterationBuilder.iterationNoun");
134
138
 
135
139
  return (
136
140
  <Dialog open={open} onOpenChange={onOpenChange}>
137
141
  <DialogContent className="flex max-h-[88vh] w-[min(56rem,94vw)] max-w-none flex-col">
138
142
  <DialogHeader>
139
143
  <DialogTitle>
140
- {value ? "Edit" : "Insert"} {noun}
144
+ {t(
145
+ value ? "editor.iterationBuilder.editTitle" : "editor.iterationBuilder.insertTitle",
146
+ {
147
+ noun,
148
+ },
149
+ )}
141
150
  </DialogTitle>
142
151
  <DialogDescription>
143
152
  {isPivot
144
- ? "Pick the row and column values, then write the per-cell template. The matrix below fills in live."
145
- : "Add the list values, then write the per-row template. The result below fills in live."}
153
+ ? t("editor.iterationBuilder.pivotDescription")
154
+ : t("editor.iterationBuilder.iterationDescription")}
146
155
  </DialogDescription>
147
156
  </DialogHeader>
148
157
 
@@ -151,47 +160,53 @@ export function IterationBuilderDialog({
151
160
  <div className="flex min-w-0 flex-col gap-4">
152
161
  {!isPivot ? (
153
162
  <div className="flex flex-col gap-1.5">
154
- <Label htmlFor={`${ids}-as`}>Bind name</Label>
163
+ <Label htmlFor={`${ids}-as`}>{t("editor.iterationBuilder.bindName")}</Label>
155
164
  <Input
156
165
  id={`${ids}-as`}
157
166
  value={asName}
158
167
  spellCheck={false}
159
168
  autoComplete="off"
160
- placeholder="item"
169
+ placeholder={t("editor.iterationBuilder.bindNamePlaceholder")}
161
170
  onChange={(e) => setAsName(e.target.value)}
162
171
  />
163
172
  <p className="text-meta text-muted-foreground">
164
- Use <code>{`{{${asName.trim() || "item"}.name}}`}</code> in the template.
173
+ {t("editor.iterationBuilder.bindNameHintPrefix")}
174
+ <code>{`{{${asName.trim() || "item"}.name}}`}</code>
175
+ {t("editor.iterationBuilder.bindNameHintSuffix")}
165
176
  </p>
166
177
  </div>
167
178
  ) : null}
168
179
 
169
180
  <div className="flex flex-col gap-1.5">
170
- <Label htmlFor={`${ids}-values`}>{isPivot ? "Row values" : "Values"}</Label>
181
+ <Label htmlFor={`${ids}-values`}>
182
+ {isPivot
183
+ ? t("editor.iterationBuilder.rowValues")
184
+ : t("editor.iterationBuilder.values")}
185
+ </Label>
171
186
  <TagInput
172
187
  id={`${ids}-values`}
173
188
  value={values}
174
189
  onValueChange={setValues}
175
190
  delimiter={[",", "\n"]}
176
- placeholder="Type a value, press Enter…"
191
+ placeholder={t("editor.iterationBuilder.valuePlaceholder")}
177
192
  />
178
193
  </div>
179
194
 
180
195
  {isPivot ? (
181
196
  <div className="flex flex-col gap-1.5">
182
- <Label htmlFor={`${ids}-cols`}>Column values</Label>
197
+ <Label htmlFor={`${ids}-cols`}>{t("editor.iterationBuilder.columnValues")}</Label>
183
198
  <TagInput
184
199
  id={`${ids}-cols`}
185
200
  value={cols}
186
201
  onValueChange={setCols}
187
202
  delimiter={[",", "\n"]}
188
- placeholder="Type a value, press Enter…"
203
+ placeholder={t("editor.iterationBuilder.valuePlaceholder")}
189
204
  />
190
205
  </div>
191
206
  ) : null}
192
207
 
193
208
  <div className="flex flex-col gap-1.5">
194
- <Label id={`${ids}-layout`}>Layout</Label>
209
+ <Label id={`${ids}-layout`}>{t("editor.iterationBuilder.layout")}</Label>
195
210
  <ToggleGroup
196
211
  type="single"
197
212
  variant="segmented"
@@ -216,23 +231,27 @@ export function IterationBuilderDialog({
216
231
  {/* Right column — the per-cell TEMPLATE + the live populated preview. */}
217
232
  <div className="flex min-h-0 min-w-0 flex-col gap-4">
218
233
  <div className="flex min-h-0 flex-col gap-1.5">
219
- <Label>Per-{isPivot ? "cell" : "row"} template</Label>
234
+ <Label>
235
+ {isPivot
236
+ ? t("editor.iterationBuilder.perCellTemplate")
237
+ : t("editor.iterationBuilder.perRowTemplate")}
238
+ </Label>
220
239
  <div className="h-44 min-h-0 overflow-hidden rounded-md border border-border">
221
240
  <MarkdownWorkspace
222
241
  value={template}
223
242
  onChange={setTemplate}
224
243
  defaultMode="source"
225
244
  className="h-full"
226
- aria-label="Per-cell template"
245
+ aria-label={t("editor.iterationBuilder.perCellTemplate")}
227
246
  />
228
247
  </div>
229
248
  </div>
230
249
 
231
250
  <div className="flex min-h-0 flex-col gap-1.5">
232
- <Label>Live preview</Label>
251
+ <Label>{t("editor.iterationBuilder.livePreview")}</Label>
233
252
  <div
234
253
  role="region"
235
- aria-label="Live preview"
254
+ aria-label={t("editor.iterationBuilder.livePreview")}
236
255
  className="min-h-0 flex-1 overflow-auto rounded-md border border-border bg-card p-3"
237
256
  >
238
257
  <MarkdownPreview
@@ -249,9 +268,11 @@ export function IterationBuilderDialog({
249
268
 
250
269
  <DialogFooter>
251
270
  <Button variant="ghost" onClick={() => onOpenChange(false)}>
252
- Cancel
271
+ {t("editor.iterationBuilder.cancel")}
272
+ </Button>
273
+ <Button onClick={save}>
274
+ {value ? t("editor.iterationBuilder.save") : t("editor.iterationBuilder.insert")}
253
275
  </Button>
254
- <Button onClick={save}>{value ? "Save" : "Insert"}</Button>
255
276
  </DialogFooter>
256
277
  </DialogContent>
257
278
  </Dialog>
@@ -18,6 +18,7 @@ import {
18
18
  DialogFooter,
19
19
  DialogHeader,
20
20
  DialogTitle,
21
+ useLocale,
21
22
  } from "@elabs-ai/components-ui";
22
23
  import { useEffect, useState, type ReactNode } from "react";
23
24
 
@@ -47,6 +48,7 @@ export function IterationTemplateDialog({
47
48
  kind = "iterate",
48
49
  mode = "split",
49
50
  }: IterationTemplateDialogProps) {
51
+ const { t } = useLocale();
50
52
  const [draft, setDraft] = useState(template);
51
53
 
52
54
  // Re-seed the draft whenever the dialog (re)opens against a new template.
@@ -63,12 +65,28 @@ export function IterationTemplateDialog({
63
65
 
64
66
  return (
65
67
  <Dialog open={open} onOpenChange={onOpenChange}>
66
- <DialogContent className="flex max-h-[85vh] w-[min(48rem,92vw)] max-w-none flex-col">
68
+ <DialogContent
69
+ className="flex max-h-[85vh] w-[min(48rem,92vw)] max-w-none flex-col"
70
+ // Radix autofocuses the first tabbable element — the toolbar's Bold button —
71
+ // and its Tooltip opens on focus, so the dialog opened with a stray tooltip.
72
+ // Focus the dialog itself instead; Tab still reaches the toolbar first.
73
+ onOpenAutoFocus={(event) => {
74
+ event.preventDefault();
75
+ (event.currentTarget as HTMLElement | null)?.focus();
76
+ }}
77
+ >
67
78
  <DialogHeader>
68
- <DialogTitle>Edit {kind === "pivot" ? "pivot" : "iteration"} template</DialogTitle>
79
+ <DialogTitle>
80
+ {kind === "pivot"
81
+ ? t("editor.templateDialog.editPivotTitle")
82
+ : t("editor.templateDialog.editIterationTitle")}
83
+ </DialogTitle>
69
84
  <DialogDescription>
70
- The per-{unit} template. Use <code>{"{{token}}"}</code> placeholders (e.g.{" "}
71
- <code>{"{{item.name}}"}</code>) — each is filled per {unit} when the block renders.
85
+ {t("editor.templateDialog.descriptionPrefix", { unit })}
86
+ <code>{"{{token}}"}</code>
87
+ {t("editor.templateDialog.descriptionMiddle")}
88
+ <code>{"{{item.name}}"}</code>
89
+ {t("editor.templateDialog.descriptionSuffix", { unit })}
72
90
  </DialogDescription>
73
91
  </DialogHeader>
74
92
  <div className="min-h-0 flex-1">
@@ -77,14 +95,14 @@ export function IterationTemplateDialog({
77
95
  onChange={setDraft}
78
96
  defaultMode={mode}
79
97
  className="h-full"
80
- aria-label="Iteration template editor"
98
+ aria-label={t("editor.templateDialog.editorLabel")}
81
99
  />
82
100
  </div>
83
101
  <DialogFooter>
84
102
  <Button variant="ghost" onClick={() => onOpenChange(false)}>
85
- Cancel
103
+ {t("editor.templateDialog.cancel")}
86
104
  </Button>
87
- <Button onClick={save}>Save template</Button>
105
+ <Button onClick={save}>{t("editor.templateDialog.saveTemplate")}</Button>
88
106
  </DialogFooter>
89
107
  </DialogContent>
90
108
  </Dialog>
@@ -6,6 +6,7 @@
6
6
  * `useMarkdownOutline`, drive `activeId` from your scroll observer, and handle
7
7
  * `onSelect` (e.g. scroll the matching `data-sourcepos` block into view).
8
8
  */
9
+ import { useLocale } from "@elabs-ai/components-ui";
9
10
  import { cn } from "@elabs-ai/components-ui/lib/cn";
10
11
  import { forwardRef, useMemo, type HTMLAttributes, type ReactNode } from "react";
11
12
 
@@ -31,11 +32,12 @@ export interface DocumentOutlineProps extends Omit<HTMLAttributes<HTMLElement>,
31
32
 
32
33
  export const DocumentOutline = forwardRef<HTMLElement, DocumentOutlineProps>(
33
34
  function DocumentOutline({ items, activeId, onSelect, itemActions, className, ...props }, ref) {
35
+ const { t } = useLocale();
34
36
  const minLevel = items.reduce<number>((min, it) => Math.min(min, it.level), 6);
35
37
  return (
36
38
  <nav
37
39
  ref={ref}
38
- aria-label="Document outline"
40
+ aria-label={t("editor.documentOutline.label")}
39
41
  className={cn("text-body", className)}
40
42
  {...props}
41
43
  >
@@ -75,7 +77,9 @@ export const DocumentOutline = forwardRef<HTMLElement, DocumentOutlineProps>(
75
77
  })}
76
78
  </ul>
77
79
  {items.length === 0 ? (
78
- <p className="px-2 py-1 text-caption text-muted-foreground">No headings yet.</p>
80
+ <p className="px-2 py-1 text-caption text-muted-foreground">
81
+ {t("editor.documentOutline.empty")}
82
+ </p>
79
83
  ) : null}
80
84
  </nav>
81
85
  );
@@ -201,7 +201,7 @@ describe("MarkdownPreview — citations + bibliography", () => {
201
201
  // `packages/tokens/src/themes-contrast.test.ts`; this row is the CLASS-NAME
202
202
  // lock that keeps these two call sites pointed at it. It is not a contrast
203
203
  // proof on its own — the token test is.
204
- it("uses the on-surface text rung (text-primary-text), not the --primary fill (#317/#399 — color-contrast)", async () => {
204
+ it("uses the link ink (text-link → --primary-text), not the --primary fill (#317/#399 — color-contrast)", async () => {
205
205
  const { container } = render(
206
206
  <MarkdownPreview resolveCitation={resolveCitation}>
207
207
  {`See [@smith2020] and [@jones2019].\n\n::bibliography`}
@@ -212,13 +212,13 @@ describe("MarkdownPreview — citations + bibliography", () => {
212
212
  );
213
213
 
214
214
  const inlineCite = container.querySelector('a[href="#ref-smith2020"]') as HTMLAnchorElement;
215
- expect(inlineCite.className.split(/\s+/)).toContain("text-primary-text");
215
+ expect(inlineCite.className.split(/\s+/)).toContain("text-link");
216
216
  expect(inlineCite.className.split(/\s+/)).not.toContain("text-primary");
217
217
 
218
218
  const bibLink = container.querySelector(
219
219
  'li[id="ref-jones2019"] a[href^="https://doi.org/"]',
220
220
  ) as HTMLAnchorElement;
221
- expect(bibLink.className.split(/\s+/)).toContain("text-primary-text");
221
+ expect(bibLink.className.split(/\s+/)).toContain("text-link");
222
222
  expect(bibLink.className.split(/\s+/)).not.toContain("text-primary");
223
223
  });
224
224
  });
@@ -19,6 +19,7 @@ import {
19
19
  TooltipContent,
20
20
  TooltipProvider,
21
21
  TooltipTrigger,
22
+ useLocale,
22
23
  } from "@elabs-ai/components-ui";
23
24
  import { cn } from "@elabs-ai/components-ui/lib/cn";
24
25
  import {
@@ -65,18 +66,31 @@ export interface MarkdownToolbarProps extends HTMLAttributes<HTMLDivElement> {
65
66
  /** A command that actually carries a source-mode snippet. */
66
67
  type InsertableCommand = SlashCommand & { snippet: string };
67
68
 
68
- const DIRECTIVE_SNIPPETS: { label: string; snippet: string }[] = [
69
- { label: "Card", snippet: `:::card{title="Title"}\nContent\n:::` },
70
- { label: "Callout", snippet: `:::callout{type="info" title="Note"}\nMessage\n:::` },
71
- { label: "Metric", snippet: `::metric{label="Label" value="0" description="detail"}` },
69
+ // The `title=`/`label=` values below are example CONTENT dropped into the user's
70
+ // document (the same seeds `brand-slash-commands.ts` uses), not UI chrome — left
71
+ // as literal English placeholder text the author overwrites.
72
+ const DIRECTIVE_SNIPPETS: { labelKey: string; snippet: string }[] = [
72
73
  {
73
- label: "Timeline",
74
- snippet: `:::timeline\n- (done) Step one\n- (active) Step two\n- (pending) Step three\n:::`,
74
+ labelKey: "editor.markdownToolbar.directiveCard",
75
+ snippet: `:::card{title="Title"}\nContent\n:::`, // i18n-exempt: example document content
76
+ },
77
+ {
78
+ labelKey: "editor.markdownToolbar.directiveCallout",
79
+ snippet: `:::callout{type="info" title="Note"}\nMessage\n:::`, // i18n-exempt: example document content
80
+ },
81
+ {
82
+ labelKey: "editor.markdownToolbar.directiveMetric",
83
+ snippet: `::metric{label="Label" value="0" description="detail"}`, // i18n-exempt: example document content
84
+ },
85
+ {
86
+ labelKey: "editor.markdownToolbar.directiveTimeline",
87
+ snippet: `:::timeline\n- (done) Step one\n- (active) Step two\n- (pending) Step three\n:::`, // i18n-exempt: example document content
75
88
  },
76
89
  ];
77
90
 
78
91
  export const MarkdownToolbar = forwardRef<HTMLDivElement, MarkdownToolbarProps>(
79
92
  function MarkdownToolbar({ editor, actions, insertCommands, className, ...props }, ref) {
93
+ const { t } = useLocale();
80
94
  const disabled = !editor;
81
95
  const run = (fn: (e: MonacoCodeEditor) => void) => () => {
82
96
  if (editor) fn(editor);
@@ -121,7 +135,7 @@ export const MarkdownToolbar = forwardRef<HTMLDivElement, MarkdownToolbarProps>(
121
135
  <div
122
136
  ref={ref}
123
137
  role="toolbar"
124
- aria-label="Markdown formatting"
138
+ aria-label={t("editor.markdownToolbar.label")}
125
139
  className={cn(
126
140
  "flex h-10 shrink-0 items-center gap-0.5 border-b border-border bg-surface px-2",
127
141
  className,
@@ -129,21 +143,25 @@ export const MarkdownToolbar = forwardRef<HTMLDivElement, MarkdownToolbarProps>(
129
143
  {...props}
130
144
  >
131
145
  <IconButton
132
- label="Bold"
146
+ label={t("editor.markdownToolbar.bold")}
133
147
  icon={<Bold className="size-4" />}
134
148
  onClick={run((e) => wrapSelection(e, "**"))}
135
149
  />
136
150
  <IconButton
137
- label="Italic"
151
+ label={t("editor.markdownToolbar.italic")}
138
152
  icon={<Italic className="size-4" />}
139
153
  onClick={run((e) => wrapSelection(e, "*"))}
140
154
  />
141
155
  <IconButton
142
- label="Inline code"
156
+ label={t("editor.markdownToolbar.inlineCode")}
143
157
  icon={<Code2 className="size-4" />}
144
158
  onClick={run((e) => wrapSelection(e, "`"))}
145
159
  />
146
- <IconButton label="Link" icon={<Link2 className="size-4" />} onClick={run(insertLink)} />
160
+ <IconButton
161
+ label={t("editor.markdownToolbar.link")}
162
+ icon={<Link2 className="size-4" />}
163
+ onClick={run(insertLink)}
164
+ />
147
165
 
148
166
  <Separator orientation="vertical" className="mx-1 h-5" />
149
167
 
@@ -157,14 +175,14 @@ export const MarkdownToolbar = forwardRef<HTMLDivElement, MarkdownToolbarProps>(
157
175
  size="sm"
158
176
  disabled={disabled}
159
177
  className="gap-1"
160
- aria-label="Heading level"
178
+ aria-label={t("editor.markdownToolbar.headingLevel")}
161
179
  >
162
180
  <Heading className="size-4" />
163
181
  <ChevronDown className="size-3" />
164
182
  </Button>
165
183
  </DropdownMenuTrigger>
166
184
  </TooltipTrigger>
167
- <TooltipContent>Heading</TooltipContent>
185
+ <TooltipContent>{t("editor.markdownToolbar.heading")}</TooltipContent>
168
186
  </Tooltip>
169
187
  <DropdownMenuContent align="start">
170
188
  {([1, 2, 3] as const).map((level) => (
@@ -172,29 +190,29 @@ export const MarkdownToolbar = forwardRef<HTMLDivElement, MarkdownToolbarProps>(
172
190
  key={level}
173
191
  onSelect={run((e) => toggleLinePrefix(e, `${"#".repeat(level)} `))}
174
192
  >
175
- Heading {level}
193
+ {t("editor.markdownToolbar.headingLevelItem", { level })}
176
194
  </DropdownMenuItem>
177
195
  ))}
178
196
  </DropdownMenuContent>
179
197
  </DropdownMenu>
180
198
 
181
199
  <IconButton
182
- label="Quote"
200
+ label={t("editor.markdownToolbar.quote")}
183
201
  icon={<Quote className="size-4" />}
184
202
  onClick={run((e) => toggleLinePrefix(e, "> "))}
185
203
  />
186
204
  <IconButton
187
- label="Bullet list"
205
+ label={t("editor.markdownToolbar.bulletList")}
188
206
  icon={<List className="size-4" />}
189
207
  onClick={run((e) => toggleLinePrefix(e, "- "))}
190
208
  />
191
209
  <IconButton
192
- label="Numbered list"
210
+ label={t("editor.markdownToolbar.numberedList")}
193
211
  icon={<ListOrdered className="size-4" />}
194
212
  onClick={run((e) => toggleLinePrefix(e, "1. "))}
195
213
  />
196
214
  <IconButton
197
- label="Divider"
215
+ label={t("editor.markdownToolbar.divider")}
198
216
  icon={<Minus className="size-4" />}
199
217
  onClick={run(insertHorizontalRule)}
200
218
  />
@@ -211,14 +229,14 @@ export const MarkdownToolbar = forwardRef<HTMLDivElement, MarkdownToolbarProps>(
211
229
  size="sm"
212
230
  disabled={disabled}
213
231
  className="gap-1"
214
- aria-label="Insert block"
232
+ aria-label={t("editor.markdownToolbar.insertBlock")}
215
233
  >
216
234
  <SquarePlus className="size-4" />
217
- <span className="text-xs">Insert</span>
235
+ <span className="text-xs">{t("editor.markdownToolbar.insert")}</span>
218
236
  </Button>
219
237
  </DropdownMenuTrigger>
220
238
  </TooltipTrigger>
221
- <TooltipContent>Insert brand block</TooltipContent>
239
+ <TooltipContent>{t("editor.markdownToolbar.insertBrandBlock")}</TooltipContent>
222
240
  </Tooltip>
223
241
  <DropdownMenuContent align="start">
224
242
  {insertGroups && insertGroups.length > 0
@@ -244,12 +262,12 @@ export const MarkdownToolbar = forwardRef<HTMLDivElement, MarkdownToolbarProps>(
244
262
  ))}
245
263
  </Fragment>
246
264
  ))
247
- : DIRECTIVE_SNIPPETS.map(({ label, snippet }) => (
265
+ : DIRECTIVE_SNIPPETS.map(({ labelKey, snippet }) => (
248
266
  <DropdownMenuItem
249
- key={label}
267
+ key={labelKey}
250
268
  onSelect={run((e) => insertDirective(e, snippet))}
251
269
  >
252
- {label}
270
+ {t(labelKey)}
253
271
  </DropdownMenuItem>
254
272
  ))}
255
273
  </DropdownMenuContent>
@@ -29,8 +29,19 @@ vi.mock("monaco-editor", () => ({
29
29
  onDidBlurEditorText: vi.fn(() => ({ dispose: vi.fn() })),
30
30
  getValue: vi.fn(() => ""),
31
31
  setValue: vi.fn(),
32
+ // The controlled-value sync effect diffs against the MODEL's value (not
33
+ // the editor's) and applies the result via `executeEdits`. Every model
34
+ // this mock hands out shares the same value the editor's `getValue`
35
+ // reports (`""`), so a no-op sync never fires.
36
+ executeEdits: vi.fn(),
32
37
  getSelection: vi.fn(() => null),
33
- getModel: vi.fn(() => ({ dispose: vi.fn(), getLineCount })),
38
+ getModel: vi.fn(() => ({
39
+ dispose: vi.fn(),
40
+ getLineCount,
41
+ getValue: vi.fn(() => ""),
42
+ getPositionAt: vi.fn((offset: number) => ({ lineNumber: 1, column: offset })),
43
+ })),
44
+ setModel: vi.fn(),
34
45
  updateOptions: vi.fn(),
35
46
  dispose: vi.fn(),
36
47
  getDomNode: vi.fn(() => null),
@@ -44,7 +55,11 @@ vi.mock("monaco-editor", () => ({
44
55
  addAction,
45
56
  focus: vi.fn(),
46
57
  })),
47
- createModel: vi.fn(() => ({ dispose: vi.fn() })),
58
+ createModel: vi.fn(() => ({
59
+ dispose: vi.fn(),
60
+ getValue: vi.fn(() => ""),
61
+ getPositionAt: vi.fn((offset: number) => ({ lineNumber: 1, column: offset })),
62
+ })),
48
63
  setModelLanguage: vi.fn(),
49
64
  defineTheme: vi.fn(),
50
65
  setTheme: vi.fn(),
@@ -81,7 +96,11 @@ vi.mock("monaco-editor", () => ({
81
96
  KeyZ: 56,
82
97
  },
83
98
  Uri: { parse: (s: string) => ({ toString: () => s }) },
84
- Range: class {},
99
+ Range: class {
100
+ static fromPositions(start: unknown, end: unknown) {
101
+ return { start, end };
102
+ }
103
+ },
85
104
  Selection: class {},
86
105
  }));
87
106
 
@@ -22,6 +22,7 @@ import {
22
22
  TooltipContent,
23
23
  TooltipProvider,
24
24
  TooltipTrigger,
25
+ useLocale,
25
26
  } from "@elabs-ai/components-ui";
26
27
  import { cn } from "@elabs-ai/components-ui/lib/cn";
27
28
  import { Columns2, Eye, Focus, SquareCode } from "lucide-react";
@@ -219,6 +220,7 @@ export const MarkdownWorkspace = forwardRef<MarkdownWorkspaceHandle, MarkdownWor
219
220
  },
220
221
  ref,
221
222
  ) {
223
+ const { t } = useLocale();
222
224
  // The source/split Insert menu defaults to the SAME commands as the WYSIWYG
223
225
  // slash menu, so both surfaces insert the same blocks (A4).
224
226
  const slashCommandList =
@@ -704,13 +706,14 @@ export const MarkdownWorkspace = forwardRef<MarkdownWorkspaceHandle, MarkdownWor
704
706
  size="sm"
705
707
  pressed={focusWritingOn}
706
708
  onPressedChange={setFocusWritingOn}
707
- aria-label="Focus writing"
709
+ aria-label={t("editor.markdownWorkspace.focusWriting")}
708
710
  className="h-6 gap-1.5 px-2 text-caption"
709
711
  >
710
- <Focus className="size-3.5" aria-hidden="true" /> Focus
712
+ <Focus className="size-3.5" aria-hidden="true" />{" "}
713
+ {t("editor.markdownWorkspace.focus")}
711
714
  </Toggle>
712
715
  </TooltipTrigger>
713
- <TooltipContent>Typewriter scrolling · inactive paragraphs dim</TooltipContent>
716
+ <TooltipContent>{t("editor.markdownWorkspace.focusWritingHint")}</TooltipContent>
714
717
  </Tooltip>
715
718
  </TooltipProvider>
716
719
  ) : null}
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Locks the three review fixes to MermaidDiagram:
3
+ *
4
+ * 1. Concurrent renders are serialized through `withMermaidLock` so two
5
+ * instances (or two overlapping theme changes) never interleave
6
+ * `mermaid.initialize()` (one shared module-level config) with a
7
+ * different instance's `mermaid.render()`.
8
+ * 2. The render effect debounces on `chart`, so a rapidly-changing
9
+ * (streaming) source only triggers one real `mermaid.render()` call for
10
+ * the final value, not one per intermediate change.
11
+ * 3. `downloadSvg()` defers `URL.revokeObjectURL` past the click (Safari).
12
+ *
13
+ * Isolated from `mermaid-diagram.test.tsx` because these tests need fake
14
+ * timers and finer control over when each `mermaid.render()` call resolves,
15
+ * which would otherwise fight that file's real-timer `waitFor` calls.
16
+ */
17
+ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
18
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
19
+
20
+ const callOrder: string[] = [];
21
+ let resolveFirstRender: (() => void) | undefined;
22
+
23
+ const initializeMock = vi.fn(() => {
24
+ callOrder.push("initialize");
25
+ });
26
+
27
+ let renderStarts = 0;
28
+ let holdFirstRender = false;
29
+ const renderMock = vi.fn(async (id: string, chart: string) => {
30
+ renderStarts += 1;
31
+ const isFirst = holdFirstRender && renderStarts === 1;
32
+ callOrder.push("render-start");
33
+ if (isFirst) {
34
+ await new Promise<void>((resolve) => {
35
+ resolveFirstRender = resolve;
36
+ });
37
+ }
38
+ callOrder.push("render-end");
39
+ return { svg: `<svg data-id="${id}" data-chart="${chart}"></svg>` };
40
+ });
41
+
42
+ vi.mock("mermaid", () => ({
43
+ default: { initialize: initializeMock, render: renderMock },
44
+ }));
45
+
46
+ import { MermaidDiagram } from "./mermaid-diagram";
47
+
48
+ beforeEach(() => {
49
+ callOrder.length = 0;
50
+ renderStarts = 0;
51
+ holdFirstRender = false;
52
+ resolveFirstRender = undefined;
53
+ renderMock.mockClear();
54
+ initializeMock.mockClear();
55
+ });
56
+
57
+ afterEach(() => {
58
+ cleanup();
59
+ vi.useRealTimers();
60
+ });
61
+
62
+ describe("MermaidDiagram — serializes concurrent renders across instances (review finding)", () => {
63
+ it("never starts a second instance's initialize before the first instance's render settles", async () => {
64
+ holdFirstRender = true;
65
+ vi.useFakeTimers();
66
+ render(<MermaidDiagram chart={"graph TD; A-->B"} />);
67
+ render(<MermaidDiagram chart={"graph TD; C-->D"} />);
68
+
69
+ // Fire both debounce timers; only the FIRST queued render should have
70
+ // started — the second instance's `initialize` must wait for it.
71
+ await act(() => vi.advanceTimersByTimeAsync(300));
72
+ expect(callOrder).toEqual(["initialize", "render-start"]);
73
+
74
+ // Let the first render settle; the queue should now release the second.
75
+ resolveFirstRender?.();
76
+ await act(() => vi.advanceTimersByTimeAsync(0));
77
+ await act(() => vi.advanceTimersByTimeAsync(0));
78
+
79
+ expect(callOrder).toEqual([
80
+ "initialize",
81
+ "render-start",
82
+ "render-end",
83
+ "initialize",
84
+ "render-start",
85
+ "render-end",
86
+ ]);
87
+ });
88
+ });
89
+
90
+ describe("MermaidDiagram — debounces re-rendering while the source is changing (review finding)", () => {
91
+ it("only renders once, for the final chart value, across rapid successive changes", async () => {
92
+ vi.useFakeTimers();
93
+ const { rerender } = render(<MermaidDiagram chart={"graph TD; A-->B"} />);
94
+ rerender(<MermaidDiagram chart={"graph TD; A-->C"} />);
95
+ rerender(<MermaidDiagram chart={"graph TD; A-->D"} />);
96
+
97
+ // Just under the debounce window: nothing has rendered yet.
98
+ await act(() => vi.advanceTimersByTimeAsync(299));
99
+ expect(renderMock).not.toHaveBeenCalled();
100
+
101
+ // Past the window: exactly one render, for the last value only.
102
+ await act(() => vi.advanceTimersByTimeAsync(50));
103
+ expect(renderMock).toHaveBeenCalledTimes(1);
104
+ expect(renderMock).toHaveBeenCalledWith(expect.any(String), "graph TD; A-->D");
105
+ });
106
+ });
107
+
108
+ describe("MermaidDiagram — defers revoking the download object URL (review finding)", () => {
109
+ it("does not call URL.revokeObjectURL synchronously after the download click", async () => {
110
+ render(<MermaidDiagram chart={"graph TD; A-->B"} label="Flow" />);
111
+ await waitFor(() =>
112
+ expect(screen.getByRole("button", { name: "Download diagram as SVG" })).toBeInTheDocument(),
113
+ );
114
+
115
+ vi.useFakeTimers();
116
+ global.URL.createObjectURL = vi.fn(() => "blob:mock");
117
+ const revokeSpy = vi.fn();
118
+ global.URL.revokeObjectURL = revokeSpy;
119
+ const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
120
+
121
+ fireEvent.click(screen.getByRole("button", { name: "Download diagram as SVG" }));
122
+
123
+ // Not revoked synchronously — the click handler must have returned first.
124
+ expect(revokeSpy).not.toHaveBeenCalled();
125
+ act(() => vi.runAllTimers());
126
+ expect(revokeSpy).toHaveBeenCalledWith("blob:mock");
127
+
128
+ clickSpy.mockRestore();
129
+ });
130
+ });