@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,8 @@
1
+ export { MarkdownToolbar, type MarkdownToolbarProps } from "./markdown-toolbar";
2
+ export {
3
+ wrapSelection,
4
+ toggleLinePrefix,
5
+ insertLink,
6
+ insertHorizontalRule,
7
+ insertDirective,
8
+ } from "./markdown-commands";
@@ -0,0 +1,84 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // Mock monaco's Range/Selection value classes (pure data — no real editor engine).
4
+ vi.mock("monaco-editor", () => ({
5
+ Range: class {
6
+ constructor(
7
+ public startLineNumber: number,
8
+ public startColumn: number,
9
+ public endLineNumber: number,
10
+ public endColumn: number,
11
+ ) {}
12
+ },
13
+ Selection: class {
14
+ constructor(
15
+ public startLineNumber: number,
16
+ public startColumn: number,
17
+ public endLineNumber: number,
18
+ public endColumn: number,
19
+ ) {}
20
+ },
21
+ }));
22
+
23
+ import { insertHorizontalRule, toggleLinePrefix, wrapSelection } from "./markdown-commands";
24
+
25
+ type Edit = { text: string; range: { startColumn: number } };
26
+
27
+ function fakeEditor(lines: string[], selected: string) {
28
+ const edits: Edit[] = [];
29
+ const selection = {
30
+ startLineNumber: 1,
31
+ startColumn: 1,
32
+ endLineNumber: lines.length,
33
+ endColumn: (lines.at(-1)?.length ?? 0) + 1,
34
+ };
35
+ const model = {
36
+ getValueInRange: () => selected,
37
+ getLineContent: (n: number) => lines[n - 1] ?? "",
38
+ getLineMaxColumn: (n: number) => (lines[n - 1]?.length ?? 0) + 1,
39
+ };
40
+ const editor = {
41
+ getModel: () => model,
42
+ getSelection: () => selection,
43
+ executeEdits: (_src: string, e: Edit[]) => edits.push(...e),
44
+ setSelection: vi.fn(),
45
+ focus: vi.fn(),
46
+ };
47
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
48
+ return { editor: editor as any, edits };
49
+ }
50
+
51
+ describe("markdown commands", () => {
52
+ let warn: ReturnType<typeof vi.spyOn>;
53
+ beforeEach(() => {
54
+ warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
55
+ });
56
+
57
+ it("wraps the selection with markers", () => {
58
+ const { editor, edits } = fakeEditor(["Bold me"], "Bold me");
59
+ wrapSelection(editor, "**");
60
+ expect(edits[0]?.text).toBe("**Bold me**");
61
+ warn.mockRestore();
62
+ });
63
+
64
+ it("adds a line prefix when absent", () => {
65
+ const { editor, edits } = fakeEditor(["hello"], "hello");
66
+ toggleLinePrefix(editor, "# ");
67
+ expect(edits[0]?.text).toBe("# ");
68
+ warn.mockRestore();
69
+ });
70
+
71
+ it("removes the line prefix when already present (toggle)", () => {
72
+ const { editor, edits } = fakeEditor(["# hello"], "# hello");
73
+ toggleLinePrefix(editor, "# ");
74
+ expect(edits[0]?.text).toBe("");
75
+ warn.mockRestore();
76
+ });
77
+
78
+ it("inserts a horizontal rule", () => {
79
+ const { editor, edits } = fakeEditor(["para"], "");
80
+ insertHorizontalRule(editor);
81
+ expect(edits[0]?.text).toContain("---");
82
+ warn.mockRestore();
83
+ });
84
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Markdown editing commands that operate on a Monaco editor instance (the source
3
+ * pane). Pure functions over the editor — the toolbar UI wires buttons to these.
4
+ */
5
+ import * as monaco from "monaco-editor";
6
+
7
+ import type { MonacoCodeEditor } from "../code-editor";
8
+
9
+ /** Wrap the current selection (or insert a placeholder) with `before`/`after`. */
10
+ export function wrapSelection(
11
+ editor: MonacoCodeEditor,
12
+ before: string,
13
+ after: string = before,
14
+ placeholder = "text",
15
+ ): void {
16
+ const model = editor.getModel();
17
+ const selection = editor.getSelection();
18
+ if (!model || !selection) return;
19
+
20
+ const selected = model.getValueInRange(selection) || placeholder;
21
+ editor.executeEdits("markdown-toolbar", [
22
+ { range: selection, text: `${before}${selected}${after}`, forceMoveMarkers: true },
23
+ ]);
24
+ // Re-select the inner text so the user can keep typing over the placeholder.
25
+ const startCol = selection.startColumn + before.length;
26
+ editor.setSelection(
27
+ new monaco.Selection(
28
+ selection.startLineNumber,
29
+ startCol,
30
+ selection.startLineNumber,
31
+ startCol + selected.length,
32
+ ),
33
+ );
34
+ editor.focus();
35
+ }
36
+
37
+ /** Toggle a line prefix (`# `, `> `, `- `, `1. `) on every selected line. */
38
+ export function toggleLinePrefix(editor: MonacoCodeEditor, prefix: string): void {
39
+ const model = editor.getModel();
40
+ const selection = editor.getSelection();
41
+ if (!model || !selection) return;
42
+
43
+ const edits: monaco.editor.IIdentifiedSingleEditOperation[] = [];
44
+ const allPrefixed = (() => {
45
+ for (let line = selection.startLineNumber; line <= selection.endLineNumber; line++) {
46
+ if (!model.getLineContent(line).startsWith(prefix)) return false;
47
+ }
48
+ return true;
49
+ })();
50
+
51
+ for (let line = selection.startLineNumber; line <= selection.endLineNumber; line++) {
52
+ const content = model.getLineContent(line);
53
+ if (allPrefixed) {
54
+ edits.push({
55
+ range: new monaco.Range(line, 1, line, prefix.length + 1),
56
+ text: "",
57
+ });
58
+ } else if (!content.startsWith(prefix)) {
59
+ edits.push({ range: new monaco.Range(line, 1, line, 1), text: prefix });
60
+ }
61
+ }
62
+ editor.executeEdits("markdown-toolbar", edits);
63
+ editor.focus();
64
+ }
65
+
66
+ /** Insert `[selection](url)` (or a placeholder link). */
67
+ export function insertLink(editor: MonacoCodeEditor): void {
68
+ const model = editor.getModel();
69
+ const selection = editor.getSelection();
70
+ if (!model || !selection) return;
71
+ const label = model.getValueInRange(selection) || "label";
72
+ editor.executeEdits("markdown-toolbar", [
73
+ { range: selection, text: `[${label}](https://)`, forceMoveMarkers: true },
74
+ ]);
75
+ editor.focus();
76
+ }
77
+
78
+ /** Insert a horizontal rule on its own line below the cursor. */
79
+ export function insertHorizontalRule(editor: MonacoCodeEditor): void {
80
+ const selection = editor.getSelection();
81
+ if (!selection) return;
82
+ const line = selection.endLineNumber;
83
+ const col = editor.getModel()?.getLineMaxColumn(line) ?? 1;
84
+ editor.executeEdits("markdown-toolbar", [
85
+ { range: new monaco.Range(line, col, line, col), text: `\n\n---\n`, forceMoveMarkers: true },
86
+ ]);
87
+ editor.focus();
88
+ }
89
+
90
+ /** Insert a brand directive block at the cursor (e.g. card/callout/metric/timeline). */
91
+ export function insertDirective(editor: MonacoCodeEditor, snippet: string): void {
92
+ const selection = editor.getSelection();
93
+ if (!selection) return;
94
+ const line = selection.endLineNumber;
95
+ const col = editor.getModel()?.getLineMaxColumn(line) ?? 1;
96
+ editor.executeEdits("markdown-toolbar", [
97
+ {
98
+ range: new monaco.Range(line, col, line, col),
99
+ text: `\n\n${snippet}\n`,
100
+ forceMoveMarkers: true,
101
+ },
102
+ ]);
103
+ editor.focus();
104
+ }
@@ -0,0 +1,56 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, waitFor, within } from "storybook/test";
3
+
4
+ import type { MonacoCodeEditor } from "../code-editor";
5
+ import { BRAND_SLASH_COMMANDS } from "../markdown-editor/slash";
6
+ import { MarkdownToolbar } from "./markdown-toolbar";
7
+
8
+ // A no-op stand-in so the toolbar's controls (and the Insert trigger) are enabled
9
+ // in the story. Listing the Insert items never calls editor methods — only an
10
+ // item click does — so an empty object is enough to render and open the menu.
11
+ const fakeEditor = {} as unknown as MonacoCodeEditor;
12
+
13
+ const meta = {
14
+ title: "Editor/MarkdownToolbar",
15
+ component: MarkdownToolbar,
16
+ tags: ["autodocs"],
17
+ parameters: {
18
+ docs: {
19
+ description: {
20
+ component:
21
+ "Formatting chrome for the markdown SOURCE pane, composed from @elabs-ai/components-ui. " +
22
+ "The Insert menu is driven by the slash-command registry, so source mode " +
23
+ "reaches the same brand blocks as the WYSIWYG `/` menu.",
24
+ },
25
+ },
26
+ },
27
+ } satisfies Meta<typeof MarkdownToolbar>;
28
+
29
+ export default meta;
30
+ type Story = StoryObj<typeof meta>;
31
+
32
+ export const Default: Story = {
33
+ args: { editor: fakeEditor },
34
+ };
35
+
36
+ /**
37
+ * Source / split Insert-menu parity (A4): passing the slash registry as
38
+ * `insertCommands` makes the Insert menu list every command that carries a
39
+ * source-mode `snippet` — Card · Callout · Metric · Timeline · Iterate · Pivot ·
40
+ * Calc — so a markdown-first author can insert `/calc`, `/iterate` and `/pivot`
41
+ * from the toolbar, not only from the WYSIWYG slash menu.
42
+ */
43
+ export const InsertParity: Story = {
44
+ args: { editor: fakeEditor, insertCommands: BRAND_SLASH_COMMANDS },
45
+ play: async ({ canvas, canvasElement, userEvent }) => {
46
+ await userEvent.click(canvas.getByRole("button", { name: "Insert block" }));
47
+ const menu = within(canvasElement.ownerDocument.body);
48
+ // The blocks the old hardcoded 4-item menu lacked are now present.
49
+ // waitFor rides out the DropdownMenu entrance animation (opacity starts
50
+ // at 0) — the dropdown-menu.stories.tsx precedent (#278 D2).
51
+ const calc = await menu.findByRole("menuitem", { name: "Calc" });
52
+ await waitFor(() => expect(calc).toBeVisible());
53
+ await waitFor(() => expect(menu.getByRole("menuitem", { name: "Iterate" })).toBeVisible());
54
+ await waitFor(() => expect(menu.getByRole("menuitem", { name: "Pivot" })).toBeVisible());
55
+ },
56
+ };
@@ -0,0 +1,263 @@
1
+ "use client";
2
+
3
+ /**
4
+ * MarkdownToolbar — formatting chrome for the markdown SOURCE pane, composed
5
+ * entirely from @elabs-ai/components-ui (Button, Tooltip, Separator, DropdownMenu). Actions run
6
+ * against a Monaco editor instance via the pure commands in markdown-commands.ts.
7
+ * Buttons disable when no editor is mounted.
8
+ */
9
+ import {
10
+ Button,
11
+ DropdownMenu,
12
+ DropdownMenuContent,
13
+ DropdownMenuItem,
14
+ DropdownMenuLabel,
15
+ DropdownMenuSeparator,
16
+ DropdownMenuTrigger,
17
+ Separator,
18
+ Tooltip,
19
+ TooltipContent,
20
+ TooltipProvider,
21
+ TooltipTrigger,
22
+ } from "@elabs-ai/components-ui";
23
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
24
+ import {
25
+ Bold,
26
+ ChevronDown,
27
+ Code2,
28
+ Heading,
29
+ Italic,
30
+ Link2,
31
+ List,
32
+ ListOrdered,
33
+ Minus,
34
+ Quote,
35
+ SquarePlus,
36
+ } from "lucide-react";
37
+ import { forwardRef, Fragment, type HTMLAttributes, type ReactNode } from "react";
38
+
39
+ import type { MonacoCodeEditor } from "../code-editor";
40
+ import { groupSlashCommands, type SlashCommand } from "../markdown-editor/slash";
41
+ import {
42
+ insertDirective,
43
+ insertHorizontalRule,
44
+ insertLink,
45
+ toggleLinePrefix,
46
+ wrapSelection,
47
+ } from "./markdown-commands";
48
+
49
+ export interface MarkdownToolbarProps extends HTMLAttributes<HTMLDivElement> {
50
+ /** The Monaco editor instance to act on (from CodeEditor ref/onMount). */
51
+ editor: MonacoCodeEditor | null;
52
+ /** Extra controls rendered on the right (e.g. a mode switch). */
53
+ actions?: ReactNode;
54
+ /**
55
+ * Drives the **Insert** menu. When set, the menu lists every command that
56
+ * carries a `snippet` (grouped by `group`), inserting that markdown at the
57
+ * caret — so the source / split pane reaches the SAME blocks as the WYSIWYG
58
+ * slash menu (`/calc`, `/iterate`, `/pivot`, plus any consumer commands).
59
+ * When omitted, the menu falls back to the four built-in directive snippets.
60
+ * (A4)
61
+ */
62
+ insertCommands?: SlashCommand[];
63
+ }
64
+
65
+ /** A command that actually carries a source-mode snippet. */
66
+ type InsertableCommand = SlashCommand & { snippet: string };
67
+
68
+ const DIRECTIVE_SNIPPETS: { label: string; snippet: string }[] = [
69
+ { label: "Card", snippet: `:::card{title="Title"}\nContent\n:::` },
70
+ { label: "Callout", snippet: `:::callout{type="info" title="Note"}\nMessage\n:::` },
71
+ { label: "Metric", snippet: `::metric{label="Label" value="0" description="detail"}` },
72
+ {
73
+ label: "Timeline",
74
+ snippet: `:::timeline\n- (done) Step one\n- (active) Step two\n- (pending) Step three\n:::`,
75
+ },
76
+ ];
77
+
78
+ export const MarkdownToolbar = forwardRef<HTMLDivElement, MarkdownToolbarProps>(
79
+ function MarkdownToolbar({ editor, actions, insertCommands, className, ...props }, ref) {
80
+ const disabled = !editor;
81
+ const run = (fn: (e: MonacoCodeEditor) => void) => () => {
82
+ if (editor) fn(editor);
83
+ };
84
+
85
+ // The Insert menu is driven by the slash registry when provided (A4): only
86
+ // commands that carry a source-mode `snippet`, grouped by `group`.
87
+ const insertGroups = insertCommands
88
+ ? groupSlashCommands(
89
+ insertCommands.filter((c): c is InsertableCommand => typeof c.snippet === "string"),
90
+ )
91
+ : null;
92
+
93
+ const IconButton = ({
94
+ label,
95
+ icon,
96
+ onClick,
97
+ }: {
98
+ label: string;
99
+ icon: ReactNode;
100
+ onClick: () => void;
101
+ }) => (
102
+ <Tooltip>
103
+ <TooltipTrigger asChild>
104
+ <Button
105
+ type="button"
106
+ variant="ghost"
107
+ size="icon-sm"
108
+ disabled={disabled}
109
+ onClick={onClick}
110
+ aria-label={label}
111
+ >
112
+ {icon}
113
+ </Button>
114
+ </TooltipTrigger>
115
+ <TooltipContent>{label}</TooltipContent>
116
+ </Tooltip>
117
+ );
118
+
119
+ return (
120
+ <TooltipProvider delayDuration={300}>
121
+ <div
122
+ ref={ref}
123
+ role="toolbar"
124
+ aria-label="Markdown formatting"
125
+ className={cn(
126
+ "flex h-10 shrink-0 items-center gap-0.5 border-b border-border bg-surface px-2",
127
+ className,
128
+ )}
129
+ {...props}
130
+ >
131
+ <IconButton
132
+ label="Bold"
133
+ icon={<Bold className="size-4" />}
134
+ onClick={run((e) => wrapSelection(e, "**"))}
135
+ />
136
+ <IconButton
137
+ label="Italic"
138
+ icon={<Italic className="size-4" />}
139
+ onClick={run((e) => wrapSelection(e, "*"))}
140
+ />
141
+ <IconButton
142
+ label="Inline code"
143
+ icon={<Code2 className="size-4" />}
144
+ onClick={run((e) => wrapSelection(e, "`"))}
145
+ />
146
+ <IconButton label="Link" icon={<Link2 className="size-4" />} onClick={run(insertLink)} />
147
+
148
+ <Separator orientation="vertical" className="mx-1 h-5" />
149
+
150
+ <DropdownMenu>
151
+ <Tooltip>
152
+ <TooltipTrigger asChild>
153
+ <DropdownMenuTrigger asChild>
154
+ <Button
155
+ type="button"
156
+ variant="ghost"
157
+ size="sm"
158
+ disabled={disabled}
159
+ className="gap-1"
160
+ aria-label="Heading level"
161
+ >
162
+ <Heading className="size-4" />
163
+ <ChevronDown className="size-3" />
164
+ </Button>
165
+ </DropdownMenuTrigger>
166
+ </TooltipTrigger>
167
+ <TooltipContent>Heading</TooltipContent>
168
+ </Tooltip>
169
+ <DropdownMenuContent align="start">
170
+ {([1, 2, 3] as const).map((level) => (
171
+ <DropdownMenuItem
172
+ key={level}
173
+ onSelect={run((e) => toggleLinePrefix(e, `${"#".repeat(level)} `))}
174
+ >
175
+ Heading {level}
176
+ </DropdownMenuItem>
177
+ ))}
178
+ </DropdownMenuContent>
179
+ </DropdownMenu>
180
+
181
+ <IconButton
182
+ label="Quote"
183
+ icon={<Quote className="size-4" />}
184
+ onClick={run((e) => toggleLinePrefix(e, "> "))}
185
+ />
186
+ <IconButton
187
+ label="Bullet list"
188
+ icon={<List className="size-4" />}
189
+ onClick={run((e) => toggleLinePrefix(e, "- "))}
190
+ />
191
+ <IconButton
192
+ label="Numbered list"
193
+ icon={<ListOrdered className="size-4" />}
194
+ onClick={run((e) => toggleLinePrefix(e, "1. "))}
195
+ />
196
+ <IconButton
197
+ label="Divider"
198
+ icon={<Minus className="size-4" />}
199
+ onClick={run(insertHorizontalRule)}
200
+ />
201
+
202
+ <Separator orientation="vertical" className="mx-1 h-5" />
203
+
204
+ <DropdownMenu>
205
+ <Tooltip>
206
+ <TooltipTrigger asChild>
207
+ <DropdownMenuTrigger asChild>
208
+ <Button
209
+ type="button"
210
+ variant="ghost"
211
+ size="sm"
212
+ disabled={disabled}
213
+ className="gap-1"
214
+ aria-label="Insert block"
215
+ >
216
+ <SquarePlus className="size-4" />
217
+ <span className="text-xs">Insert</span>
218
+ </Button>
219
+ </DropdownMenuTrigger>
220
+ </TooltipTrigger>
221
+ <TooltipContent>Insert brand block</TooltipContent>
222
+ </Tooltip>
223
+ <DropdownMenuContent align="start">
224
+ {insertGroups && insertGroups.length > 0
225
+ ? insertGroups.map(({ group, commands }, gi) => (
226
+ <Fragment key={group}>
227
+ {gi > 0 ? <DropdownMenuSeparator /> : null}
228
+ <DropdownMenuLabel className="text-meta font-medium text-muted-foreground">
229
+ {group}
230
+ </DropdownMenuLabel>
231
+ {(commands as InsertableCommand[]).map((cmd) => (
232
+ <DropdownMenuItem
233
+ key={cmd.id}
234
+ className="gap-2"
235
+ onSelect={run((e) => insertDirective(e, cmd.snippet))}
236
+ >
237
+ {cmd.icon ? (
238
+ <span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4">
239
+ {cmd.icon}
240
+ </span>
241
+ ) : null}
242
+ {cmd.label}
243
+ </DropdownMenuItem>
244
+ ))}
245
+ </Fragment>
246
+ ))
247
+ : DIRECTIVE_SNIPPETS.map(({ label, snippet }) => (
248
+ <DropdownMenuItem
249
+ key={label}
250
+ onSelect={run((e) => insertDirective(e, snippet))}
251
+ >
252
+ {label}
253
+ </DropdownMenuItem>
254
+ ))}
255
+ </DropdownMenuContent>
256
+ </DropdownMenu>
257
+
258
+ {actions ? <div className="ml-auto flex items-center gap-1.5">{actions}</div> : null}
259
+ </div>
260
+ </TooltipProvider>
261
+ );
262
+ },
263
+ );
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { topLevelBlockOf, typewriterDelta } from "./focus-writing";
4
+
5
+ describe("topLevelBlockOf", () => {
6
+ const build = () => {
7
+ const root = document.createElement("div");
8
+ root.innerHTML = `<p id="a">one</p><ul id="list"><li id="li">two <strong id="deep">deep</strong></li></ul>`;
9
+ return root;
10
+ };
11
+
12
+ it("returns the node itself when it is a direct child", () => {
13
+ const root = build();
14
+ const a = root.querySelector("#a")!;
15
+ expect(topLevelBlockOf(root, a)).toBe(a);
16
+ });
17
+
18
+ it("walks nested nodes up to the top-level block", () => {
19
+ const root = build();
20
+ const deep = root.querySelector("#deep")!.firstChild!; // the text node
21
+ expect(topLevelBlockOf(root, deep)).toBe(root.querySelector("#list"));
22
+ });
23
+
24
+ it("returns null for nodes outside the root (and for null)", () => {
25
+ const root = build();
26
+ const stranger = document.createElement("p");
27
+ expect(topLevelBlockOf(root, stranger)).toBeNull();
28
+ expect(topLevelBlockOf(root, null)).toBeNull();
29
+ });
30
+ });
31
+
32
+ describe("typewriterDelta", () => {
33
+ // Host: top 100, height 600 → center 400; band 0.22 → tolerance ±66.
34
+ it("returns 0 while the caret sits inside the center band", () => {
35
+ expect(typewriterDelta(390, 20, 100, 600)).toBe(0); // mid 400 = center
36
+ expect(typewriterDelta(440, 20, 100, 600)).toBe(0); // mid 450, off 50 < 66
37
+ });
38
+
39
+ it("returns a positive delta when the caret drifts below center", () => {
40
+ const d = typewriterDelta(580, 20, 100, 600); // mid 590, off 190
41
+ expect(d).toBe(190);
42
+ });
43
+
44
+ it("returns a negative delta when the caret drifts above center", () => {
45
+ const d = typewriterDelta(120, 20, 100, 600); // mid 130, off -270
46
+ expect(d).toBe(-270);
47
+ });
48
+
49
+ it("is inert for an unmeasured host", () => {
50
+ expect(typewriterDelta(0, 0, 0, 0)).toBe(0);
51
+ });
52
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Focus-writing helpers (Ulysses Phase A) — the pure half of the workspace's
3
+ * "Focus" toggle: paragraph focus (mark the top-level block that owns the
4
+ * selection) and typewriter scrolling (keep the caret in a vertical band
5
+ * around the scroller's center). Engine-agnostic: plain DOM walking, no
6
+ * ProseMirror plugin surface — testable in jsdom without booting Milkdown.
7
+ */
8
+
9
+ /** The editor-root CHILD that contains `node` (the active top-level block). */
10
+ export function topLevelBlockOf(editorRoot: Element, node: Node | null): Element | null {
11
+ let current: Node | null = node;
12
+ while (current && current.parentNode !== editorRoot) {
13
+ current = current.parentNode;
14
+ }
15
+ return current instanceof Element ? current : null;
16
+ }
17
+
18
+ /**
19
+ * Typewriter scroll adjustment: how far the scroller must move so the caret
20
+ * sits back in the center band. Returns 0 while the caret is inside the band
21
+ * (no jitter on every keystroke — only re-center when it drifts out).
22
+ *
23
+ * @param band fraction of the scroller height treated as "centered enough".
24
+ */
25
+ export function typewriterDelta(
26
+ caretTop: number,
27
+ caretHeight: number,
28
+ hostTop: number,
29
+ hostHeight: number,
30
+ band = 0.22,
31
+ ): number {
32
+ if (hostHeight <= 0) return 0;
33
+ const center = hostTop + hostHeight / 2;
34
+ const caretMid = caretTop + caretHeight / 2;
35
+ const tolerance = (hostHeight * band) / 2;
36
+ const off = caretMid - center;
37
+ return Math.abs(off) <= tolerance ? 0 : off;
38
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ MarkdownWorkspace,
3
+ type MarkdownWorkspaceHandle,
4
+ type MarkdownWorkspaceProps,
5
+ type MarkdownWorkspaceMode,
6
+ } from "./markdown-workspace";