@elabs-ai/components-editor 5.1.0 → 5.3.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/dist/{chunk-FO5S3YZM.js → chunk-VTGYYWGH.js} +21 -4
- package/dist/chunk-VTGYYWGH.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/markdown/index.js +1 -1
- package/package.json +7 -5
- package/src/markdown-editor/directive-nodes.ts +30 -3
- package/src/markdown-editor/markdown-editor.directives.test.tsx +12 -0
- package/src/markdown-editor/markdown-editor.insert.test.tsx +37 -0
- package/src/markdown-editor/markdown-editor.tsx +9 -3
- package/dist/chunk-FO5S3YZM.js.map +0 -1
package/dist/index.js
CHANGED
package/dist/markdown/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elabs-ai/components-editor",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.3.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -55,6 +55,8 @@
|
|
|
55
55
|
"katex": "^0.16.11",
|
|
56
56
|
"lucide-react": "^0.577.0",
|
|
57
57
|
"mermaid": "^11.15.0",
|
|
58
|
+
"mdast-util-directive": "^3.1.0",
|
|
59
|
+
"micromark-extension-directive": "^4.0.0",
|
|
58
60
|
"remark-directive": "^4.0.0",
|
|
59
61
|
"remark-frontmatter": "^5.0.0",
|
|
60
62
|
"remark-gfm": "^4.0.1",
|
|
@@ -69,8 +71,8 @@
|
|
|
69
71
|
"monaco-editor": "^0.55.1",
|
|
70
72
|
"react": "^18.2.0 || ^19.0.0",
|
|
71
73
|
"react-dom": "^18.2.0 || ^19.0.0",
|
|
72
|
-
"@elabs-ai/components-tokens": "^5.
|
|
73
|
-
"@elabs-ai/components-ui": "^5.
|
|
74
|
+
"@elabs-ai/components-tokens": "^5.3.0",
|
|
75
|
+
"@elabs-ai/components-ui": "^5.3.0"
|
|
74
76
|
},
|
|
75
77
|
"devDependencies": {
|
|
76
78
|
"@testing-library/jest-dom": "^6.6.3",
|
|
@@ -90,9 +92,9 @@
|
|
|
90
92
|
"typescript": "^5.7.3",
|
|
91
93
|
"vitest": "^3.0.2",
|
|
92
94
|
"@elabs-ai/components-eslint-config": "0.1.0",
|
|
93
|
-
"@elabs-ai/components-tokens": "5.
|
|
95
|
+
"@elabs-ai/components-tokens": "5.3.0",
|
|
94
96
|
"@elabs-ai/components-typescript-config": "0.1.0",
|
|
95
|
-
"@elabs-ai/components-ui": "5.
|
|
97
|
+
"@elabs-ai/components-ui": "5.3.0"
|
|
96
98
|
},
|
|
97
99
|
"scripts": {
|
|
98
100
|
"build": "rm -rf dist && tsup && node ../../scripts/link-dist-css.mjs",
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import type { MilkdownPlugin } from "@milkdown/kit/ctx";
|
|
17
17
|
import { $nodeSchema, $remark } from "@milkdown/kit/utils";
|
|
18
|
-
import
|
|
18
|
+
import { directiveFromMarkdown, directiveToMarkdown } from "mdast-util-directive";
|
|
19
|
+
import { directive } from "micromark-extension-directive";
|
|
20
|
+
import type { Processor } from "unified";
|
|
19
21
|
|
|
20
22
|
interface DirectiveMdast {
|
|
21
23
|
type: string;
|
|
@@ -24,8 +26,33 @@ interface DirectiveMdast {
|
|
|
24
26
|
children?: unknown[];
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
+
/**
|
|
30
|
+
* remark-directive, BLOCK forms only (`::leaf`, `:::container`).
|
|
31
|
+
*
|
|
32
|
+
* The stock plugin also parses every inline `:word` as a TEXT directive — a
|
|
33
|
+
* `${{msr:id:Title}}` placeholder, `ratio :a`, an emoji shortcode — which the brand has
|
|
34
|
+
* no vocabulary for and which crashed the editor ("Cannot match target parser"); its
|
|
35
|
+
* serializer then escaped every `word:Word` as `word\:Word`. This variant drops the
|
|
36
|
+
* inline tokenizer and the matching escape rule, so inline colons stay ordinary text
|
|
37
|
+
* both ways.
|
|
38
|
+
*/
|
|
39
|
+
function remarkBlockDirectives(this: Processor): void {
|
|
40
|
+
const data = this.data() as Record<string, unknown[] | undefined>;
|
|
41
|
+
const micromark = (data.micromarkExtensions ??= []);
|
|
42
|
+
const from = (data.fromMarkdownExtensions ??= []);
|
|
43
|
+
const to = (data.toMarkdownExtensions ??= []);
|
|
44
|
+
const { flow } = directive();
|
|
45
|
+
micromark.push({ flow });
|
|
46
|
+
from.push(directiveFromMarkdown());
|
|
47
|
+
const serializer = directiveToMarkdown();
|
|
48
|
+
serializer.unsafe = (serializer.unsafe ?? []).filter(
|
|
49
|
+
(rule) => !(rule.character === ":" && rule.inConstruct !== undefined && !rule.atBreak),
|
|
50
|
+
);
|
|
51
|
+
to.push(serializer);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Add block directives to Milkdown's unified processor (parse + stringify). */
|
|
55
|
+
export const directiveRemark = $remark("brandDirective", () => remarkBlockDirectives);
|
|
29
56
|
|
|
30
57
|
/** `:::name` block directives → an editable container node with branded chrome. */
|
|
31
58
|
export const containerDirectiveSchema = $nodeSchema("brand_container_directive", () => ({
|
|
@@ -103,3 +103,15 @@ test("#37 — an unknown container directive is role=note, not an assertive aler
|
|
|
103
103
|
// It must NOT be an assertive live region (role=alert) that re-announces on re-parse.
|
|
104
104
|
expect(screen.queryByRole("alert")).toBeNull();
|
|
105
105
|
});
|
|
106
|
+
|
|
107
|
+
test("inline `word:Word` (e.g. `${{msr:id:Title}}` placeholders) stays plain text, unescaped, both ways", async () => {
|
|
108
|
+
const ref = createRef<MarkdownEditorHandle>();
|
|
109
|
+
const doc = "Revenue: ${{msr:rev-ytd:Revenue YTD}} and a ratio :a\n";
|
|
110
|
+
const { container } = render(<MarkdownEditor ref={ref} defaultValue={doc} />);
|
|
111
|
+
await waitFor(() => expect(container.textContent).toContain("${{msr:rev-ytd:Revenue YTD}}"));
|
|
112
|
+
const md = ref.current!.getMarkdown();
|
|
113
|
+
expect(md).toContain(":rev-ytd");
|
|
114
|
+
expect(md).toContain(":Revenue");
|
|
115
|
+
expect(md).toContain("ratio :a");
|
|
116
|
+
expect(md).not.toContain("\\:");
|
|
117
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createRef } from "react";
|
|
2
|
+
import { cleanup, render, 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
|
+
async function mount(doc: string) {
|
|
10
|
+
const ref = createRef<MarkdownEditorHandle>();
|
|
11
|
+
const { container } = render(<MarkdownEditor ref={ref} defaultValue={doc} />);
|
|
12
|
+
await waitFor(() =>
|
|
13
|
+
expect(container.querySelector(".ProseMirror")?.textContent).toContain("Hello"),
|
|
14
|
+
);
|
|
15
|
+
return ref;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test("insertAtCursor lands a one-line fragment INLINE, not as a new block", async () => {
|
|
19
|
+
const ref = await mount("Hello\n");
|
|
20
|
+
ref.current!.focus();
|
|
21
|
+
ref.current!.insertAtCursor("${{variables.target}} **bold**");
|
|
22
|
+
await waitFor(() =>
|
|
23
|
+
expect(ref.current!.getMarkdown()).toContain("${{variables.target}} **bold**Hello"),
|
|
24
|
+
);
|
|
25
|
+
// One paragraph — the fragment joined the existing line.
|
|
26
|
+
const md = ref.current!.getMarkdown().trim();
|
|
27
|
+
expect(md.split(/\n\s*\n/)).toHaveLength(1);
|
|
28
|
+
expect(md).toContain("Hello");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("insertAtCursor keeps multi-block markdown as blocks", async () => {
|
|
32
|
+
const ref = await mount("Hello\n");
|
|
33
|
+
ref.current!.focus();
|
|
34
|
+
ref.current!.insertAtCursor("# Title\n\nBody");
|
|
35
|
+
await waitFor(() => expect(ref.current!.getMarkdown()).toContain("# Title"));
|
|
36
|
+
expect(ref.current!.getMarkdown()).toContain("Body");
|
|
37
|
+
});
|
|
@@ -33,7 +33,7 @@ import { commonmark } from "@milkdown/kit/preset/commonmark";
|
|
|
33
33
|
import { gfm } from "@milkdown/kit/preset/gfm";
|
|
34
34
|
import { history } from "@milkdown/kit/plugin/history";
|
|
35
35
|
import { listener, listenerCtx } from "@milkdown/kit/plugin/listener";
|
|
36
|
-
import type
|
|
36
|
+
import { Slice, type Node as ProseNode } from "@milkdown/kit/prose/model";
|
|
37
37
|
import { TextSelection } from "@milkdown/kit/prose/state";
|
|
38
38
|
import type { EditorView } from "@milkdown/kit/prose/view";
|
|
39
39
|
import { getMarkdown, replaceAll } from "@milkdown/kit/utils";
|
|
@@ -430,8 +430,14 @@ const MarkdownEditorView = forwardRef<MarkdownEditorHandle, ViewProps>(function
|
|
|
430
430
|
view.dispatch(view.state.tr.insertText(md));
|
|
431
431
|
return;
|
|
432
432
|
}
|
|
433
|
-
//
|
|
434
|
-
|
|
433
|
+
// A single paragraph lands INLINE at the caret (a `**bold**` fragment, a
|
|
434
|
+
// `${{placeholder}}` from an insert rail); anything else replaces the
|
|
435
|
+
// selection as blocks.
|
|
436
|
+
const only = parsed.childCount === 1 ? parsed.firstChild : null;
|
|
437
|
+
const tr =
|
|
438
|
+
only && only.type.name === "paragraph"
|
|
439
|
+
? view.state.tr.replaceSelection(new Slice(only.content, 0, 0))
|
|
440
|
+
: view.state.tr.replaceSelectionWith(parsed);
|
|
435
441
|
view.dispatch(tr);
|
|
436
442
|
});
|
|
437
443
|
} catch {
|