@elabs-ai/components-editor 4.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 (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/dist/chunk-LBC5VJBD.js +3748 -0
  4. package/dist/chunk-LBC5VJBD.js.map +1 -0
  5. package/dist/index.css +233 -0
  6. package/dist/index.css.map +1 -0
  7. package/dist/index.d.ts +199 -0
  8. package/dist/index.js +362 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/lib/monaco-environment.d.ts +2 -0
  11. package/dist/lib/monaco-environment.js +44 -0
  12. package/dist/lib/monaco-environment.js.map +1 -0
  13. package/dist/markdown/frontmatter.d.ts +11 -0
  14. package/dist/markdown/frontmatter.js +33 -0
  15. package/dist/markdown/frontmatter.js.map +1 -0
  16. package/dist/markdown/index.css +233 -0
  17. package/dist/markdown/index.css.map +1 -0
  18. package/dist/markdown/index.d.ts +1486 -0
  19. package/dist/markdown/index.js +4882 -0
  20. package/dist/markdown/index.js.map +1 -0
  21. package/dist/markdown/parse.d.ts +25 -0
  22. package/dist/markdown/parse.js +14 -0
  23. package/dist/markdown/parse.js.map +1 -0
  24. package/dist/markdown-editor-DfBZibAn.d.ts +702 -0
  25. package/package.json +101 -0
  26. package/src/ai-objects/ai-objects-integration.test.tsx +128 -0
  27. package/src/ai-objects/ai-objects.stories.tsx +233 -0
  28. package/src/ai-objects/ai-objects.test.tsx +205 -0
  29. package/src/ai-objects/decision-card.tsx +178 -0
  30. package/src/ai-objects/directives.ts +136 -0
  31. package/src/ai-objects/entity.tsx +181 -0
  32. package/src/ai-objects/index.ts +34 -0
  33. package/src/ai-objects/knowledge-card.tsx +143 -0
  34. package/src/calc-block/calc-block.stories.tsx +297 -0
  35. package/src/calc-block/calc-block.test.tsx +164 -0
  36. package/src/calc-block/calc-block.tsx +398 -0
  37. package/src/calc-block/calc-editor-monaco.ts +214 -0
  38. package/src/calc-block/calc-editor-prose.ts +128 -0
  39. package/src/calc-block/calc-editor.css +80 -0
  40. package/src/calc-block/calc-editor.stories.tsx +272 -0
  41. package/src/calc-block/calc-editor.test.ts +228 -0
  42. package/src/calc-block/calc-editor.ts +270 -0
  43. package/src/calc-block/calc-inline.stories.tsx +44 -0
  44. package/src/calc-block/calc-inline.test.tsx +44 -0
  45. package/src/calc-block/calc-inline.tsx +75 -0
  46. package/src/calc-block/index.ts +20 -0
  47. package/src/calc-block/types.ts +164 -0
  48. package/src/code-editor/code-editor.stories.tsx +218 -0
  49. package/src/code-editor/code-editor.test.tsx +134 -0
  50. package/src/code-editor/code-editor.tsx +250 -0
  51. package/src/code-editor/index.ts +6 -0
  52. package/src/code-workspace/code-workspace.stories.tsx +51 -0
  53. package/src/code-workspace/code-workspace.test.tsx +146 -0
  54. package/src/code-workspace/code-workspace.tsx +253 -0
  55. package/src/code-workspace/index.ts +6 -0
  56. package/src/copy-button/copy-button.tsx +51 -0
  57. package/src/copy-button/index.ts +1 -0
  58. package/src/css.d.ts +3 -0
  59. package/src/diff-editor/diff-editor.stories.tsx +44 -0
  60. package/src/diff-editor/diff-editor.test.tsx +68 -0
  61. package/src/diff-editor/diff-editor.tsx +151 -0
  62. package/src/diff-editor/index.ts +1 -0
  63. package/src/editor-context-menu/editor-context-menu.tsx +120 -0
  64. package/src/editor-context-menu/index.ts +1 -0
  65. package/src/editor-toolbar/editor-toolbar.tsx +83 -0
  66. package/src/editor-toolbar/index.ts +1 -0
  67. package/src/index.ts +48 -0
  68. package/src/lib/editor-completions-monaco.test.ts +206 -0
  69. package/src/lib/editor-completions-monaco.ts +127 -0
  70. package/src/lib/editor-completions.test.ts +156 -0
  71. package/src/lib/editor-completions.ts +153 -0
  72. package/src/lib/editor-content-access-prose.test.ts +411 -0
  73. package/src/lib/editor-content-access-prose.ts +172 -0
  74. package/src/lib/editor-content-access.test.ts +182 -0
  75. package/src/lib/editor-content-access.ts +109 -0
  76. package/src/lib/languages.ts +32 -0
  77. package/src/lib/markdown/diff.test.ts +80 -0
  78. package/src/lib/markdown/diff.ts +194 -0
  79. package/src/lib/markdown/directives.ts +342 -0
  80. package/src/lib/markdown/frontmatter.ts +50 -0
  81. package/src/lib/markdown/markdown-scale.test.ts +65 -0
  82. package/src/lib/markdown/markdown-scale.ts +62 -0
  83. package/src/lib/markdown/merge.test.ts +64 -0
  84. package/src/lib/markdown/merge.ts +158 -0
  85. package/src/lib/markdown/slugify.test.ts +156 -0
  86. package/src/lib/markdown/slugify.ts +47 -0
  87. package/src/lib/monaco-environment.ts +103 -0
  88. package/src/lib/monaco-theme-bridge.ts +293 -0
  89. package/src/lib/use-data-theme.ts +61 -0
  90. package/src/markdown/frontmatter.ts +14 -0
  91. package/src/markdown/index.ts +323 -0
  92. package/src/markdown/parse.test.ts +60 -0
  93. package/src/markdown/parse.ts +38 -0
  94. package/src/markdown-academic/citations.test.ts +77 -0
  95. package/src/markdown-academic/citations.tsx +442 -0
  96. package/src/markdown-academic/footnotes.tsx +235 -0
  97. package/src/markdown-academic/index.ts +32 -0
  98. package/src/markdown-academic/math.tsx +163 -0
  99. package/src/markdown-academic/toc.tsx +88 -0
  100. package/src/markdown-editor/completions/completions-menu.tsx +96 -0
  101. package/src/markdown-editor/completions/completions-prose.test.ts +356 -0
  102. package/src/markdown-editor/completions/completions-prose.ts +313 -0
  103. package/src/markdown-editor/completions/completions-widget.tsx +75 -0
  104. package/src/markdown-editor/completions/index.ts +37 -0
  105. package/src/markdown-editor/directive-nodes.ts +151 -0
  106. package/src/markdown-editor/directive-views.test.tsx +189 -0
  107. package/src/markdown-editor/directive-views.tsx +795 -0
  108. package/src/markdown-editor/exit-keymap.test.ts +88 -0
  109. package/src/markdown-editor/exit-keymap.ts +87 -0
  110. package/src/markdown-editor/index.ts +6 -0
  111. package/src/markdown-editor/markdown-editor.css +250 -0
  112. package/src/markdown-editor/markdown-editor.directives.test.tsx +105 -0
  113. package/src/markdown-editor/markdown-editor.fill.test.ts +34 -0
  114. package/src/markdown-editor/markdown-editor.paste-embed.test.tsx +297 -0
  115. package/src/markdown-editor/markdown-editor.stories.tsx +560 -0
  116. package/src/markdown-editor/markdown-editor.strictmode.test.tsx +38 -0
  117. package/src/markdown-editor/markdown-editor.table.test.tsx +287 -0
  118. package/src/markdown-editor/markdown-editor.tsx +560 -0
  119. package/src/markdown-editor/milkdown-react/editor.tsx +33 -0
  120. package/src/markdown-editor/milkdown-react/index.ts +8 -0
  121. package/src/markdown-editor/milkdown-react/types.ts +27 -0
  122. package/src/markdown-editor/milkdown-react/use-editor.ts +27 -0
  123. package/src/markdown-editor/milkdown-react/use-get-editor.ts +56 -0
  124. package/src/markdown-editor/milkdown-react/use-instance.ts +23 -0
  125. package/src/markdown-editor/paste-embed.ts +355 -0
  126. package/src/markdown-editor/slash/brand-slash-commands.test.ts +194 -0
  127. package/src/markdown-editor/slash/brand-slash-commands.ts +324 -0
  128. package/src/markdown-editor/slash/brand-slash-plugin.test.ts +254 -0
  129. package/src/markdown-editor/slash/brand-slash-plugin.ts +385 -0
  130. package/src/markdown-editor/slash/index.ts +107 -0
  131. package/src/markdown-editor/slash/insert-directive.test.ts +263 -0
  132. package/src/markdown-editor/slash/insert-directive.ts +345 -0
  133. package/src/markdown-editor/slash/monaco-slash-menu.test.tsx +198 -0
  134. package/src/markdown-editor/slash/monaco-slash-menu.tsx +356 -0
  135. package/src/markdown-editor/slash/shortcut-monaco.ts +69 -0
  136. package/src/markdown-editor/slash/shortcut.test.ts +157 -0
  137. package/src/markdown-editor/slash/shortcut.ts +57 -0
  138. package/src/markdown-editor/slash/slash-menu.stories.tsx +139 -0
  139. package/src/markdown-editor/slash/slash-menu.tsx +115 -0
  140. package/src/markdown-editor/slash/slash-scroll.test.tsx +52 -0
  141. package/src/markdown-editor/slash/slash-widget.tsx +97 -0
  142. package/src/markdown-editor/slash/source-slash-trigger.test.ts +42 -0
  143. package/src/markdown-editor/slash/source-slash-trigger.ts +46 -0
  144. package/src/markdown-editor/table-view.tsx +249 -0
  145. package/src/markdown-iteration/directive.tsx +92 -0
  146. package/src/markdown-iteration/edit-context.ts +56 -0
  147. package/src/markdown-iteration/index.ts +24 -0
  148. package/src/markdown-iteration/iteration-block.test.tsx +138 -0
  149. package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +293 -0
  150. package/src/markdown-iteration/iteration-builder-dialog.tsx +312 -0
  151. package/src/markdown-iteration/iteration-builder.test.ts +252 -0
  152. package/src/markdown-iteration/iteration-builder.ts +303 -0
  153. package/src/markdown-iteration/iteration.test.ts +56 -0
  154. package/src/markdown-iteration/iteration.tsx +333 -0
  155. package/src/markdown-iteration/template-dialog.stories.tsx +63 -0
  156. package/src/markdown-iteration/template-dialog.test.tsx +104 -0
  157. package/src/markdown-iteration/template-dialog.tsx +116 -0
  158. package/src/markdown-outline/document-outline.stories.tsx +76 -0
  159. package/src/markdown-outline/document-outline.tsx +83 -0
  160. package/src/markdown-outline/index.ts +2 -0
  161. package/src/markdown-outline/markdown-outline.test.ts +49 -0
  162. package/src/markdown-outline/markdown-outline.ts +60 -0
  163. package/src/markdown-preview/code-fence.tsx +193 -0
  164. package/src/markdown-preview/index.ts +8 -0
  165. package/src/markdown-preview/markdown-preview-academic.stories.tsx +203 -0
  166. package/src/markdown-preview/markdown-preview-academic.test.tsx +257 -0
  167. package/src/markdown-preview/markdown-preview-iteration-calc.test.tsx +71 -0
  168. package/src/markdown-preview/markdown-preview-iteration-nested.test.tsx +58 -0
  169. package/src/markdown-preview/markdown-preview-iteration.stories.tsx +187 -0
  170. package/src/markdown-preview/markdown-preview-iteration.test.tsx +90 -0
  171. package/src/markdown-preview/markdown-preview-linking.test.tsx +61 -0
  172. package/src/markdown-preview/markdown-preview-transclusion.test.tsx +33 -0
  173. package/src/markdown-preview/markdown-preview.stories.tsx +108 -0
  174. package/src/markdown-preview/markdown-preview.test.tsx +411 -0
  175. package/src/markdown-preview/markdown-preview.tsx +1541 -0
  176. package/src/markdown-toolbar/index.ts +8 -0
  177. package/src/markdown-toolbar/markdown-commands.test.ts +84 -0
  178. package/src/markdown-toolbar/markdown-commands.ts +104 -0
  179. package/src/markdown-toolbar/markdown-toolbar.stories.tsx +56 -0
  180. package/src/markdown-toolbar/markdown-toolbar.tsx +263 -0
  181. package/src/markdown-workspace/focus-writing.test.ts +52 -0
  182. package/src/markdown-workspace/focus-writing.ts +38 -0
  183. package/src/markdown-workspace/index.ts +6 -0
  184. package/src/markdown-workspace/markdown-workspace.stories.tsx +688 -0
  185. package/src/markdown-workspace/markdown-workspace.test.tsx +326 -0
  186. package/src/markdown-workspace/markdown-workspace.tsx +787 -0
  187. package/src/mermaid-diagram/index.ts +1 -0
  188. package/src/mermaid-diagram/mermaid-diagram.stories.tsx +42 -0
  189. package/src/mermaid-diagram/mermaid-diagram.test.tsx +86 -0
  190. package/src/mermaid-diagram/mermaid-diagram.tsx +318 -0
  191. package/src/mermaid-diagram/mermaid-viewer.test.tsx +89 -0
  192. package/src/mermaid-diagram/mermaid-viewer.tsx +367 -0
  193. package/src/mermaid-diagram/real-parse.test.ts +42 -0
  194. package/src/mermaid-diagram/remediate.test.ts +60 -0
  195. package/src/mermaid-diagram/remediate.ts +124 -0
  196. package/src/mermaid-workspace/index.ts +1 -0
  197. package/src/mermaid-workspace/mermaid-workspace.stories.tsx +28 -0
  198. package/src/mermaid-workspace/mermaid-workspace.tsx +71 -0
  199. package/src/metric-block/index.ts +1 -0
  200. package/src/metric-block/metric-block.stories.tsx +40 -0
  201. package/src/metric-block/metric-block.tsx +14 -0
  202. package/src/monaco-workers.d.ts +10 -0
  203. package/src/prose/index.ts +14 -0
  204. package/src/prose/prose.stories.tsx +44 -0
  205. package/src/prose/prose.test.tsx +44 -0
  206. package/src/prose/prose.tsx +21 -0
  207. package/src/timeline/index.ts +18 -0
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Unit tests for the code-block exit command (A3). We boot a HEADLESS Milkdown
3
+ * editor with the real commonmark + gfm schema (so the `code_block` node — and
4
+ * the ```calc fence, which is just a code block — is the genuine one), position
5
+ * the caret inside a code block, run {@link exitCodeBlock} exactly as the
6
+ * `handleKeyDown` binding does, and assert the caret escapes to a paragraph. The
7
+ * live Tab keypress UX is exercised in the browser via the editor stories.
8
+ */
9
+ import { Editor, defaultValueCtx, rootCtx, editorViewCtx } from "@milkdown/kit/core";
10
+ import { commonmark } from "@milkdown/kit/preset/commonmark";
11
+ import { gfm } from "@milkdown/kit/preset/gfm";
12
+ import { Selection, TextSelection } from "@milkdown/kit/prose/state";
13
+ import type { EditorView } from "@milkdown/kit/prose/view";
14
+ import type { Node as ProseNode } from "@milkdown/kit/prose/model";
15
+ import { afterEach, describe, expect, test } from "vitest";
16
+
17
+ import { exitCodeBlock } from "./exit-keymap";
18
+
19
+ const roots: HTMLElement[] = [];
20
+
21
+ async function makeEditor(initial: string): Promise<Editor> {
22
+ const root = document.createElement("div");
23
+ document.body.appendChild(root);
24
+ roots.push(root);
25
+ return Editor.make()
26
+ .config((ctx) => {
27
+ ctx.set(rootCtx, root);
28
+ ctx.set(defaultValueCtx, initial);
29
+ })
30
+ .use(commonmark)
31
+ .use(gfm)
32
+ .create();
33
+ }
34
+
35
+ afterEach(() => {
36
+ for (const r of roots.splice(0)) r.remove();
37
+ });
38
+
39
+ const getView = (editor: Editor): EditorView => editor.action((ctx) => ctx.get(editorViewCtx));
40
+
41
+ /** Position just inside the first code block's content (or -1 if none). */
42
+ function firstCodeBlockContentPos(doc: ProseNode): number {
43
+ let pos = -1;
44
+ doc.descendants((node, p) => {
45
+ if (pos === -1 && node.type.spec.code) pos = p + 1;
46
+ return pos === -1;
47
+ });
48
+ return pos;
49
+ }
50
+
51
+ describe("exitCodeBlock", () => {
52
+ test("from a trailing code block, inserts a following paragraph and lands the caret in it", async () => {
53
+ const editor = await makeEditor("Intro\n\n```\nx = 1\n```\n");
54
+ const v = getView(editor);
55
+ // Caret at the very end of the doc = inside the trailing code block.
56
+ v.dispatch(v.state.tr.setSelection(Selection.atEnd(v.state.doc)));
57
+ expect(v.state.selection.$head.parent.type.spec.code).toBe(true);
58
+
59
+ const handled = exitCodeBlock(v.state, v.dispatch);
60
+ expect(handled).toBe(true);
61
+ expect(v.state.selection.$head.parent.type.name).toBe("paragraph");
62
+ expect(v.state.doc.lastChild?.type.name).toBe("paragraph");
63
+ });
64
+
65
+ test("steps into an existing following paragraph without inserting a new one", async () => {
66
+ const editor = await makeEditor("```\nx = 1\n```\n\nAfter\n");
67
+ const v = getView(editor);
68
+ v.dispatch(
69
+ v.state.tr.setSelection(
70
+ TextSelection.create(v.state.doc, firstCodeBlockContentPos(v.state.doc)),
71
+ ),
72
+ );
73
+ const before = v.state.doc.childCount;
74
+
75
+ const handled = exitCodeBlock(v.state, v.dispatch);
76
+ expect(handled).toBe(true);
77
+ expect(v.state.doc.childCount).toBe(before);
78
+ expect(v.state.selection.$head.parent.type.name).toBe("paragraph");
79
+ expect(v.state.selection.$head.parent.textContent).toBe("After");
80
+ });
81
+
82
+ test("returns false when the caret is not inside a code block", async () => {
83
+ const editor = await makeEditor("Just a paragraph.\n");
84
+ const v = getView(editor);
85
+ v.dispatch(v.state.tr.setSelection(Selection.atStart(v.state.doc)));
86
+ expect(exitCodeBlock(v.state, v.dispatch)).toBe(false);
87
+ });
88
+ });
@@ -0,0 +1,87 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Exit-block keymap — let the caret escape a fenced code block (incl. the
5
+ * ```calc fence) to the block that follows it.
6
+ *
7
+ * Inside a `code_block` there is otherwise no keyboard way out: Tab is swallowed
8
+ * (indent) or blurs the editor, and a code block that is the document's LAST
9
+ * child traps the caret entirely — there is no following line to arrow into.
10
+ * This binds Tab and Mod-Enter (collapsed selection, inside a code block) to move
11
+ * the caret to the block AFTER the code block, inserting a trailing empty
12
+ * paragraph first when the code block is the document's last child.
13
+ *
14
+ * Why `props.handleKeyDown` and not a `keymap` plugin: `handleKeyDown` props run
15
+ * BEFORE keymap plugins, so this reliably beats any commonmark Tab→indent
16
+ * binding. It only acts inside a code block, so Tab in a GFM table (`tableKeymap`
17
+ * → next cell) and Tab elsewhere are untouched, and the slash menu (which never
18
+ * opens inside a code block) is unaffected.
19
+ */
20
+ import type { MilkdownPlugin } from "@milkdown/kit/ctx";
21
+ import { $prose } from "@milkdown/kit/utils";
22
+ import type { Command, Transaction } from "@milkdown/kit/prose/state";
23
+ import { Plugin, TextSelection } from "@milkdown/kit/prose/state";
24
+
25
+ /**
26
+ * ProseMirror command: if the collapsed selection sits inside a `code_block`,
27
+ * move the caret to the block after it — creating a trailing empty paragraph when
28
+ * the code block is the document's last child. Returns `false` otherwise so other
29
+ * handlers still run. Exported standalone so it can be unit-tested against the
30
+ * real editor schema without a live keyboard event.
31
+ */
32
+ export const exitCodeBlock: Command = (state, dispatch) => {
33
+ const { selection } = state;
34
+ if (!selection.empty) return false;
35
+ const { $head } = selection;
36
+ // `spec.code` is set on code-block node types (commonmark's `code_block`,
37
+ // including the ```calc fence) — the same check the slash plugin uses.
38
+ if (!$head.parent.type.spec.code) return false;
39
+
40
+ const paragraph = state.schema.nodes.paragraph;
41
+ if (!paragraph) return false;
42
+
43
+ // Position directly after the code-block node (its sibling boundary).
44
+ const after = $head.after($head.depth);
45
+
46
+ if (dispatch) {
47
+ const nodeAfter = state.doc.resolve(after).nodeAfter;
48
+ let tr: Transaction = state.tr;
49
+ if (nodeAfter && nodeAfter.isTextblock) {
50
+ // A textblock already follows — step the caret into its content start.
51
+ tr = tr.setSelection(TextSelection.create(tr.doc, after + 1));
52
+ } else {
53
+ // Nothing usable follows (the code block is the last child, or a leaf
54
+ // follows) — insert a fresh empty paragraph and land the caret inside it.
55
+ const para = paragraph.createAndFill();
56
+ if (!para) return false;
57
+ tr = tr.insert(after, para);
58
+ tr = tr.setSelection(TextSelection.create(tr.doc, after + 1));
59
+ }
60
+ dispatch(tr.scrollIntoView());
61
+ }
62
+ return true;
63
+ };
64
+
65
+ /**
66
+ * The Milkdown plugin array to `.use()` on the editor chain. Binds Tab and
67
+ * Mod-Enter to {@link exitCodeBlock} via `handleKeyDown`.
68
+ */
69
+ export function exitKeymapPlugins(): MilkdownPlugin[] {
70
+ return [
71
+ $prose(
72
+ () =>
73
+ new Plugin({
74
+ props: {
75
+ handleKeyDown: (view, event) => {
76
+ const isTab = event.key === "Tab" && !event.shiftKey;
77
+ const isModEnter = event.key === "Enter" && (event.metaKey || event.ctrlKey);
78
+ if (!isTab && !isModEnter) return false;
79
+ const handled = exitCodeBlock(view.state, view.dispatch);
80
+ if (handled) event.preventDefault();
81
+ return handled;
82
+ },
83
+ },
84
+ }),
85
+ ) as unknown as MilkdownPlugin,
86
+ ];
87
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ MarkdownEditor,
3
+ type MarkdownEditorProps,
4
+ type MarkdownEditorHandle,
5
+ type EmbedAssetFn,
6
+ } from "./markdown-editor";
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Token-driven theme for the Milkdown (ProseMirror) WYSIWYG surface.
3
+ *
4
+ * Milkdown core ships ZERO styles (headless), so this is where brand-ui owns the
5
+ * look. Every value is a semantic design token (var(--foreground), --border,
6
+ * --radius, …) — NO raw colors — so the editor matches all three themes
7
+ * (light/dark) for free. Every rule
8
+ * is scoped under `.milkdown-host` so nothing leaks globally.
9
+ */
10
+
11
+ .milkdown-host .ProseMirror {
12
+ outline: none;
13
+ padding: 1rem 1.1rem;
14
+ min-height: 9rem;
15
+ color: var(--foreground);
16
+ font-size: 0.875rem;
17
+ line-height: 1.65;
18
+ caret-color: var(--primary);
19
+ word-break: break-word;
20
+ /* Same reading measure as the preview (max-w-3xl). Driven by the shared scale
21
+ (--md-measure, set via markdownScaleVars) so editor + preview match (#18). */
22
+ max-width: var(--md-measure, 48rem);
23
+ margin-inline: auto;
24
+ }
25
+
26
+ .milkdown-host .ProseMirror:focus {
27
+ outline: none;
28
+ }
29
+
30
+ /* ----------------------------------------------------------------------------
31
+ * Fill height — the editable grows to fill a definite-height parent.
32
+ *
33
+ * Milkdown's host box is otherwise content-sized (the `.ProseMirror` floor below
34
+ * is `min-height: 9rem`), so inside a tall pane a short document leaves a large
35
+ * dead area below the editable AND the caret-anchored `/` slash menu clips inside
36
+ * the short box near the bottom edge. Making `.milkdown-host` a column flex that
37
+ * fills its parent — and letting the intermediate Milkdown wrapper + the
38
+ * ProseMirror surface grow inside it — means the editable fills the pane
39
+ * (clickable everywhere) and the slash menu has room to open / flip.
40
+ *
41
+ * `min-height: 100%` only resolves against a parent with a DEFINITE height (the
42
+ * workspace's `h-full` wysiwyg pane, or any sized container). In an
43
+ * indefinite-height parent it resolves to `auto`, so the standalone / short
44
+ * editor still sizes to its content above the 9rem floor — no regression for the
45
+ * autodocs / inline cases. (elabs-components: editor-fill-height)
46
+ * ------------------------------------------------------------------------- */
47
+ .milkdown-host {
48
+ display: flex;
49
+ flex-direction: column;
50
+ min-height: 100%;
51
+ }
52
+ .milkdown-host > div,
53
+ .milkdown-host .milkdown {
54
+ display: flex;
55
+ min-height: 0;
56
+ flex: 1 1 auto;
57
+ flex-direction: column;
58
+ }
59
+ .milkdown-host .ProseMirror.editor {
60
+ flex: 1 1 auto;
61
+ }
62
+
63
+ .milkdown-host .ProseMirror > * + * {
64
+ margin-top: 0.7em;
65
+ }
66
+
67
+ /* Headings — sizes/weight/tracking come from the SHARED markdown scale
68
+ (markdown-scale.ts → --md-* vars on .milkdown-host), so the editor matches the
69
+ prose `Heading` exactly and can't drift on the next edit to either side (#18).
70
+ Fallbacks equal the canonical scale for use without the vars (e.g. clipboard). */
71
+ .milkdown-host .ProseMirror h1,
72
+ .milkdown-host .ProseMirror h2,
73
+ .milkdown-host .ProseMirror h3,
74
+ .milkdown-host .ProseMirror h4,
75
+ .milkdown-host .ProseMirror h5,
76
+ .milkdown-host .ProseMirror h6 {
77
+ font-weight: var(--md-heading-weight, 600);
78
+ letter-spacing: var(--md-heading-tracking, -0.025em);
79
+ line-height: 1.25;
80
+ color: var(--foreground);
81
+ margin-top: 1.1em;
82
+ }
83
+ .milkdown-host .ProseMirror h1 {
84
+ font-size: var(--md-h1, 1.5rem);
85
+ }
86
+ .milkdown-host .ProseMirror h2 {
87
+ font-size: var(--md-h2, 1.25rem);
88
+ }
89
+ .milkdown-host .ProseMirror h3 {
90
+ font-size: var(--md-h3, 1.125rem);
91
+ }
92
+ .milkdown-host .ProseMirror h4 {
93
+ font-size: var(--md-h4, 1rem);
94
+ }
95
+ .milkdown-host .ProseMirror h5 {
96
+ font-size: var(--md-h5, 0.875rem);
97
+ }
98
+ .milkdown-host .ProseMirror h6 {
99
+ font-size: var(--md-h6, 0.875rem);
100
+ color: var(--muted-foreground);
101
+ }
102
+
103
+ /* Inline */
104
+ .milkdown-host .ProseMirror a {
105
+ color: var(--primary);
106
+ text-decoration: underline;
107
+ text-underline-offset: 2px;
108
+ }
109
+ .milkdown-host .ProseMirror strong {
110
+ font-weight: 650;
111
+ }
112
+ .milkdown-host .ProseMirror code {
113
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
114
+ font-size: 0.85em;
115
+ background: var(--muted);
116
+ color: var(--foreground);
117
+ padding: 0.12em 0.36em;
118
+ border-radius: calc(var(--radius) - 4px);
119
+ }
120
+
121
+ /* Blocks */
122
+ .milkdown-host .ProseMirror blockquote {
123
+ border-left: 2px solid var(--border);
124
+ padding-left: 1rem;
125
+ color: var(--muted-foreground);
126
+ }
127
+ .milkdown-host .ProseMirror pre {
128
+ background: var(--surface-muted);
129
+ border: 1px solid var(--border);
130
+ border-radius: var(--radius);
131
+ padding: 0.85rem 1rem;
132
+ overflow: auto;
133
+ }
134
+ .milkdown-host .ProseMirror pre code {
135
+ background: transparent;
136
+ padding: 0;
137
+ font-size: 0.82rem;
138
+ }
139
+ .milkdown-host .ProseMirror hr {
140
+ border: none;
141
+ border-top: 1px solid var(--border);
142
+ margin: 1.25em 0;
143
+ }
144
+
145
+ /* Lists */
146
+ .milkdown-host .ProseMirror ul,
147
+ .milkdown-host .ProseMirror ol {
148
+ padding-left: 1.4rem;
149
+ }
150
+ .milkdown-host .ProseMirror ul {
151
+ list-style: disc;
152
+ }
153
+ .milkdown-host .ProseMirror ol {
154
+ list-style: decimal;
155
+ }
156
+ .milkdown-host .ProseMirror li > * + * {
157
+ margin-top: 0.35em;
158
+ }
159
+
160
+ /* Tables (GFM) */
161
+ .milkdown-host .ProseMirror table {
162
+ border-collapse: collapse;
163
+ width: 100%;
164
+ font-size: 0.85rem;
165
+ }
166
+ .milkdown-host .ProseMirror th,
167
+ .milkdown-host .ProseMirror td {
168
+ border: 1px solid var(--border);
169
+ padding: 0.4rem 0.6rem;
170
+ text-align: left;
171
+ }
172
+ .milkdown-host .ProseMirror th {
173
+ background: var(--surface-muted);
174
+ font-weight: 600;
175
+ }
176
+
177
+ /* Task lists (GFM) */
178
+ .milkdown-host .ProseMirror ul[data-item-type="task"] {
179
+ list-style: none;
180
+ padding-left: 0.25rem;
181
+ }
182
+
183
+ /* Read-only mode dims the caret affordance */
184
+ .milkdown-host .ProseMirror[contenteditable="false"] {
185
+ caret-color: transparent;
186
+ }
187
+
188
+ /* ---- Brand `:::` directives ----------------------------------------------------
189
+ * These now render as LIVE @brand React components via @prosemirror-adapter/react
190
+ * (see directive-views.tsx), so the box-model chrome (card/callout/metric borders,
191
+ * fills, type accents) comes from the components themselves — NOT from here. This
192
+ * section only owns what the editor adds on top: outer spacing, the editable-body
193
+ * rhythm, the timeline frame (an editable list, not a derived <Timeline>), and the
194
+ * inline-edit affordance for editable attributes (card/callout title, metric
195
+ * label/value). The `toDOM` defs in directive-nodes.ts keep their own marker classes
196
+ * for clipboard serialization but are not visually rendered while node-views are on.
197
+ */
198
+ .milkdown-host .brand-directive {
199
+ margin: 0.85em 0;
200
+ }
201
+ .milkdown-host .brand-directive__body > * + * {
202
+ margin-top: 0.6em;
203
+ }
204
+ /* The first/last editable block inside a card/callout shouldn't add stray margin
205
+ * on top of the component's own padding. */
206
+ .milkdown-host .brand-directive__body > :first-child {
207
+ margin-top: 0;
208
+ }
209
+
210
+ /* timeline → keep the list editable inside a subtle frame */
211
+ .milkdown-host .brand-directive--timeline {
212
+ border-left: 2px solid var(--border);
213
+ padding-left: 1rem;
214
+ }
215
+
216
+ /* Inline-editable directive attribute (seamless, caret-friendly). */
217
+ .milkdown-host .brand-inline-edit {
218
+ cursor: text;
219
+ min-width: 1ch;
220
+ /* inherit the surrounding component's type (CardTitle, MetricBlock label/value) */
221
+ font: inherit;
222
+ color: inherit;
223
+ letter-spacing: inherit;
224
+ }
225
+ .milkdown-host .brand-inline-edit:empty::before {
226
+ content: attr(data-placeholder);
227
+ color: var(--muted-foreground);
228
+ opacity: 0.65;
229
+ pointer-events: none;
230
+ }
231
+
232
+ /* ------------------------------------------------------------------------- */
233
+ /* Focus writing (Ulysses Phase A): under a `[data-focus-writing]` ancestor */
234
+ /* (the workspace's wysiwyg pane), every top-level block dims except the one */
235
+ /* owning the selection (`.wb-fw-active`, set by the workspace) — paragraph */
236
+ /* focus. Hover un-dims for mouse repositioning. Token-driven, motion-safe. */
237
+ /* ------------------------------------------------------------------------- */
238
+ [data-focus-writing] .milkdown-host .ProseMirror > * {
239
+ opacity: 0.35;
240
+ transition: opacity var(--t-base, 200ms) var(--ease-standard, ease);
241
+ }
242
+ [data-focus-writing] .milkdown-host .ProseMirror > .wb-fw-active,
243
+ [data-focus-writing] .milkdown-host .ProseMirror > *:hover {
244
+ opacity: 1;
245
+ }
246
+ @media (prefers-reduced-motion: reduce) {
247
+ [data-focus-writing] .milkdown-host .ProseMirror > * {
248
+ transition: none;
249
+ }
250
+ }
@@ -0,0 +1,105 @@
1
+ import { createRef } from "react";
2
+ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
3
+ import { afterEach, expect, test } from "vitest";
4
+
5
+ import { MarkdownEditor, type MarkdownEditorHandle } from "./markdown-editor";
6
+
7
+ afterEach(cleanup);
8
+
9
+ const DOC = `# Heading
10
+
11
+ :::card{title="Objective"}
12
+
13
+ card body text
14
+
15
+ :::
16
+
17
+ ::metric{label="Scope" value="1,245" description="reports"}
18
+ `;
19
+
20
+ test("parses :::card + ::metric into branded editor chrome", async () => {
21
+ const { container } = render(<MarkdownEditor defaultValue={DOC} />);
22
+ await waitFor(() => {
23
+ expect(container.querySelector(".brand-directive--card")).toBeTruthy();
24
+ expect(container.querySelector(".brand-directive--metric")).toBeTruthy();
25
+ });
26
+ });
27
+
28
+ test("round-trips directives back to markdown (parse → serialize)", async () => {
29
+ const ref = createRef<MarkdownEditorHandle>();
30
+ const { container } = render(<MarkdownEditor ref={ref} defaultValue={DOC} />);
31
+ // Wait until the editor has mounted + rendered the directive node.
32
+ await waitFor(() => expect(container.querySelector(".brand-directive--card")).toBeTruthy());
33
+
34
+ const md = ref.current!.getMarkdown();
35
+ expect(md).toContain(":::card");
36
+ expect(md).toContain("card body text");
37
+ expect(md).toContain("::metric");
38
+ expect(md).toContain("Scope");
39
+ });
40
+
41
+ test("renders directives as live @brand components with inline-editable attributes", async () => {
42
+ render(<MarkdownEditor defaultValue={DOC} />);
43
+ // The node-views expose the directive title + metric label/value as editable
44
+ // textboxes — an affordance the old toDOM chrome did not have.
45
+ await waitFor(() => expect(screen.getByRole("textbox", { name: "Card title" })).toBeTruthy());
46
+ expect(screen.getByRole("textbox", { name: "Metric label" })).toBeTruthy();
47
+
48
+ const value = screen.getByRole("textbox", { name: "Metric value" });
49
+ expect(value).toBeTruthy();
50
+ expect(value.textContent).toBe("1,245");
51
+ });
52
+
53
+ test("committing an inline edit writes through to the markdown attributes", async () => {
54
+ const ref = createRef<MarkdownEditorHandle>();
55
+ render(<MarkdownEditor ref={ref} defaultValue={DOC} />);
56
+
57
+ const value = await screen.findByRole("textbox", { name: "Metric value" });
58
+
59
+ // Edit the metric value inline, then commit on blur.
60
+ value.focus();
61
+ value.textContent = "9,999";
62
+ fireEvent.blur(value);
63
+
64
+ await waitFor(() => expect(ref.current!.getMarkdown()).toContain('value="9,999"'));
65
+ // The untouched attributes survive the write-through.
66
+ const md = ref.current!.getMarkdown();
67
+ expect(md).toContain('label="Scope"');
68
+ expect(md).not.toContain('value="1,245"');
69
+ });
70
+
71
+ test("#21 — the editable surface has an accessible name (default + override)", async () => {
72
+ const { unmount } = render(<MarkdownEditor defaultValue={DOC} />);
73
+ await waitFor(() =>
74
+ expect(screen.getByRole("textbox", { name: "Markdown editor" })).toBeTruthy(),
75
+ );
76
+ unmount();
77
+
78
+ render(<MarkdownEditor defaultValue={DOC} ariaLabel="Notes" />);
79
+ await waitFor(() => expect(screen.getByRole("textbox", { name: "Notes" })).toBeTruthy());
80
+ });
81
+
82
+ test("#37 — inline-edit textboxes declare single-line (aria-multiline=false)", async () => {
83
+ render(<MarkdownEditor defaultValue={DOC} />);
84
+ const label = await screen.findByRole("textbox", { name: "Metric label" });
85
+ expect(label).toHaveAttribute("aria-multiline", "false");
86
+ });
87
+
88
+ test("#21/#37 — a :::callout title is NOT a heading inside the WYSIWYG flow", async () => {
89
+ const { container } = render(
90
+ <MarkdownEditor
91
+ defaultValue={`# Doc\n\n:::callout{type="warning" title="Heads up"}\n\nbody\n\n:::`}
92
+ />,
93
+ );
94
+ await waitFor(() => expect(container.querySelector(".brand-directive--callout")).toBeTruthy());
95
+ // The callout title is present as an editable textbox, but not a heading.
96
+ expect(screen.getByRole("textbox", { name: "Callout title" })).toBeTruthy();
97
+ expect(screen.queryByRole("heading", { name: "Heads up" })).toBeNull();
98
+ });
99
+
100
+ test("#37 — an unknown container directive is role=note, not an assertive alert", async () => {
101
+ render(<MarkdownEditor defaultValue={`:::mystery\n\nbody\n\n:::`} />);
102
+ await waitFor(() => expect(screen.getByRole("note")).toBeTruthy());
103
+ // It must NOT be an assertive live region (role=alert) that re-announces on re-parse.
104
+ expect(screen.queryByRole("alert")).toBeNull();
105
+ });
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+
5
+ // Read the stylesheet as text. The repo runs vitest per-package (turbo /
6
+ // `--filter`), so cwd is the package root — `src/...` resolves deterministically.
7
+ const css = readFileSync(resolve(process.cwd(), "src/markdown-editor/markdown-editor.css"), "utf8");
8
+
9
+ /**
10
+ * The editor's fill-height behavior lives in CSS (`markdown-editor.css`), which
11
+ * jsdom does not lay out — so a render test can't observe it. Lock the rules
12
+ * deterministically instead: assert the stylesheet still declares the flex-fill
13
+ * chain that makes the editable grow to fill a definite-height parent. The
14
+ * regression this guards is the editable staying content-sized (`min-height:
15
+ * 9rem`) inside a tall pane, which also clips the caret-anchored `/` slash menu.
16
+ * The real visual proof is the `FillsContainer` Storybook story.
17
+ * (elabs-components: editor-fill-height)
18
+ */
19
+
20
+ describe("editor fill-height CSS (regression)", () => {
21
+ it("makes .milkdown-host a column flex that fills its parent", () => {
22
+ expect(css).toMatch(/\.milkdown-host\s*\{[^}]*display:\s*flex/);
23
+ expect(css).toMatch(/\.milkdown-host\s*\{[^}]*flex-direction:\s*column/);
24
+ expect(css).toMatch(/\.milkdown-host\s*\{[^}]*min-height:\s*100%/);
25
+ });
26
+
27
+ it("lets the Milkdown wrapper grow inside the host", () => {
28
+ expect(css).toMatch(/\.milkdown-host\s+\.milkdown[^{]*\{[^}]*flex:\s*1 1 auto/);
29
+ });
30
+
31
+ it("lets the ProseMirror editing surface grow", () => {
32
+ expect(css).toMatch(/\.milkdown-host\s+\.ProseMirror\.editor\s*\{[^}]*flex:\s*1 1 auto/);
33
+ });
34
+ });