@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
@@ -7,9 +7,10 @@ const renderMock = vi.fn(async (_id: string, chart: string) => {
7
7
  svg: `<svg data-chart="ok"><g class="node"><rect></rect><text>Microsoft Graph API</text></g><g class="node"><rect></rect><text>Worker</text></g></svg>`,
8
8
  };
9
9
  });
10
+ const initializeMock = vi.fn();
10
11
 
11
12
  vi.mock("mermaid", () => ({
12
- default: { initialize: vi.fn(), render: renderMock },
13
+ default: { initialize: initializeMock, render: renderMock },
13
14
  }));
14
15
 
15
16
  import { MermaidDiagram } from "./mermaid-diagram";
@@ -13,7 +13,7 @@
13
13
  * never show a diagram never download the engine. Invalid sources render an
14
14
  * inline error block (message + source), never a thrown render.
15
15
  */
16
- import { Button, Dialog, DialogContent, DialogTitle } from "@elabs-ai/components-ui";
16
+ import { Button, Dialog, DialogContent, DialogTitle, useLocale } from "@elabs-ai/components-ui";
17
17
  import { cn } from "@elabs-ai/components-ui/lib/cn";
18
18
  import { Download, Maximize2 } from "lucide-react";
19
19
  import { forwardRef, useEffect, useRef, useState, type HTMLAttributes } from "react";
@@ -67,6 +67,39 @@ const TOKEN_VARS: Record<string, string> = {
67
67
 
68
68
  let renderSeq = 0;
69
69
 
70
+ /**
71
+ * Serializes every `mermaid.initialize()` + `mermaid.render()` pair across
72
+ * every `MermaidDiagram` instance on the page.
73
+ *
74
+ * The `mermaid` package configures itself through ONE module-level global
75
+ * (`mermaid.initialize`/`setConfig`) — `render()` takes no per-call config —
76
+ * so two diagrams rendering concurrently (e.g. under different `data-theme`
77
+ * scopes, or just two diagrams mounting together) can interleave: instance A
78
+ * calls `initialize({theme: A})`, then before A's `render()` finishes reading
79
+ * it, instance B calls `initialize({theme: B})` and A's diagram comes out
80
+ * themed as B. Routing every render through this queue makes "initialize,
81
+ * then render" atomic with respect to every other instance.
82
+ */
83
+ let mermaidRenderQueue: Promise<unknown> = Promise.resolve();
84
+
85
+ function withMermaidLock<T>(task: () => Promise<T>): Promise<T> {
86
+ const result = mermaidRenderQueue.then(task, task);
87
+ // Never let a failed render break the chain for the next caller.
88
+ mermaidRenderQueue = result.then(
89
+ () => undefined,
90
+ () => undefined,
91
+ );
92
+ return result;
93
+ }
94
+
95
+ /**
96
+ * How long to let `chart` sit unchanged before actually rendering it — a
97
+ * source streamed in token-by-token (an LLM authoring one live) would
98
+ * otherwise trigger a full mermaid parse + layout on every partial,
99
+ * malformed intermediate string.
100
+ */
101
+ const RENDER_DEBOUNCE_MS = 300;
102
+
70
103
  /**
71
104
  * Search-hit strokes for rendered nodes — token-driven, shared by the inline
72
105
  * render and the expanded viewer (a `<style>` is document-global wherever it
@@ -127,7 +160,7 @@ export const MermaidDiagram = forwardRef<HTMLDivElement, MermaidDiagramProps>(
127
160
  function MermaidDiagram(
128
161
  {
129
162
  chart,
130
- label = "Diagram",
163
+ label: labelProp,
131
164
  copyable = true,
132
165
  expandable = true,
133
166
  highlightTerm,
@@ -137,6 +170,8 @@ export const MermaidDiagram = forwardRef<HTMLDivElement, MermaidDiagramProps>(
137
170
  },
138
171
  ref,
139
172
  ) {
173
+ const { t } = useLocale();
174
+ const label = labelProp ?? t("editor.mermaidDiagram.label");
140
175
  const hostRef = useRef<HTMLDivElement | null>(null);
141
176
  const svgHostRef = useRef<HTMLDivElement | null>(null);
142
177
  const [svg, setSvg] = useState<string | null>(null);
@@ -151,7 +186,10 @@ export const MermaidDiagram = forwardRef<HTMLDivElement, MermaidDiagramProps>(
151
186
  a.href = url;
152
187
  a.download = `${label.toLowerCase().replace(/[^a-z0-9]+/g, "-") || "diagram"}.svg`;
153
188
  a.click();
154
- URL.revokeObjectURL(url);
189
+ // Defer the revoke past the current task: some browsers (Safari) start
190
+ // the save asynchronously off the click and cancel it if the object URL
191
+ // is invalidated too early.
192
+ setTimeout(() => URL.revokeObjectURL(url), 0);
155
193
  };
156
194
  // Bumped by the observer when the governing data-theme changes.
157
195
  const [themeVersion, setThemeVersion] = useState(0);
@@ -173,43 +211,52 @@ export const MermaidDiagram = forwardRef<HTMLDivElement, MermaidDiagramProps>(
173
211
  setError(null);
174
212
  return;
175
213
  }
176
- (async () => {
177
- try {
178
- const mermaid = (await import("mermaid")).default;
214
+ // Debounce: a `chart` fed from a streaming source changes on every
215
+ // token, and a mermaid parse + layout is not cheap enough to run on
216
+ // every one of those partial, often-invalid intermediate strings — wait
217
+ // for the source to sit still for RENDER_DEBOUNCE_MS first.
218
+ const timer = setTimeout(() => {
219
+ void withMermaidLock(async () => {
179
220
  if (cancelled) return;
180
- mermaid.initialize({
181
- startOnLoad: false,
182
- securityLevel: "strict",
183
- suppressErrorRendering: true,
184
- theme: "base",
185
- themeVariables: resolveThemeVariables(host),
186
- });
187
- const render = (source: string) => mermaid.render(`brand-mermaid-${++renderSeq}`, source);
188
- let out;
189
221
  try {
190
- out = await render(chart);
191
- } catch (firstErr) {
192
- // Reserved-keyword node ids ("graph[...]", "end[...]") are the
193
- // most common authoring mistake — remediate the id (labels stay)
194
- // and retry once instead of failing the reader.
195
- const token = offendingToken(
196
- firstErr instanceof Error ? firstErr.message : String(firstErr),
197
- );
198
- const fixed = token ? remediateReservedIds(chart, token) : null;
199
- if (!fixed) throw firstErr;
200
- out = await render(fixed);
222
+ const mermaid = (await import("mermaid")).default;
223
+ if (cancelled) return;
224
+ mermaid.initialize({
225
+ startOnLoad: false,
226
+ securityLevel: "strict",
227
+ suppressErrorRendering: true,
228
+ theme: "base",
229
+ themeVariables: resolveThemeVariables(host),
230
+ });
231
+ const render = (source: string) =>
232
+ mermaid.render(`brand-mermaid-${++renderSeq}`, source);
233
+ let out;
234
+ try {
235
+ out = await render(chart);
236
+ } catch (firstErr) {
237
+ // Reserved-keyword node ids ("graph[...]", "end[...]") are the
238
+ // most common authoring mistake — remediate the id (labels stay)
239
+ // and retry once instead of failing the reader.
240
+ const token = offendingToken(
241
+ firstErr instanceof Error ? firstErr.message : String(firstErr),
242
+ );
243
+ const fixed = token ? remediateReservedIds(chart, token) : null;
244
+ if (!fixed) throw firstErr;
245
+ out = await render(fixed);
246
+ }
247
+ if (cancelled) return;
248
+ setSvg(out.svg);
249
+ setError(null);
250
+ } catch (err) {
251
+ if (cancelled) return;
252
+ setSvg(null);
253
+ setError(err instanceof Error ? err.message : String(err));
201
254
  }
202
- if (cancelled) return;
203
- setSvg(out.svg);
204
- setError(null);
205
- } catch (err) {
206
- if (cancelled) return;
207
- setSvg(null);
208
- setError(err instanceof Error ? err.message : String(err));
209
- }
210
- })();
255
+ });
256
+ }, RENDER_DEBOUNCE_MS);
211
257
  return () => {
212
258
  cancelled = true;
259
+ clearTimeout(timer);
213
260
  };
214
261
  }, [chart, themeVersion]);
215
262
 
@@ -248,7 +295,7 @@ export const MermaidDiagram = forwardRef<HTMLDivElement, MermaidDiagramProps>(
248
295
  <Button
249
296
  variant="outline"
250
297
  size="icon-sm"
251
- aria-label="Expand diagram"
298
+ aria-label={t("editor.mermaidDiagram.expand")}
252
299
  onClick={() => setExpanded(true)}
253
300
  >
254
301
  <Maximize2 className="size-3.5" />
@@ -257,7 +304,7 @@ export const MermaidDiagram = forwardRef<HTMLDivElement, MermaidDiagramProps>(
257
304
  <Button
258
305
  variant="outline"
259
306
  size="icon-sm"
260
- aria-label="Download diagram as SVG"
307
+ aria-label={t("editor.mermaidDiagram.downloadSvg")}
261
308
  onClick={downloadSvg}
262
309
  >
263
310
  <Download className="size-3.5" />
@@ -266,7 +313,7 @@ export const MermaidDiagram = forwardRef<HTMLDivElement, MermaidDiagramProps>(
266
313
  <CopyButton
267
314
  value={chart}
268
315
  label={false}
269
- aria-label="Copy diagram source"
316
+ aria-label={t("editor.mermaidDiagram.copySource")}
270
317
  size="icon-sm"
271
318
  />
272
319
  ) : null}
@@ -277,7 +324,9 @@ export const MermaidDiagram = forwardRef<HTMLDivElement, MermaidDiagramProps>(
277
324
  role="alert"
278
325
  className="space-y-2 border-s-2 border-s-destructive bg-destructive/10 p-3 text-body"
279
326
  >
280
- <p className="font-medium text-destructive-text">Diagram failed to render</p>
327
+ <p className="font-medium text-destructive-text">
328
+ {t("editor.mermaidDiagram.renderFailed")}
329
+ </p>
281
330
  <p className="text-muted-foreground">{error}</p>
282
331
  <pre className="overflow-x-auto rounded bg-muted p-2 font-mono text-code text-foreground">
283
332
  {chart}
@@ -298,7 +347,7 @@ export const MermaidDiagram = forwardRef<HTMLDivElement, MermaidDiagramProps>(
298
347
  ) : (
299
348
  <div
300
349
  role="status"
301
- aria-label="Rendering diagram…"
350
+ aria-label={t("editor.mermaidDiagram.rendering")}
302
351
  className="h-24 animate-pulse rounded-md bg-surface-muted motion-reduce:animate-none"
303
352
  />
304
353
  )}
@@ -5,7 +5,7 @@
5
5
  * drag-pan, fit/100% controls, and a left search panel that filters the
6
6
  * RENDERED nodes, highlights every hit and zooms to the selected one.
7
7
  */
8
- import { Button, Input } from "@elabs-ai/components-ui";
8
+ import { Button, Input, useLocale } from "@elabs-ai/components-ui";
9
9
  import { cn } from "@elabs-ai/components-ui/lib/cn";
10
10
  import { Maximize, Minus, Plus } from "lucide-react";
11
11
  import {
@@ -79,6 +79,7 @@ export function diagramNodeLabel(node: Element): string {
79
79
  }
80
80
 
81
81
  export function MermaidViewer({ svg, label }: { svg: string; label: string }) {
82
+ const { t } = useLocale();
82
83
  const containerRef = useRef<HTMLDivElement | null>(null);
83
84
  const stageRef = useRef<HTMLDivElement | null>(null);
84
85
  const [transform, setTransform] = useState<Transform>({ scale: 1, tx: 0, ty: 0 });
@@ -188,10 +189,10 @@ export function MermaidViewer({ svg, label }: { svg: string; label: string }) {
188
189
  if (!container) return;
189
190
  userDrivenRef.current = true;
190
191
  const rect = container.getBoundingClientRect();
191
- setTransform((t) => {
192
- const scale = clampScale(t.scale * factor);
193
- const px = (clientX - rect.left - t.tx) / t.scale;
194
- const py = (clientY - rect.top - t.ty) / t.scale;
192
+ setTransform((prev) => {
193
+ const scale = clampScale(prev.scale * factor);
194
+ const px = (clientX - rect.left - prev.tx) / prev.scale;
195
+ const py = (clientY - rect.top - prev.ty) / prev.scale;
195
196
  return { scale, tx: clientX - rect.left - px * scale, ty: clientY - rect.top - py * scale };
196
197
  });
197
198
  }, []);
@@ -210,13 +211,13 @@ export function MermaidViewer({ svg, label }: { svg: string; label: string }) {
210
211
  if (!container || !svgEl || !node) return;
211
212
  userDrivenRef.current = true;
212
213
  setActiveHit(id);
213
- setTransform((t) => {
214
+ setTransform((prev) => {
214
215
  const nodeRect = node.getBoundingClientRect();
215
216
  const containerRect = container.getBoundingClientRect();
216
217
  // Node center in svg-space (invert the current transform).
217
- const cx = (nodeRect.left + nodeRect.width / 2 - containerRect.left - t.tx) / t.scale;
218
- const cy = (nodeRect.top + nodeRect.height / 2 - containerRect.top - t.ty) / t.scale;
219
- const scale = clampScale(Math.max(t.scale, 1.25));
218
+ const cx = (nodeRect.left + nodeRect.width / 2 - containerRect.left - prev.tx) / prev.scale;
219
+ const cy = (nodeRect.top + nodeRect.height / 2 - containerRect.top - prev.ty) / prev.scale;
220
+ const scale = clampScale(Math.max(prev.scale, 1.25));
220
221
  return {
221
222
  scale,
222
223
  tx: containerRect.width / 2 - cx * scale,
@@ -239,8 +240,8 @@ export function MermaidViewer({ svg, label }: { svg: string; label: string }) {
239
240
  const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
240
241
  const drag = dragRef.current;
241
242
  if (!drag) return;
242
- setTransform((t) => ({
243
- ...t,
243
+ setTransform((prev) => ({
244
+ ...prev,
244
245
  tx: drag.tx + (e.clientX - drag.x),
245
246
  ty: drag.ty + (e.clientY - drag.y),
246
247
  }));
@@ -256,8 +257,8 @@ export function MermaidViewer({ svg, label }: { svg: string; label: string }) {
256
257
  <Input
257
258
  autoFocus
258
259
  type="search"
259
- placeholder="Find in diagram…"
260
- aria-label="Find in diagram"
260
+ placeholder={t("editor.mermaidViewer.findPlaceholder")}
261
+ aria-label={t("editor.mermaidViewer.findLabel")}
261
262
  value={query}
262
263
  onChange={(e) => setQuery(e.target.value)}
263
264
  spellCheck={false}
@@ -265,8 +266,8 @@ export function MermaidViewer({ svg, label }: { svg: string; label: string }) {
265
266
  />
266
267
  <p aria-live="polite" className="px-1 pt-1.5 text-meta text-muted-foreground tabular-nums">
267
268
  {q.length >= 2
268
- ? `${matches.length} ${matches.length === 1 ? "node" : "nodes"}`
269
- : `${hits.length} nodes · type to filter`}
269
+ ? t("editor.mermaidViewer.matchCount", { count: matches.length })
270
+ : t("editor.mermaidViewer.nodeCountHint", { count: hits.length })}
270
271
  </p>
271
272
  <ul className="m-0 mt-1 min-h-0 flex-1 list-none overflow-auto p-0">
272
273
  {(q.length >= 2 ? matches : hits).map((hit) => (
@@ -297,7 +298,7 @@ export function MermaidViewer({ svg, label }: { svg: string; label: string }) {
297
298
  <Button
298
299
  variant="outline"
299
300
  size="icon-sm"
300
- aria-label="Zoom out"
301
+ aria-label={t("editor.mermaidViewer.zoomOut")}
301
302
  onClick={() => zoomCenter(1 / 1.25)}
302
303
  >
303
304
  <Minus className="size-3.5" />
@@ -305,7 +306,7 @@ export function MermaidViewer({ svg, label }: { svg: string; label: string }) {
305
306
  <Button
306
307
  variant="outline"
307
308
  size="icon-sm"
308
- aria-label="Zoom in"
309
+ aria-label={t("editor.mermaidViewer.zoomIn")}
309
310
  onClick={() => zoomCenter(1.25)}
310
311
  >
311
312
  <Plus className="size-3.5" />
@@ -314,10 +315,10 @@ export function MermaidViewer({ svg, label }: { svg: string; label: string }) {
314
315
  variant="outline"
315
316
  size="sm"
316
317
  className="h-7 px-2 font-mono text-meta tabular-nums"
317
- aria-label="Reset zoom to 100%"
318
+ aria-label={t("editor.mermaidViewer.resetZoom")}
318
319
  onClick={() => {
319
320
  userDrivenRef.current = true;
320
- setTransform((t) => ({ ...t, scale: 1 }));
321
+ setTransform((prev) => ({ ...prev, scale: 1 }));
321
322
  }}
322
323
  >
323
324
  {Math.round(transform.scale * 100)}%
@@ -325,7 +326,7 @@ export function MermaidViewer({ svg, label }: { svg: string; label: string }) {
325
326
  <Button
326
327
  variant="outline"
327
328
  size="icon-sm"
328
- aria-label="Fit diagram"
329
+ aria-label={t("editor.mermaidViewer.fitDiagram")}
329
330
  onClick={() => {
330
331
  // An explicit fit hands control back: keep fitting on resize.
331
332
  userDrivenRef.current = false;
package/src/monaco.ts ADDED
@@ -0,0 +1,21 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The `monaco-editor` namespace, as its own subpath (ADR 0006 subpath exports).
5
+ *
6
+ * The root barrel (`.`) exports lightweight chrome — `CopyButton`,
7
+ * `EDITOR_LANGUAGES`, `languageLabel` — that a consumer should be able to
8
+ * import WITHOUT pulling Monaco in at all: the editing surfaces
9
+ * (`CodeEditor`, `DiffEditor`, `MarkdownEditor`) already own lazy-loading the
10
+ * engine themselves. Re-exporting the whole `monaco-editor` namespace from
11
+ * that SAME barrel defeated that split — `import { CopyButton } from
12
+ * "@elabs-ai/components-editor"` pulled megabytes of Monaco and touched
13
+ * browser globals at import time, breaking SSR/RSC for a component that never
14
+ * renders the editor.
15
+ *
16
+ * A consumer that needs to build a custom editor, or wire Monaco commands
17
+ * directly, imports this subpath instead:
18
+ *
19
+ * import { monaco } from "@elabs-ai/components-editor/monaco";
20
+ */
21
+ export * as monaco from "monaco-editor";