@elabs-ai/components-editor 4.0.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -7
- package/dist/{chunk-LBC5VJBD.js → chunk-FO5S3YZM.js} +349 -177
- package/dist/chunk-FO5S3YZM.js.map +1 -0
- package/dist/index.css +4 -2
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.js +112 -57
- package/dist/index.js.map +1 -1
- package/dist/markdown/index.css +4 -2
- package/dist/markdown/index.css.map +1 -1
- package/dist/markdown/index.d.ts +2 -2
- package/dist/markdown/index.js +268 -191
- package/dist/markdown/index.js.map +1 -1
- package/dist/{markdown-editor-DfBZibAn.d.ts → markdown-editor-Dn-0L_MM.d.ts} +9 -2
- package/dist/monaco.d.ts +2 -0
- package/dist/monaco.js +9 -0
- package/dist/monaco.js.map +1 -0
- package/package.json +10 -6
- package/src/ai-objects/decision-card.tsx +15 -9
- package/src/ai-objects/entity.tsx +1 -1
- package/src/ai-objects/knowledge-card.tsx +18 -9
- package/src/barrel-monaco-lazy.test.ts +50 -0
- package/src/calc-block/calc-block.tsx +10 -4
- package/src/calc-block/calc-editor.css +1 -1
- package/src/code-editor/code-editor.stories.tsx +76 -1
- package/src/code-editor/code-editor.test.tsx +141 -14
- package/src/code-editor/code-editor.tsx +167 -36
- package/src/code-workspace/code-workspace.test.tsx +113 -11
- package/src/code-workspace/code-workspace.tsx +73 -20
- package/src/copy-button/copy-button.tsx +6 -3
- package/src/diff-editor/diff-editor.stories.tsx +9 -1
- package/src/diff-editor/diff-editor.test.tsx +9 -3
- package/src/diff-editor/diff-editor.tsx +47 -23
- package/src/editor-toolbar/editor-toolbar.tsx +8 -2
- package/src/index.ts +4 -3
- package/src/lib/monaco-deep-imports.d.ts +59 -0
- package/src/lib/monaco-theme-bridge.test.ts +307 -0
- package/src/lib/monaco-theme-bridge.ts +154 -10
- package/src/markdown-academic/citations.tsx +14 -7
- package/src/markdown-academic/footnotes.tsx +2 -2
- package/src/markdown-academic/math.tsx +7 -4
- package/src/markdown-academic/toc.tsx +1 -1
- package/src/markdown-editor/completions/completions-menu.tsx +5 -2
- package/src/markdown-editor/directive-views.tsx +35 -24
- package/src/markdown-editor/markdown-editor.css +26 -3
- package/src/markdown-editor/markdown-editor.focus.test.ts +49 -0
- package/src/markdown-editor/markdown-editor.stories.tsx +128 -7
- package/src/markdown-editor/markdown-editor.tsx +8 -5
- package/src/markdown-editor/milkdown-react/use-get-editor.timer-leak.test.ts +80 -0
- package/src/markdown-editor/milkdown-react/use-get-editor.ts +37 -1
- package/src/markdown-editor/paste-embed.ts +2 -1
- package/src/markdown-editor/slash/slash-menu.stories.tsx +8 -3
- package/src/markdown-editor/slash/slash-menu.test.tsx +27 -0
- package/src/markdown-editor/slash/slash-menu.tsx +18 -3
- package/src/markdown-editor/table-view.tsx +31 -17
- package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +57 -19
- package/src/markdown-iteration/iteration-builder-dialog.tsx +39 -18
- package/src/markdown-iteration/template-dialog.tsx +25 -7
- package/src/markdown-outline/document-outline.stories.tsx +1 -1
- package/src/markdown-outline/document-outline.tsx +7 -3
- package/src/markdown-preview/markdown-preview-academic.test.tsx +3 -3
- package/src/markdown-preview/markdown-preview-transclusion.test.tsx +1 -1
- package/src/markdown-preview/markdown-preview.stories.tsx +24 -2
- package/src/markdown-preview/markdown-preview.test.tsx +20 -3
- package/src/markdown-toolbar/markdown-toolbar.stories.tsx +3 -0
- package/src/markdown-toolbar/markdown-toolbar.tsx +42 -24
- package/src/markdown-workspace/markdown-workspace.test.tsx +53 -4
- package/src/markdown-workspace/markdown-workspace.tsx +6 -3
- package/src/mermaid-diagram/mermaid-diagram-fixes.test.tsx +130 -0
- package/src/mermaid-diagram/mermaid-diagram.test.tsx +2 -1
- package/src/mermaid-diagram/mermaid-diagram.tsx +89 -40
- package/src/mermaid-diagram/mermaid-viewer.tsx +22 -21
- package/src/monaco.ts +21 -0
- package/src/prose/prose.stories.tsx +3 -0
- package/src/prose/prose.test.ts +54 -0
- package/src/prose/prose.tsx +7 -0
- package/dist/chunk-LBC5VJBD.js.map +0 -1
|
@@ -60,7 +60,7 @@ function ItemActionsDemo() {
|
|
|
60
60
|
aria-label={`Pin section “${item.text}”`}
|
|
61
61
|
aria-pressed={pinned === item.id}
|
|
62
62
|
onClick={() => setPinned((p) => (p === item.id ? null : item.id))}
|
|
63
|
-
className="rounded-sm p-0.5 text-muted-foreground hover:text-foreground focus-
|
|
63
|
+
className="rounded-sm p-0.5 text-muted-foreground hover:text-foreground focus-ring"
|
|
64
64
|
>
|
|
65
65
|
<Pin className="size-3" aria-hidden="true" />
|
|
66
66
|
</button>
|
|
@@ -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="
|
|
40
|
+
aria-label={t("editor.documentOutline.label")}
|
|
39
41
|
className={cn("text-body", className)}
|
|
40
42
|
{...props}
|
|
41
43
|
>
|
|
@@ -55,7 +57,7 @@ export const DocumentOutline = forwardRef<HTMLElement, DocumentOutlineProps>(
|
|
|
55
57
|
className={cn(
|
|
56
58
|
"-ms-px block w-full truncate border-s-2 py-1 pe-2 text-start text-caption",
|
|
57
59
|
"transition-colors duration-fast ease-standard motion-reduce:transition-none",
|
|
58
|
-
"focus-
|
|
60
|
+
"focus-ring-inset",
|
|
59
61
|
item.id === activeId
|
|
60
62
|
? "border-s-primary font-medium text-foreground"
|
|
61
63
|
: "border-s-transparent text-muted-foreground hover:border-s-border-strong hover:text-foreground",
|
|
@@ -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">
|
|
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
|
|
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-
|
|
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-
|
|
221
|
+
expect(bibLink.className.split(/\s+/)).toContain("text-link");
|
|
222
222
|
expect(bibLink.className.split(/\s+/)).not.toContain("text-primary");
|
|
223
223
|
});
|
|
224
224
|
});
|
|
@@ -11,7 +11,7 @@ afterEach(cleanup);
|
|
|
11
11
|
* nested mount is perturbed by prior Streamdown renders (an env-only gremlin — the
|
|
12
12
|
* feature renders correctly in the browser and in isolation). So transclusion gets
|
|
13
13
|
* its OWN file with the resolved (nested-render) case asserted FIRST in a clean
|
|
14
|
-
* module context.
|
|
14
|
+
* module context. Cross-theme pixel sweep still owed.
|
|
15
15
|
*/
|
|
16
16
|
describe("MarkdownPreview — transclusion (resolveTransclusion)", () => {
|
|
17
17
|
it("embeds resolved markdown in a labelled figure", async () => {
|
|
@@ -12,11 +12,33 @@ const meta = {
|
|
|
12
12
|
docs: {
|
|
13
13
|
description: {
|
|
14
14
|
component:
|
|
15
|
+
"The EDITOR preview pane, which understands the brand `:::` directives. " +
|
|
16
|
+
"Pick a markdown renderer by where the markdown is going to be READ: a " +
|
|
17
|
+
"read-only document in a chat or a side rail → `AI/MarkdownView`; the preview " +
|
|
18
|
+
"pane of the markdown editor → `Editor/MarkdownPreview`; a file the app did " +
|
|
19
|
+
"not write → the markdown adapter behind `Viewer/FileViewer`; streaming into " +
|
|
20
|
+
"a message as the model writes it → `MessageResponse` on `AI/Message`. See " +
|
|
21
|
+
"[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs)." +
|
|
22
|
+
"\n\n" +
|
|
15
23
|
"Renders markdown to REAL @brand components (Streamdown + a branded components " +
|
|
16
24
|
"map): `#` → Heading, link → Link, table → Table, `---` → Separator, and the brand " +
|
|
17
25
|
"directives `:::card` / `:::callout` / `::metric` / `:::timeline` → Card / Alert / " +
|
|
18
26
|
"MetricBlock / Timeline. Uses the SAME shared remark pipeline as the Milkdown editor. " +
|
|
19
|
-
"Unknown directives render an explicit error block instead of disappearing
|
|
27
|
+
"Unknown directives render an explicit error block instead of disappearing.\n\n" +
|
|
28
|
+
"Its map is NOT a superset or a subset of `MarkdownView`'s — it is a different " +
|
|
29
|
+
"job. Every standard element here is wrapped to attach `data-sourcepos` (the " +
|
|
30
|
+
"seam the editor scroll-sync, the ghost-diff wash and the search wash all read), " +
|
|
31
|
+
"and a heading additionally carries a TOC slug id, a heading-action slot and " +
|
|
32
|
+
"hover-reveal anchor chrome. It has no `baseHeadingLevel`; it does have " +
|
|
33
|
+
"wikilinks, transclusion, `resolveUrl` and mermaid fences.\n\n" +
|
|
34
|
+
"All three map their element tree onto the same `Prose*` primitives owned " +
|
|
35
|
+
"by `@elabs-ai/components-ui` (`@elabs-ai/components-editor` re-exports them " +
|
|
36
|
+
"under short names). The element MAPS stay per-surface on purpose — " +
|
|
37
|
+
"`@elabs-ai/components-ai`, `@elabs-ai/components-editor` and " +
|
|
38
|
+
"`@elabs-ai/components-viewer` are leaves that may not import one another, and " +
|
|
39
|
+
"`streamdown` deliberately never moves down into `@elabs-ai/components-ui`, or " +
|
|
40
|
+
"every consumer of every foundation component would carry it. One prose source, " +
|
|
41
|
+
"several renderers.",
|
|
20
42
|
},
|
|
21
43
|
},
|
|
22
44
|
},
|
|
@@ -88,7 +110,7 @@ export const HeadingActions: Story = {
|
|
|
88
110
|
type="button"
|
|
89
111
|
aria-label={`Pin section “${h.text}”`}
|
|
90
112
|
aria-pressed={h.text === "Timeline"}
|
|
91
|
-
className="rounded-sm p-0.5 text-muted-foreground hover:text-foreground focus-
|
|
113
|
+
className="rounded-sm p-0.5 text-muted-foreground hover:text-foreground focus-ring"
|
|
92
114
|
>
|
|
93
115
|
<Pin className="size-3.5" aria-hidden="true" />
|
|
94
116
|
</button>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
3
3
|
|
|
4
4
|
import { MarkdownPreview } from "./markdown-preview";
|
|
5
5
|
|
|
@@ -249,16 +249,33 @@ describe("MarkdownPreview — code fences (highlight + chip + copy)", () => {
|
|
|
249
249
|
});
|
|
250
250
|
|
|
251
251
|
describe("MarkdownPreview — mermaid fences (#L1)", () => {
|
|
252
|
+
// #64 — under a full, parallel, uncached monorepo run this describe block's
|
|
253
|
+
// FIRST `import("mermaid")` (fired inside MermaidDiagram's effect,
|
|
254
|
+
// mermaid-diagram.tsx) is a large, cold module-graph evaluation
|
|
255
|
+
// (d3/dagre/cytoscape/khroma/katex) that can lose the race against Vitest's
|
|
256
|
+
// 5000ms default test timeout — not because the spec is wrong, but because it
|
|
257
|
+
// shares the event loop with every other worker on a CPU-starved host. Warm
|
|
258
|
+
// the module graph here, OUTSIDE any timed assertion, so the two specs below
|
|
259
|
+
// only ever pay a warm re-import. `mermaid` stays a dynamic import in
|
|
260
|
+
// mermaid-diagram.tsx (`pnpm heavy-deps:check` requires it) — this does not
|
|
261
|
+
// change that, it just pays the cold cost once, up front, off the clock.
|
|
262
|
+
beforeAll(async () => {
|
|
263
|
+
await import("mermaid");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Explicit, generous timeouts as the second, independent half of the fix —
|
|
267
|
+
// belt-and-suspenders alongside the warm-up above, in case the warm-up itself
|
|
268
|
+
// lands inside a contended window. See #64.
|
|
252
269
|
it("routes ```mermaid fences to the MermaidDiagram component", async () => {
|
|
253
270
|
render(<MarkdownPreview>{"```mermaid\ngraph TD; A-->B\n```"}</MarkdownPreview>);
|
|
254
271
|
await waitFor(() => expect(screen.getByTestId("mermaid-diagram")).toBeInTheDocument());
|
|
255
|
-
});
|
|
272
|
+
}, 15_000);
|
|
256
273
|
|
|
257
274
|
it("keeps non-mermaid fences as plain pre blocks", async () => {
|
|
258
275
|
const { container } = render(<MarkdownPreview>{"```ts\nconst x = 1;\n```"}</MarkdownPreview>);
|
|
259
276
|
await waitFor(() => expect(container.querySelector("pre")).toBeInTheDocument());
|
|
260
277
|
expect(container.querySelector('[data-testid="mermaid-diagram"]')).toBeNull();
|
|
261
|
-
});
|
|
278
|
+
}, 15_000);
|
|
262
279
|
});
|
|
263
280
|
|
|
264
281
|
describe("MarkdownPreview — colon false-positives (#workbench live finding)", () => {
|
|
@@ -18,6 +18,9 @@ const meta = {
|
|
|
18
18
|
docs: {
|
|
19
19
|
description: {
|
|
20
20
|
component:
|
|
21
|
+
"The markdown SOURCE-pane toolbar; the general-purpose roving control strip is " +
|
|
22
|
+
"`Layout/Toolbar` — see " +
|
|
23
|
+
"[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
|
|
21
24
|
"Formatting chrome for the markdown SOURCE pane, composed from @elabs-ai/components-ui. " +
|
|
22
25
|
"The Insert menu is driven by the slash-command registry, so source mode " +
|
|
23
26
|
"reaches the same brand blocks as the WYSIWYG `/` menu.",
|
|
@@ -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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
snippet: `:::
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
156
|
+
label={t("editor.markdownToolbar.inlineCode")}
|
|
143
157
|
icon={<Code2 className="size-4" />}
|
|
144
158
|
onClick={run((e) => wrapSelection(e, "`"))}
|
|
145
159
|
/>
|
|
146
|
-
<IconButton
|
|
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="
|
|
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>
|
|
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
|
-
|
|
193
|
+
{t("editor.markdownToolbar.headingLevelItem", { level })}
|
|
176
194
|
</DropdownMenuItem>
|
|
177
195
|
))}
|
|
178
196
|
</DropdownMenuContent>
|
|
179
197
|
</DropdownMenu>
|
|
180
198
|
|
|
181
199
|
<IconButton
|
|
182
|
-
label="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
232
|
+
aria-label={t("editor.markdownToolbar.insertBlock")}
|
|
215
233
|
>
|
|
216
234
|
<SquarePlus className="size-4" />
|
|
217
|
-
<span className="text-xs">
|
|
235
|
+
<span className="text-xs">{t("editor.markdownToolbar.insert")}</span>
|
|
218
236
|
</Button>
|
|
219
237
|
</DropdownMenuTrigger>
|
|
220
238
|
</TooltipTrigger>
|
|
221
|
-
<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(({
|
|
265
|
+
: DIRECTIVE_SNIPPETS.map(({ labelKey, snippet }) => (
|
|
248
266
|
<DropdownMenuItem
|
|
249
|
-
key={
|
|
267
|
+
key={labelKey}
|
|
250
268
|
onSelect={run((e) => insertDirective(e, snippet))}
|
|
251
269
|
>
|
|
252
|
-
{
|
|
270
|
+
{t(labelKey)}
|
|
253
271
|
</DropdownMenuItem>
|
|
254
272
|
))}
|
|
255
273
|
</DropdownMenuContent>
|
|
@@ -3,6 +3,10 @@ import { createRef } from "react";
|
|
|
3
3
|
import { afterEach, expect, test, vi } from "vitest";
|
|
4
4
|
|
|
5
5
|
import type { SlashCommand } from "../markdown-editor/slash";
|
|
6
|
+
import {
|
|
7
|
+
hasPendingMilkdownTeardown,
|
|
8
|
+
waitForPendingMilkdownTeardown,
|
|
9
|
+
} from "../markdown-editor/milkdown-react/use-get-editor";
|
|
6
10
|
|
|
7
11
|
// ---------------------------------------------------------------------------
|
|
8
12
|
// Monaco mock — extended with revealLine / revealLineInCenter / getLineCount
|
|
@@ -25,8 +29,19 @@ vi.mock("monaco-editor", () => ({
|
|
|
25
29
|
onDidBlurEditorText: vi.fn(() => ({ dispose: vi.fn() })),
|
|
26
30
|
getValue: vi.fn(() => ""),
|
|
27
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(),
|
|
28
37
|
getSelection: vi.fn(() => null),
|
|
29
|
-
getModel: vi.fn(() => ({
|
|
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(),
|
|
30
45
|
updateOptions: vi.fn(),
|
|
31
46
|
dispose: vi.fn(),
|
|
32
47
|
getDomNode: vi.fn(() => null),
|
|
@@ -40,7 +55,11 @@ vi.mock("monaco-editor", () => ({
|
|
|
40
55
|
addAction,
|
|
41
56
|
focus: vi.fn(),
|
|
42
57
|
})),
|
|
43
|
-
createModel: 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
|
+
})),
|
|
44
63
|
setModelLanguage: vi.fn(),
|
|
45
64
|
defineTheme: vi.fn(),
|
|
46
65
|
setTheme: vi.fn(),
|
|
@@ -77,14 +96,25 @@ vi.mock("monaco-editor", () => ({
|
|
|
77
96
|
KeyZ: 56,
|
|
78
97
|
},
|
|
79
98
|
Uri: { parse: (s: string) => ({ toString: () => s }) },
|
|
80
|
-
Range: class {
|
|
99
|
+
Range: class {
|
|
100
|
+
static fromPositions(start: unknown, end: unknown) {
|
|
101
|
+
return { start, end };
|
|
102
|
+
}
|
|
103
|
+
},
|
|
81
104
|
Selection: class {},
|
|
82
105
|
}));
|
|
83
106
|
|
|
84
107
|
import { MarkdownWorkspace, type MarkdownWorkspaceHandle } from "./markdown-workspace";
|
|
85
108
|
|
|
86
|
-
|
|
109
|
+
// #65 — `cleanup()` unmounts synchronously but Milkdown's own `editor.destroy()`
|
|
110
|
+
// is async (`@milkdown/ctx` schedules an internal cleanup timer inside it), so
|
|
111
|
+
// returning here without waiting let that timer fire after Vitest recycled this
|
|
112
|
+
// file's jsdom environment: `ReferenceError: removeEventListener is not defined`.
|
|
113
|
+
// Await every pending Milkdown teardown (tracked in `use-get-editor.ts`) before
|
|
114
|
+
// the next test's `vi.clearAllMocks()` / setup runs.
|
|
115
|
+
afterEach(async () => {
|
|
87
116
|
cleanup();
|
|
117
|
+
await waitForPendingMilkdownTeardown();
|
|
88
118
|
vi.clearAllMocks();
|
|
89
119
|
});
|
|
90
120
|
|
|
@@ -98,6 +128,25 @@ test("renders the mode switcher and the WYSIWYG editor", async () => {
|
|
|
98
128
|
await waitFor(() => expect(screen.getByText("Hello")).toBeInTheDocument());
|
|
99
129
|
});
|
|
100
130
|
|
|
131
|
+
// #65 — regression lock for the awaitable Milkdown-destroy handle itself. Proves
|
|
132
|
+
// (a) unmounting a Milkdown-backed surface registers a real pending teardown
|
|
133
|
+
// (not a no-op — a reverted fix would leave nothing tracked and fail the first
|
|
134
|
+
// assertion), and (b) awaiting the exposed handle actually drains it rather than
|
|
135
|
+
// resolving early. Five mount/unmount cycles back to back so a leak in the
|
|
136
|
+
// tracking Set itself (an entry that never gets removed) would also fail.
|
|
137
|
+
test("#65 awaits Milkdown teardown on unmount via the exposed handle", async () => {
|
|
138
|
+
for (let i = 0; i < 5; i += 1) {
|
|
139
|
+
const { unmount } = render(<MarkdownWorkspace defaultMode="wysiwyg" defaultValue="# Hello" />);
|
|
140
|
+
await waitFor(() => expect(screen.getByText("Hello")).toBeInTheDocument());
|
|
141
|
+
|
|
142
|
+
unmount();
|
|
143
|
+
expect(hasPendingMilkdownTeardown()).toBe(true);
|
|
144
|
+
|
|
145
|
+
await waitForPendingMilkdownTeardown();
|
|
146
|
+
expect(hasPendingMilkdownTeardown()).toBe(false);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
101
150
|
// #270 — focusWriting opt-out
|
|
102
151
|
test("focusWriting={false} hides the Focus toggle in wysiwyg mode", () => {
|
|
103
152
|
render(<MarkdownWorkspace defaultMode="wysiwyg" focusWriting={false} />);
|
|
@@ -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="
|
|
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" />
|
|
712
|
+
<Focus className="size-3.5" aria-hidden="true" />{" "}
|
|
713
|
+
{t("editor.markdownWorkspace.focus")}
|
|
711
714
|
</Toggle>
|
|
712
715
|
</TooltipTrigger>
|
|
713
|
-
<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
|
+
});
|
|
@@ -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:
|
|
13
|
+
default: { initialize: initializeMock, render: renderMock },
|
|
13
14
|
}));
|
|
14
15
|
|
|
15
16
|
import { MermaidDiagram } from "./mermaid-diagram";
|