@elabs-ai/components-editor 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +6 -7
  2. package/dist/{chunk-LBC5VJBD.js → chunk-C62O7IOQ.js} +110 -21
  3. package/dist/chunk-C62O7IOQ.js.map +1 -0
  4. package/dist/index.css +4 -2
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.js +57 -20
  8. package/dist/index.js.map +1 -1
  9. package/dist/markdown/index.css +4 -2
  10. package/dist/markdown/index.css.map +1 -1
  11. package/dist/markdown/index.d.ts +2 -2
  12. package/dist/markdown/index.js +9 -9
  13. package/dist/markdown/index.js.map +1 -1
  14. package/dist/{markdown-editor-DfBZibAn.d.ts → markdown-editor-CBp_4eDv.d.ts} +1 -1
  15. package/package.json +6 -6
  16. package/src/ai-objects/entity.tsx +1 -1
  17. package/src/ai-objects/knowledge-card.tsx +1 -1
  18. package/src/calc-block/calc-editor.css +1 -1
  19. package/src/code-editor/code-editor.stories.tsx +76 -1
  20. package/src/code-editor/code-editor.tsx +1 -1
  21. package/src/code-workspace/code-workspace.test.tsx +83 -0
  22. package/src/code-workspace/code-workspace.tsx +73 -20
  23. package/src/diff-editor/diff-editor.stories.tsx +9 -1
  24. package/src/lib/monaco-deep-imports.d.ts +59 -0
  25. package/src/lib/monaco-theme-bridge.test.ts +307 -0
  26. package/src/lib/monaco-theme-bridge.ts +154 -10
  27. package/src/markdown-academic/citations.tsx +2 -2
  28. package/src/markdown-academic/footnotes.tsx +2 -2
  29. package/src/markdown-academic/toc.tsx +1 -1
  30. package/src/markdown-editor/directive-views.tsx +2 -5
  31. package/src/markdown-editor/markdown-editor.css +26 -3
  32. package/src/markdown-editor/markdown-editor.focus.test.ts +49 -0
  33. package/src/markdown-editor/markdown-editor.stories.tsx +128 -7
  34. package/src/markdown-editor/markdown-editor.tsx +8 -5
  35. package/src/markdown-editor/milkdown-react/use-get-editor.timer-leak.test.ts +80 -0
  36. package/src/markdown-editor/milkdown-react/use-get-editor.ts +37 -1
  37. package/src/markdown-editor/paste-embed.ts +2 -1
  38. package/src/markdown-editor/slash/slash-menu.stories.tsx +8 -3
  39. package/src/markdown-editor/slash/slash-menu.test.tsx +27 -0
  40. package/src/markdown-editor/slash/slash-menu.tsx +13 -1
  41. package/src/markdown-editor/table-view.tsx +3 -2
  42. package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +57 -19
  43. package/src/markdown-outline/document-outline.stories.tsx +1 -1
  44. package/src/markdown-outline/document-outline.tsx +1 -1
  45. package/src/markdown-preview/markdown-preview-transclusion.test.tsx +1 -1
  46. package/src/markdown-preview/markdown-preview.stories.tsx +24 -2
  47. package/src/markdown-preview/markdown-preview.test.tsx +20 -3
  48. package/src/markdown-toolbar/markdown-toolbar.stories.tsx +3 -0
  49. package/src/markdown-workspace/markdown-workspace.test.tsx +31 -1
  50. package/src/mermaid-diagram/mermaid-viewer.tsx +1 -1
  51. package/src/prose/prose.stories.tsx +3 -0
  52. package/src/prose/prose.test.ts +54 -0
  53. package/src/prose/prose.tsx +7 -0
  54. package/dist/chunk-LBC5VJBD.js.map +0 -1
@@ -55,7 +55,7 @@ export const DocumentOutline = forwardRef<HTMLElement, DocumentOutlineProps>(
55
55
  className={cn(
56
56
  "-ms-px block w-full truncate border-s-2 py-1 pe-2 text-start text-caption",
57
57
  "transition-colors duration-fast ease-standard motion-reduce:transition-none",
58
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
58
+ "focus-ring-inset",
59
59
  item.id === activeId
60
60
  ? "border-s-primary font-medium text-foreground"
61
61
  : "border-s-transparent text-muted-foreground hover:border-s-border-strong hover:text-foreground",
@@ -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. Six-theme pixel sweep still owed.
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-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
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.",
@@ -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
@@ -83,8 +87,15 @@ vi.mock("monaco-editor", () => ({
83
87
 
84
88
  import { MarkdownWorkspace, type MarkdownWorkspaceHandle } from "./markdown-workspace";
85
89
 
86
- afterEach(() => {
90
+ // #65 — `cleanup()` unmounts synchronously but Milkdown's own `editor.destroy()`
91
+ // is async (`@milkdown/ctx` schedules an internal cleanup timer inside it), so
92
+ // returning here without waiting let that timer fire after Vitest recycled this
93
+ // file's jsdom environment: `ReferenceError: removeEventListener is not defined`.
94
+ // Await every pending Milkdown teardown (tracked in `use-get-editor.ts`) before
95
+ // the next test's `vi.clearAllMocks()` / setup runs.
96
+ afterEach(async () => {
87
97
  cleanup();
98
+ await waitForPendingMilkdownTeardown();
88
99
  vi.clearAllMocks();
89
100
  });
90
101
 
@@ -98,6 +109,25 @@ test("renders the mode switcher and the WYSIWYG editor", async () => {
98
109
  await waitFor(() => expect(screen.getByText("Hello")).toBeInTheDocument());
99
110
  });
100
111
 
112
+ // #65 — regression lock for the awaitable Milkdown-destroy handle itself. Proves
113
+ // (a) unmounting a Milkdown-backed surface registers a real pending teardown
114
+ // (not a no-op — a reverted fix would leave nothing tracked and fail the first
115
+ // assertion), and (b) awaiting the exposed handle actually drains it rather than
116
+ // resolving early. Five mount/unmount cycles back to back so a leak in the
117
+ // tracking Set itself (an entry that never gets removed) would also fail.
118
+ test("#65 awaits Milkdown teardown on unmount via the exposed handle", async () => {
119
+ for (let i = 0; i < 5; i += 1) {
120
+ const { unmount } = render(<MarkdownWorkspace defaultMode="wysiwyg" defaultValue="# Hello" />);
121
+ await waitFor(() => expect(screen.getByText("Hello")).toBeInTheDocument());
122
+
123
+ unmount();
124
+ expect(hasPendingMilkdownTeardown()).toBe(true);
125
+
126
+ await waitForPendingMilkdownTeardown();
127
+ expect(hasPendingMilkdownTeardown()).toBe(false);
128
+ }
129
+ });
130
+
101
131
  // #270 — focusWriting opt-out
102
132
  test("focusWriting={false} hides the Focus toggle in wysiwyg mode", () => {
103
133
  render(<MarkdownWorkspace defaultMode="wysiwyg" focusWriting={false} />);
@@ -278,7 +278,7 @@ export function MermaidViewer({ svg, label }: { svg: string; label: string }) {
278
278
  className={cn(
279
279
  "w-full truncate rounded-md px-2 py-1.5 text-start text-caption transition-colors duration-fast ease-standard motion-reduce:transition-none",
280
280
  "hover:bg-accent hover:text-accent-foreground",
281
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
281
+ "focus-ring-inset",
282
282
  activeHit === hit.id
283
283
  ? "bg-accent font-medium text-foreground"
284
284
  : "text-muted-foreground",
@@ -9,6 +9,9 @@ const meta = {
9
9
  docs: {
10
10
  description: {
11
11
  component:
12
+ "The typographic primitives every markdown renderer maps onto, not a renderer " +
13
+ "itself — see " +
14
+ "[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
12
15
  "Token-driven typography primitives the markdown preview maps onto: Heading, " +
13
16
  "Text, Link, List, Blockquote, InlineCode. Generic and theme-safe.",
14
17
  },
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The teeth behind "one prose source, several renderers".
3
+ *
4
+ * `@elabs-ai/components-ui` owns the canonical `Prose*` set; this package
5
+ * re-exports it under the short names the `@elabs-ai/components-editor/markdown`
6
+ * surface has always used (ADR-0012 own/re-export model, #188). The whole claim
7
+ * that a `Prose*` change cannot drift the editor is that these are the SAME
8
+ * function objects — not two implementations that happen to agree today.
9
+ *
10
+ * Nothing enforced that. A future refactor could quietly replace a line here
11
+ * with a local component of the same name and every render test in
12
+ * `prose.test.tsx` would keep passing, because those assert BEHAVIOUR. These
13
+ * assert IDENTITY, which is the property the re-export actually promises.
14
+ *
15
+ * The import below is the package BARREL (`@elabs-ai/components-ui`), never a
16
+ * deep path into its source. A deep path can resolve to a second module
17
+ * instance whose exports are different objects with identical behaviour — the
18
+ * assertion would then fail for a reason that has nothing to do with the
19
+ * re-export, or (worse, with a different resolver) pass vacuously. The barrel is
20
+ * exactly what `prose.tsx` imports, so this compares like with like.
21
+ */
22
+ import { describe, expect, it } from "vitest";
23
+
24
+ import {
25
+ ProseBlockquote,
26
+ ProseHeading,
27
+ ProseInlineCode,
28
+ ProseLink,
29
+ ProseList,
30
+ ProseListItem,
31
+ ProseText,
32
+ } from "@elabs-ai/components-ui";
33
+
34
+ import { Blockquote, Heading, InlineCode, Link, List, ListItem, Text } from "./prose";
35
+
36
+ describe("prose re-export identity", () => {
37
+ it.each([
38
+ ["Heading", Heading, ProseHeading],
39
+ ["Text", Text, ProseText],
40
+ ["Link", Link, ProseLink],
41
+ ["List", List, ProseList],
42
+ ["ListItem", ListItem, ProseListItem],
43
+ ["Blockquote", Blockquote, ProseBlockquote],
44
+ ["InlineCode", InlineCode, ProseInlineCode],
45
+ ])("%s is the same object as the ui primitive it re-exports", (_name, short, canonical) => {
46
+ expect(short).toBe(canonical);
47
+ });
48
+
49
+ it("re-exports all seven, so the list above cannot silently shrink", () => {
50
+ const shortNames = [Heading, Text, Link, List, ListItem, Blockquote, InlineCode];
51
+ expect(shortNames).toHaveLength(7);
52
+ expect(shortNames.every((c) => typeof c !== "undefined")).toBe(true);
53
+ });
54
+ });
@@ -4,6 +4,13 @@
4
4
  * `components/typography/prose.tsx`; this package derives). The
5
5
  * `@elabs-ai/components-editor/markdown` public surface keeps the original names
6
6
  * (Heading, Text, Link, List, ListItem, Blockquote, InlineCode).
7
+ *
8
+ * These must stay the SAME objects, not lookalikes: that identity is the whole
9
+ * reason a `Prose*` change cannot drift the editor away from the chat view or
10
+ * the file viewer. `prose.test.ts` asserts it against the package barrel — if
11
+ * you replace a line below with a local component, that test goes red while the
12
+ * behaviour tests in `prose.test.tsx` stay green, which is exactly the failure
13
+ * it exists to catch.
7
14
  */
8
15
  export {
9
16
  ProseHeading as Heading,