@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.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/dist/chunk-LBC5VJBD.js +3748 -0
- package/dist/chunk-LBC5VJBD.js.map +1 -0
- package/dist/index.css +233 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +199 -0
- package/dist/index.js +362 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/monaco-environment.d.ts +2 -0
- package/dist/lib/monaco-environment.js +44 -0
- package/dist/lib/monaco-environment.js.map +1 -0
- package/dist/markdown/frontmatter.d.ts +11 -0
- package/dist/markdown/frontmatter.js +33 -0
- package/dist/markdown/frontmatter.js.map +1 -0
- package/dist/markdown/index.css +233 -0
- package/dist/markdown/index.css.map +1 -0
- package/dist/markdown/index.d.ts +1486 -0
- package/dist/markdown/index.js +4882 -0
- package/dist/markdown/index.js.map +1 -0
- package/dist/markdown/parse.d.ts +25 -0
- package/dist/markdown/parse.js +14 -0
- package/dist/markdown/parse.js.map +1 -0
- package/dist/markdown-editor-DfBZibAn.d.ts +702 -0
- package/package.json +101 -0
- package/src/ai-objects/ai-objects-integration.test.tsx +128 -0
- package/src/ai-objects/ai-objects.stories.tsx +233 -0
- package/src/ai-objects/ai-objects.test.tsx +205 -0
- package/src/ai-objects/decision-card.tsx +178 -0
- package/src/ai-objects/directives.ts +136 -0
- package/src/ai-objects/entity.tsx +181 -0
- package/src/ai-objects/index.ts +34 -0
- package/src/ai-objects/knowledge-card.tsx +143 -0
- package/src/calc-block/calc-block.stories.tsx +297 -0
- package/src/calc-block/calc-block.test.tsx +164 -0
- package/src/calc-block/calc-block.tsx +398 -0
- package/src/calc-block/calc-editor-monaco.ts +214 -0
- package/src/calc-block/calc-editor-prose.ts +128 -0
- package/src/calc-block/calc-editor.css +80 -0
- package/src/calc-block/calc-editor.stories.tsx +272 -0
- package/src/calc-block/calc-editor.test.ts +228 -0
- package/src/calc-block/calc-editor.ts +270 -0
- package/src/calc-block/calc-inline.stories.tsx +44 -0
- package/src/calc-block/calc-inline.test.tsx +44 -0
- package/src/calc-block/calc-inline.tsx +75 -0
- package/src/calc-block/index.ts +20 -0
- package/src/calc-block/types.ts +164 -0
- package/src/code-editor/code-editor.stories.tsx +218 -0
- package/src/code-editor/code-editor.test.tsx +134 -0
- package/src/code-editor/code-editor.tsx +250 -0
- package/src/code-editor/index.ts +6 -0
- package/src/code-workspace/code-workspace.stories.tsx +51 -0
- package/src/code-workspace/code-workspace.test.tsx +146 -0
- package/src/code-workspace/code-workspace.tsx +253 -0
- package/src/code-workspace/index.ts +6 -0
- package/src/copy-button/copy-button.tsx +51 -0
- package/src/copy-button/index.ts +1 -0
- package/src/css.d.ts +3 -0
- package/src/diff-editor/diff-editor.stories.tsx +44 -0
- package/src/diff-editor/diff-editor.test.tsx +68 -0
- package/src/diff-editor/diff-editor.tsx +151 -0
- package/src/diff-editor/index.ts +1 -0
- package/src/editor-context-menu/editor-context-menu.tsx +120 -0
- package/src/editor-context-menu/index.ts +1 -0
- package/src/editor-toolbar/editor-toolbar.tsx +83 -0
- package/src/editor-toolbar/index.ts +1 -0
- package/src/index.ts +48 -0
- package/src/lib/editor-completions-monaco.test.ts +206 -0
- package/src/lib/editor-completions-monaco.ts +127 -0
- package/src/lib/editor-completions.test.ts +156 -0
- package/src/lib/editor-completions.ts +153 -0
- package/src/lib/editor-content-access-prose.test.ts +411 -0
- package/src/lib/editor-content-access-prose.ts +172 -0
- package/src/lib/editor-content-access.test.ts +182 -0
- package/src/lib/editor-content-access.ts +109 -0
- package/src/lib/languages.ts +32 -0
- package/src/lib/markdown/diff.test.ts +80 -0
- package/src/lib/markdown/diff.ts +194 -0
- package/src/lib/markdown/directives.ts +342 -0
- package/src/lib/markdown/frontmatter.ts +50 -0
- package/src/lib/markdown/markdown-scale.test.ts +65 -0
- package/src/lib/markdown/markdown-scale.ts +62 -0
- package/src/lib/markdown/merge.test.ts +64 -0
- package/src/lib/markdown/merge.ts +158 -0
- package/src/lib/markdown/slugify.test.ts +156 -0
- package/src/lib/markdown/slugify.ts +47 -0
- package/src/lib/monaco-environment.ts +103 -0
- package/src/lib/monaco-theme-bridge.ts +293 -0
- package/src/lib/use-data-theme.ts +61 -0
- package/src/markdown/frontmatter.ts +14 -0
- package/src/markdown/index.ts +323 -0
- package/src/markdown/parse.test.ts +60 -0
- package/src/markdown/parse.ts +38 -0
- package/src/markdown-academic/citations.test.ts +77 -0
- package/src/markdown-academic/citations.tsx +442 -0
- package/src/markdown-academic/footnotes.tsx +235 -0
- package/src/markdown-academic/index.ts +32 -0
- package/src/markdown-academic/math.tsx +163 -0
- package/src/markdown-academic/toc.tsx +88 -0
- package/src/markdown-editor/completions/completions-menu.tsx +96 -0
- package/src/markdown-editor/completions/completions-prose.test.ts +356 -0
- package/src/markdown-editor/completions/completions-prose.ts +313 -0
- package/src/markdown-editor/completions/completions-widget.tsx +75 -0
- package/src/markdown-editor/completions/index.ts +37 -0
- package/src/markdown-editor/directive-nodes.ts +151 -0
- package/src/markdown-editor/directive-views.test.tsx +189 -0
- package/src/markdown-editor/directive-views.tsx +795 -0
- package/src/markdown-editor/exit-keymap.test.ts +88 -0
- package/src/markdown-editor/exit-keymap.ts +87 -0
- package/src/markdown-editor/index.ts +6 -0
- package/src/markdown-editor/markdown-editor.css +250 -0
- package/src/markdown-editor/markdown-editor.directives.test.tsx +105 -0
- package/src/markdown-editor/markdown-editor.fill.test.ts +34 -0
- package/src/markdown-editor/markdown-editor.paste-embed.test.tsx +297 -0
- package/src/markdown-editor/markdown-editor.stories.tsx +560 -0
- package/src/markdown-editor/markdown-editor.strictmode.test.tsx +38 -0
- package/src/markdown-editor/markdown-editor.table.test.tsx +287 -0
- package/src/markdown-editor/markdown-editor.tsx +560 -0
- package/src/markdown-editor/milkdown-react/editor.tsx +33 -0
- package/src/markdown-editor/milkdown-react/index.ts +8 -0
- package/src/markdown-editor/milkdown-react/types.ts +27 -0
- package/src/markdown-editor/milkdown-react/use-editor.ts +27 -0
- package/src/markdown-editor/milkdown-react/use-get-editor.ts +56 -0
- package/src/markdown-editor/milkdown-react/use-instance.ts +23 -0
- package/src/markdown-editor/paste-embed.ts +355 -0
- package/src/markdown-editor/slash/brand-slash-commands.test.ts +194 -0
- package/src/markdown-editor/slash/brand-slash-commands.ts +324 -0
- package/src/markdown-editor/slash/brand-slash-plugin.test.ts +254 -0
- package/src/markdown-editor/slash/brand-slash-plugin.ts +385 -0
- package/src/markdown-editor/slash/index.ts +107 -0
- package/src/markdown-editor/slash/insert-directive.test.ts +263 -0
- package/src/markdown-editor/slash/insert-directive.ts +345 -0
- package/src/markdown-editor/slash/monaco-slash-menu.test.tsx +198 -0
- package/src/markdown-editor/slash/monaco-slash-menu.tsx +356 -0
- package/src/markdown-editor/slash/shortcut-monaco.ts +69 -0
- package/src/markdown-editor/slash/shortcut.test.ts +157 -0
- package/src/markdown-editor/slash/shortcut.ts +57 -0
- package/src/markdown-editor/slash/slash-menu.stories.tsx +139 -0
- package/src/markdown-editor/slash/slash-menu.tsx +115 -0
- package/src/markdown-editor/slash/slash-scroll.test.tsx +52 -0
- package/src/markdown-editor/slash/slash-widget.tsx +97 -0
- package/src/markdown-editor/slash/source-slash-trigger.test.ts +42 -0
- package/src/markdown-editor/slash/source-slash-trigger.ts +46 -0
- package/src/markdown-editor/table-view.tsx +249 -0
- package/src/markdown-iteration/directive.tsx +92 -0
- package/src/markdown-iteration/edit-context.ts +56 -0
- package/src/markdown-iteration/index.ts +24 -0
- package/src/markdown-iteration/iteration-block.test.tsx +138 -0
- package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +293 -0
- package/src/markdown-iteration/iteration-builder-dialog.tsx +312 -0
- package/src/markdown-iteration/iteration-builder.test.ts +252 -0
- package/src/markdown-iteration/iteration-builder.ts +303 -0
- package/src/markdown-iteration/iteration.test.ts +56 -0
- package/src/markdown-iteration/iteration.tsx +333 -0
- package/src/markdown-iteration/template-dialog.stories.tsx +63 -0
- package/src/markdown-iteration/template-dialog.test.tsx +104 -0
- package/src/markdown-iteration/template-dialog.tsx +116 -0
- package/src/markdown-outline/document-outline.stories.tsx +76 -0
- package/src/markdown-outline/document-outline.tsx +83 -0
- package/src/markdown-outline/index.ts +2 -0
- package/src/markdown-outline/markdown-outline.test.ts +49 -0
- package/src/markdown-outline/markdown-outline.ts +60 -0
- package/src/markdown-preview/code-fence.tsx +193 -0
- package/src/markdown-preview/index.ts +8 -0
- package/src/markdown-preview/markdown-preview-academic.stories.tsx +203 -0
- package/src/markdown-preview/markdown-preview-academic.test.tsx +257 -0
- package/src/markdown-preview/markdown-preview-iteration-calc.test.tsx +71 -0
- package/src/markdown-preview/markdown-preview-iteration-nested.test.tsx +58 -0
- package/src/markdown-preview/markdown-preview-iteration.stories.tsx +187 -0
- package/src/markdown-preview/markdown-preview-iteration.test.tsx +90 -0
- package/src/markdown-preview/markdown-preview-linking.test.tsx +61 -0
- package/src/markdown-preview/markdown-preview-transclusion.test.tsx +33 -0
- package/src/markdown-preview/markdown-preview.stories.tsx +108 -0
- package/src/markdown-preview/markdown-preview.test.tsx +411 -0
- package/src/markdown-preview/markdown-preview.tsx +1541 -0
- package/src/markdown-toolbar/index.ts +8 -0
- package/src/markdown-toolbar/markdown-commands.test.ts +84 -0
- package/src/markdown-toolbar/markdown-commands.ts +104 -0
- package/src/markdown-toolbar/markdown-toolbar.stories.tsx +56 -0
- package/src/markdown-toolbar/markdown-toolbar.tsx +263 -0
- package/src/markdown-workspace/focus-writing.test.ts +52 -0
- package/src/markdown-workspace/focus-writing.ts +38 -0
- package/src/markdown-workspace/index.ts +6 -0
- package/src/markdown-workspace/markdown-workspace.stories.tsx +688 -0
- package/src/markdown-workspace/markdown-workspace.test.tsx +326 -0
- package/src/markdown-workspace/markdown-workspace.tsx +787 -0
- package/src/mermaid-diagram/index.ts +1 -0
- package/src/mermaid-diagram/mermaid-diagram.stories.tsx +42 -0
- package/src/mermaid-diagram/mermaid-diagram.test.tsx +86 -0
- package/src/mermaid-diagram/mermaid-diagram.tsx +318 -0
- package/src/mermaid-diagram/mermaid-viewer.test.tsx +89 -0
- package/src/mermaid-diagram/mermaid-viewer.tsx +367 -0
- package/src/mermaid-diagram/real-parse.test.ts +42 -0
- package/src/mermaid-diagram/remediate.test.ts +60 -0
- package/src/mermaid-diagram/remediate.ts +124 -0
- package/src/mermaid-workspace/index.ts +1 -0
- package/src/mermaid-workspace/mermaid-workspace.stories.tsx +28 -0
- package/src/mermaid-workspace/mermaid-workspace.tsx +71 -0
- package/src/metric-block/index.ts +1 -0
- package/src/metric-block/metric-block.stories.tsx +40 -0
- package/src/metric-block/metric-block.tsx +14 -0
- package/src/monaco-workers.d.ts +10 -0
- package/src/prose/index.ts +14 -0
- package/src/prose/prose.stories.tsx +44 -0
- package/src/prose/prose.test.tsx +44 -0
- package/src/prose/prose.tsx +21 -0
- package/src/timeline/index.ts +18 -0
|
@@ -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
|
+
}
|