@surrealdb/ui 1.1.0 → 1.2.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/.zed/settings.json +36 -0
- package/AGENTS.md +10 -5
- package/README.md +30 -0
- package/REVIEW.md +1 -1
- package/dist/assets/0d2c2f665b0f41ed.woff2 +0 -0
- package/dist/assets/12b57c6beacdbca0.woff2 +0 -0
- package/dist/assets/23645aad5ccc2b92.woff2 +0 -0
- package/dist/assets/8bbfa6e01a9e6a0f.woff2 +0 -0
- package/dist/assets/93fc40a807be6880.woff2 +0 -0
- package/dist/assets/9c9751ca111e97c2.woff2 +0 -0
- package/dist/assets/9ff55a8a9670220d.woff2 +0 -0
- package/dist/assets/a865edea076e0166.woff2 +0 -0
- package/dist/assets/b921df26851c5aca.woff2 +0 -0
- package/dist/assets/c6a3f4e555097159.woff2 +0 -0
- package/dist/assets/c6c31cb1350b2544.woff2 +0 -0
- package/dist/fonts.css +1 -0
- package/dist/{yoopta.d.ts → fonts.d.ts} +6 -6
- package/dist/fonts.js +2 -0
- package/dist/fonts.js.map +1 -0
- package/dist/icons.d.ts +33 -6
- package/dist/icons.js +180 -167
- package/dist/icons.js.map +1 -1
- package/dist/ui.css +1 -1
- package/dist/ui.d.ts +570 -531
- package/dist/ui.js +16261 -14582
- package/dist/ui.js.map +1 -1
- package/package.json +22 -24
- package/tests/_setup/e2e-helpers.tsx +169 -0
- package/tests/_setup/markdown-classes.ts +3 -0
- package/tests/_setup/portable-stories.ts +10 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/content-blocks.test.tsx/MarkdownEditor---content-blocks-continues-an-ordered-list-when-Enter-is-pressed-at-the-end-of-a-numbered-line-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/content-blocks.test.tsx/MarkdownEditor---content-blocks-renders-a-diagram-for-the-fenced-mermaid-block-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/content-blocks.test.tsx/MarkdownEditor---content-blocks-renders-a-horizontal-rule-as-a-separator-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/content-blocks.test.tsx/MarkdownEditor---content-blocks-renders-a-read-only-table-preview-when-the-table-block-is-inactive-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/content-blocks.test.tsx/MarkdownEditor---content-blocks-shows-blockquote-text-from-the-sample-document-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/content-blocks.test.tsx/MarkdownEditor---content-blocks-shows-fenced-TypeScript-sample-code-in-the-document-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/edits.test.tsx/MarkdownEditor---edits-auto-continues-a-bullet-list-when-Enter-is-pressed-at-the-end-of-a-list-item-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/edits.test.tsx/MarkdownEditor---edits-expands-triple-backtick-into-a-fenced-code-block-with-the-caret-on-the-empty-body-line-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/edits.test.tsx/MarkdownEditor---edits-reflects-typed-characters-in-the-underlying-document-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/heading-fold.test.tsx/MarkdownEditor---heading-folds-viewer--folds-and-unfolds-a-heading-section-via-the-margin-control-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/hybrid-widgets.test.tsx/MarkdownEditor---hybrid-widgets-clicking-a-callout-header-focuses-the-editor-and-parks-the-caret-inside-the-callout--REASONING--4--1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/hybrid-widgets.test.tsx/MarkdownEditor---hybrid-widgets-keeps-list-bullets-consistent-after-hopping-the-caret-across-items-several-times-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/hybrid-widgets.test.tsx/MarkdownEditor---hybrid-widgets-reveals-heading-marks-when-the-caret-enters-the-heading-line-and-hides-them-when-it-leaves-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/hybrid-widgets.test.tsx/MarkdownEditor---hybrid-widgets-shows-the-right-callout-title-when-moving-focus-between-consecutive-callouts-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/hybrid-widgets.test.tsx/MarkdownEditor---preview-widgets-clicking-a-callout-header-focuses-the-editor-with-the-caret-in-that-callout-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/hybrid-widgets.test.tsx/MarkdownEditor---preview-widgets-keeps-list-bullets-consistent-after-hopping-the-caret-across-items-several-times-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/hybrid-widgets.test.tsx/MarkdownEditor---preview-widgets-shows-the-right-callout-title-when-moving-focus-between-consecutive-callouts-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/jsx-block-content.test.tsx/MarkdownEditor---block-JSX-components-clicking-the-edit-source-action-selects-the-component-source-range-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/jsx-block-content.test.tsx/MarkdownEditor---block-JSX-components-renders-block-components-via-JsxBlockWidget-with-an-edit-source-action-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/jsx-block-content.test.tsx/MarkdownEditor---inline-JSX-rendering-block-content-keeps-multiple-inline-JSX-widgets-on-the-same-line-side-by-side-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/jsx-highlight.test.tsx/MarkdownEditor---JSX-attribute-highlighting-does-not-let-the-nested-HTML-parser-mis-highlight-attributes-after-an-expression-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/jsx-selection.test.tsx/MarkdownEditor---JSX-selection-reveal-shows-JSX-widget-when-inactive-and-raw-source-when-caret-is-inside-the-tag-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/media-sizing.test.tsx/MarkdownEditor---media-sizing-images-and-videos-do-not-exceed-the-editor-content-width-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/media-sizing.test.tsx/MarkdownEditor---media-sizing-matches-MarkdownViewer-image-width-in-the-split-playground-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/modes.test.tsx/MarkdownEditor---modes-standalone-MarkdownViewer-is-not-a-CodeMirror-surface-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/modes.test.tsx/MarkdownEditor---modes-toggling-the-SegmentedControl-swaps-mode-without-remounting-the-editor-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/regressions.test.tsx/MarkdownEditor---regressions-repeated-checkbox-toggles-do-not-duplicate-or-drift-task-markers-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/regressions.test.tsx/MarkdownEditor---regressions-scroll-position-is-preserved-when-the-document-is-edited--chat-92584463--1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/regressions.test.tsx/MarkdownEditor---regressions-task-checkbox-toggles-between-checked-and-unchecked-in-the-source-document-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/regressions.test.tsx/MarkdownEditor---regressions-toggle-via-checkbox-preserves-task-marker-widgets-visible-from-the-previous-caret-position-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/slash-commands.test.tsx/MarkdownEditor---slash-commands-dismisses-on-Escape-and-leaves-the-slash-as-literal-text-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/slash-commands.test.tsx/MarkdownEditor---slash-commands-highlights-the-slash-and-shows-an-Enter-command-placeholder-until-text-is-typed-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/slash-commands.test.tsx/MarkdownEditor---slash-commands-opens-a-filtered--keyboard-navigable-menu-and-inserts-boilerplate-on-Enter-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/undo-redo.test.tsx/MarkdownEditor---undo-and-redo-restores-undone-document-edits-via-redo-1.png +0 -0
- package/tests/e2e/MarkdownEditor/content-blocks.test.tsx +152 -0
- package/tests/e2e/MarkdownEditor/edits.test.tsx +111 -0
- package/tests/e2e/MarkdownEditor/heading-fold.test.tsx +44 -0
- package/tests/e2e/MarkdownEditor/hybrid-widgets.test.tsx +192 -0
- package/tests/e2e/MarkdownEditor/jsx-block-content.test.tsx +242 -0
- package/tests/e2e/MarkdownEditor/jsx-highlight.test.tsx +68 -0
- package/tests/e2e/MarkdownEditor/jsx-inline-badges.test.tsx +59 -0
- package/tests/e2e/MarkdownEditor/jsx-selection.test.tsx +43 -0
- package/tests/e2e/MarkdownEditor/link-placeholder.test.tsx +67 -0
- package/tests/e2e/MarkdownEditor/media-align.test.tsx +57 -0
- package/tests/e2e/MarkdownEditor/media-edit.test.tsx +63 -0
- package/tests/e2e/MarkdownEditor/media-sizing.test.tsx +123 -0
- package/tests/e2e/MarkdownEditor/modes.test.tsx +93 -0
- package/tests/e2e/MarkdownEditor/regressions.test.tsx +182 -0
- package/tests/e2e/MarkdownEditor/slash-commands.test.tsx +99 -0
- package/tests/e2e/MarkdownEditor/table-click.test.tsx +47 -0
- package/tests/e2e/MarkdownEditor/table-controls.test.tsx +56 -0
- package/tests/e2e/MarkdownEditor/table-format.test.tsx +41 -0
- package/tests/e2e/MarkdownEditor/undo-redo.test.tsx +38 -0
- package/tests/e2e/MarkdownViewer/__screenshots__/parity.test.tsx/MarkdownViewer---editor-parity-matches-computed-font-size-on-first-heading-1.png +0 -0
- package/tests/e2e/MarkdownViewer/__screenshots__/parity.test.tsx/MarkdownViewer---editor-parity-matches-counts-for-shared-structural-classes-1.png +0 -0
- package/tests/e2e/MarkdownViewer/__screenshots__/parity.test.tsx/MarkdownViewer---editor-parity-matches-visible-text-between-preview-editor--blurred--and-MarkdownViewer-1.png +0 -0
- package/tests/e2e/MarkdownViewer/__screenshots__/render.test.tsx/MarkdownViewer---render-exercises-shared-preview-class-names-without-mounting-CodeMirror-1.png +0 -0
- package/tests/e2e/MarkdownViewer/parity.test.tsx +190 -0
- package/tests/e2e/MarkdownViewer/render.test.tsx +35 -0
- package/tests/unit/Editor/helpers.test.ts +42 -0
- package/tests/unit/MarkdownEditor/code-info.test.ts +63 -0
- package/tests/unit/MarkdownEditor/decorations.test.ts +488 -0
- package/tests/unit/MarkdownEditor/editor-ready.test.ts +36 -0
- package/tests/unit/MarkdownEditor/html-descriptors.test.ts +94 -0
- package/tests/unit/MarkdownEditor/jsx-attr-scan.test.ts +115 -0
- package/tests/unit/MarkdownEditor/jsx-tag-grammar.test.ts +88 -0
- package/tests/unit/MarkdownEditor/list-indent.test.ts +95 -0
- package/tests/unit/MarkdownEditor/slash-commands.test.ts +213 -0
- package/tests/unit/MarkdownEditor/table-format.test.ts +83 -0
- package/tests/unit/MarkdownEditor/table.test.ts +119 -0
- package/tests/unit/MarkdownEditor/triggers.test.ts +244 -0
- package/tests/unit/MarkdownEditor/widget-store.test.ts +105 -0
- package/tests/unit/MarkdownViewer/code-title.test.tsx +62 -0
- package/tests/unit/MarkdownViewer/features.test.tsx +110 -0
- package/tests/unit/MarkdownViewer/headings.test.tsx +40 -0
- package/tests/unit/MarkdownViewer/jsx.test.tsx +211 -0
- package/tests/unit/MarkdownViewer/list-bullets.test.tsx +49 -0
- package/tests/unit/MarkdownViewer/list-code.test.tsx +65 -0
- package/tests/unit/MarkdownViewer/renderers.test.tsx +79 -0
- package/tests/unit/MarkdownViewer/runnable.test.tsx +69 -0
- package/tests/unit/MarkdownViewer/ssr.test.tsx +93 -0
- package/dist/yoopta.css +0 -1
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildJsxTagHighlights,
|
|
3
|
+
findJsonAttributeClose,
|
|
4
|
+
parseAttributes,
|
|
5
|
+
parseJsonAttribute,
|
|
6
|
+
resolveAttributeValue,
|
|
7
|
+
} from "@src/lib/markdown/tree/html-attrs";
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
|
|
10
|
+
describe("findJsonAttributeClose", () => {
|
|
11
|
+
it("finds the closing brace for nested JSON objects and strings", () => {
|
|
12
|
+
const json = '{"version": "2.0.0", "items": [{"id": 1}]}';
|
|
13
|
+
const full = `{${json}}`;
|
|
14
|
+
const bounds = findJsonAttributeClose(`${full} trailing`, 0);
|
|
15
|
+
expect(bounds).toEqual({
|
|
16
|
+
contentFrom: 1,
|
|
17
|
+
contentTo: full.length - 1,
|
|
18
|
+
closeBrace: full.length - 1,
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("handles braces inside JSON strings", () => {
|
|
23
|
+
const json = '{"note": "}"}';
|
|
24
|
+
const full = `{${json}}`;
|
|
25
|
+
const bounds = findJsonAttributeClose(full, 0);
|
|
26
|
+
expect(bounds?.closeBrace).toBe(full.length - 1);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("parseJsonAttribute", () => {
|
|
31
|
+
it("parses JSON literals without evaluation", () => {
|
|
32
|
+
expect(parseJsonAttribute("42")).toBe(42);
|
|
33
|
+
expect(parseJsonAttribute('"hello"')).toBe("hello");
|
|
34
|
+
expect(parseJsonAttribute('{"enabled": true}')).toEqual({ enabled: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns undefined for invalid JSON", () => {
|
|
38
|
+
expect(parseJsonAttribute("{ foo: 1 }")).toBeUndefined();
|
|
39
|
+
expect(parseJsonAttribute("count")).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("resolveAttributeValue", () => {
|
|
44
|
+
it("resolves JSON attribute values", () => {
|
|
45
|
+
expect(resolveAttributeValue({ type: "json", value: "100" })).toBe(100);
|
|
46
|
+
expect(resolveAttributeValue({ type: "json", value: '"2.0.0"' })).toBe("2.0.0");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("buildJsxTagHighlights", () => {
|
|
51
|
+
const tag = '<Since v={"2.0.0"} t="true" b="false" />';
|
|
52
|
+
|
|
53
|
+
it("covers the attribute section without gaps for the nested HTML parser", () => {
|
|
54
|
+
const highlights = buildJsxTagHighlights(tag);
|
|
55
|
+
const attrStart = tag.indexOf(" v=");
|
|
56
|
+
const attrEnd = tag.lastIndexOf(">");
|
|
57
|
+
|
|
58
|
+
const gaps = leftoverGaps(attrStart, attrEnd, highlights);
|
|
59
|
+
expect(gaps).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("labels attribute names and quoted literals after a JSON value", () => {
|
|
63
|
+
const highlights = buildJsxTagHighlights(tag);
|
|
64
|
+
const names = textAtKinds(tag, highlights, "name");
|
|
65
|
+
const strings = textAtKinds(tag, highlights, "string");
|
|
66
|
+
|
|
67
|
+
expect(names).toEqual(["v", "t", "b"]);
|
|
68
|
+
expect(strings).toEqual(['"true"', '"false"']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("matches parseAttributes JSON boundaries", () => {
|
|
72
|
+
const { endIndex } = readTagNameForTest(tag);
|
|
73
|
+
const attrString = tag.slice(endIndex, tag.lastIndexOf(">"));
|
|
74
|
+
const attrs = parseAttributes(attrString);
|
|
75
|
+
const expr = buildJsxTagHighlights(tag).find((h) => h.kind === "expression-content");
|
|
76
|
+
|
|
77
|
+
expect(attrs.v).toEqual({ type: "json", value: '"2.0.0"' });
|
|
78
|
+
expect(expr).toBeDefined();
|
|
79
|
+
if (!expr) return;
|
|
80
|
+
expect(tag.slice(expr.from, expr.to)).toBe('"2.0.0"');
|
|
81
|
+
expect(attrs.t).toBe("true");
|
|
82
|
+
expect(attrs.b).toBe("false");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
function readTagNameForTest(text: string): { endIndex: number } {
|
|
87
|
+
let i = 1;
|
|
88
|
+
while (i < text.length && !/[\s/>]/.test(text[i] ?? "")) i++;
|
|
89
|
+
return { endIndex: i };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function leftoverGaps(
|
|
93
|
+
from: number,
|
|
94
|
+
to: number,
|
|
95
|
+
spans: { from: number; to: number }[],
|
|
96
|
+
): { from: number; to: number }[] {
|
|
97
|
+
const gaps: { from: number; to: number }[] = [];
|
|
98
|
+
let pos = from;
|
|
99
|
+
|
|
100
|
+
for (const span of spans) {
|
|
101
|
+
if (span.from > pos) gaps.push({ from: pos, to: span.from });
|
|
102
|
+
pos = Math.max(pos, span.to);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (pos < to) gaps.push({ from: pos, to });
|
|
106
|
+
return gaps;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function textAtKinds(
|
|
110
|
+
text: string,
|
|
111
|
+
highlights: ReturnType<typeof buildJsxTagHighlights>,
|
|
112
|
+
kind: "name" | "string",
|
|
113
|
+
): string[] {
|
|
114
|
+
return highlights.filter((h) => h.kind === kind).map((h) => text.slice(h.from, h.to));
|
|
115
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {
|
|
2
|
+
JSX_ATTR_NAME_NODE,
|
|
3
|
+
JSX_ATTR_STRING_NODE,
|
|
4
|
+
JSX_EXPRESSION_CONTENT_NODE,
|
|
5
|
+
JSX_EXPRESSION_MARK_NODE,
|
|
6
|
+
} from "@src/lib/markdown/editor/jsx-tag";
|
|
7
|
+
import { createMarkdownLanguage } from "@src/lib/markdown/editor/language";
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
|
|
10
|
+
function parse(text: string) {
|
|
11
|
+
const support = createMarkdownLanguage({ addKeymap: false, pasteURLAsLink: false });
|
|
12
|
+
return support.language.parser.parse(text);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function nodeNames(text: string): string[] {
|
|
16
|
+
const tree = parse(text);
|
|
17
|
+
const names: string[] = [];
|
|
18
|
+
tree.iterate({
|
|
19
|
+
enter: (n) => {
|
|
20
|
+
names.push(n.name);
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
return names;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function htmlTagSpan(text: string): { from: number; to: number } | null {
|
|
27
|
+
const tree = parse(text);
|
|
28
|
+
let span: { from: number; to: number } | null = null;
|
|
29
|
+
tree.iterate({
|
|
30
|
+
enter: (n) => {
|
|
31
|
+
if (n.name === "HTMLTag") span = { from: n.from, to: n.to };
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
return span;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("JsxTag grammar extension", () => {
|
|
38
|
+
it("keeps plain quoted attributes on the built-in HTMLTag parser", () => {
|
|
39
|
+
expect(nodeNames('<Since v="1" />')).toEqual(["Document", "Paragraph", "HTMLTag"]);
|
|
40
|
+
expect(nodeNames('<Since v="1" />')).not.toContain(JSX_EXPRESSION_CONTENT_NODE);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("parses a full HTMLTag when the attribute value is a JSX expression", () => {
|
|
44
|
+
const text = '<Component prop={"a"} />';
|
|
45
|
+
expect(htmlTagSpan(text)).toEqual({ from: 0, to: text.length });
|
|
46
|
+
expect(nodeNames(text)).toContain(JSX_EXPRESSION_CONTENT_NODE);
|
|
47
|
+
expect(nodeNames(text)).toContain(JSX_EXPRESSION_MARK_NODE);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("does not truncate at > inside a JSX expression", () => {
|
|
51
|
+
const text = "<Component prop={foo > bar} />";
|
|
52
|
+
expect(htmlTagSpan(text)).toEqual({ from: 0, to: text.length });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("parses multiple JSX tags on one line", () => {
|
|
56
|
+
const text = '<Tabs defaultValue={"a"}><TabItem label={x} /></Tabs>';
|
|
57
|
+
const tree = parse(text);
|
|
58
|
+
const tags: string[] = [];
|
|
59
|
+
tree.iterate({
|
|
60
|
+
enter: (n) => {
|
|
61
|
+
if (n.name === "HTMLTag") tags.push(text.slice(n.from, n.to));
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
expect(tags).toEqual(['<Tabs defaultValue={"a"}>', "<TabItem label={x} />", "</Tabs>"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("highlights quoted attributes that follow a JSX expression", () => {
|
|
68
|
+
const text = '<Since v={"2.0.0"} t="true" b="false" />';
|
|
69
|
+
expect(htmlTagSpan(text)).toEqual({ from: 0, to: text.length });
|
|
70
|
+
|
|
71
|
+
const tree = parse(text);
|
|
72
|
+
const names = collectNodes(tree, text, JSX_ATTR_NAME_NODE);
|
|
73
|
+
const strings = collectNodes(tree, text, JSX_ATTR_STRING_NODE);
|
|
74
|
+
|
|
75
|
+
expect(names).toEqual(["v", "t", "b"]);
|
|
76
|
+
expect(strings).toEqual(['"true"', '"false"']);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
function collectNodes(tree: ReturnType<typeof parse>, text: string, name: string): string[] {
|
|
81
|
+
const out: string[] = [];
|
|
82
|
+
tree.iterate({
|
|
83
|
+
enter: (n) => {
|
|
84
|
+
if (n.name === name) out.push(text.slice(n.from, n.to));
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { EditorSelection, EditorState } from "@codemirror/state";
|
|
2
|
+
import { EditorView } from "@codemirror/view";
|
|
3
|
+
import { Callout } from "@src/lib/markdown/editor/callout";
|
|
4
|
+
import { createMarkdownLanguage } from "@src/lib/markdown/editor/language";
|
|
5
|
+
import { indentMarkdownList } from "@src/lib/markdown/editor/list-indent";
|
|
6
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
let view: EditorView;
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
view?.destroy();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
function createView(doc: string, cursor: number): EditorView {
|
|
15
|
+
const state = EditorState.create({
|
|
16
|
+
doc,
|
|
17
|
+
selection: EditorSelection.cursor(cursor),
|
|
18
|
+
extensions: [createMarkdownLanguage({ extensions: [Callout] })],
|
|
19
|
+
});
|
|
20
|
+
const parent = document.createElement("div");
|
|
21
|
+
document.body.appendChild(parent);
|
|
22
|
+
view = new EditorView({ state, parent });
|
|
23
|
+
return view;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Index just after the marker + space of the line containing `needle`. */
|
|
27
|
+
function cursorInItem(doc: string, needle: string): number {
|
|
28
|
+
return doc.indexOf(needle) + 1;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("indentMarkdownList", () => {
|
|
32
|
+
it("indents an item under its preceding sibling", () => {
|
|
33
|
+
const doc = "- one\n- two";
|
|
34
|
+
const v = createView(doc, cursorInItem(doc, "two"));
|
|
35
|
+
const handled = indentMarkdownList(v, false);
|
|
36
|
+
expect(handled).toBe(true);
|
|
37
|
+
expect(v.state.doc.toString()).toBe("- one\n - two");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("consumes Tab but does not change the first item (no preceding sibling)", () => {
|
|
41
|
+
const doc = "- one\n- two";
|
|
42
|
+
const v = createView(doc, cursorInItem(doc, "one"));
|
|
43
|
+
const handled = indentMarkdownList(v, false);
|
|
44
|
+
expect(handled).toBe(true);
|
|
45
|
+
expect(v.state.doc.toString()).toBe(doc);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("outdents a nested item to its parent level", () => {
|
|
49
|
+
const doc = "- one\n - two";
|
|
50
|
+
const v = createView(doc, cursorInItem(doc, "two"));
|
|
51
|
+
const handled = indentMarkdownList(v, true);
|
|
52
|
+
expect(handled).toBe(true);
|
|
53
|
+
expect(v.state.doc.toString()).toBe("- one\n- two");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("consumes Shift-Tab but does not outdent a top-level item", () => {
|
|
57
|
+
const doc = "- one\n- two";
|
|
58
|
+
const v = createView(doc, cursorInItem(doc, "two"));
|
|
59
|
+
const handled = indentMarkdownList(v, true);
|
|
60
|
+
expect(handled).toBe(true);
|
|
61
|
+
expect(v.state.doc.toString()).toBe(doc);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns false outside a list", () => {
|
|
65
|
+
const doc = "just a paragraph";
|
|
66
|
+
const v = createView(doc, 4);
|
|
67
|
+
expect(indentMarkdownList(v, false)).toBe(false);
|
|
68
|
+
expect(indentMarkdownList(v, true)).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("indents ordered list items by the marker width", () => {
|
|
72
|
+
const doc = "1. one\n2. two";
|
|
73
|
+
const v = createView(doc, cursorInItem(doc, "two"));
|
|
74
|
+
const handled = indentMarkdownList(v, false);
|
|
75
|
+
expect(handled).toBe(true);
|
|
76
|
+
expect(v.state.doc.toString()).toBe("1. one\n 2. two");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("indents an item together with its nested children", () => {
|
|
80
|
+
const doc = "- one\n- two\n - child";
|
|
81
|
+
const v = createView(doc, cursorInItem(doc, "two"));
|
|
82
|
+
const handled = indentMarkdownList(v, false);
|
|
83
|
+
expect(handled).toBe(true);
|
|
84
|
+
expect(v.state.doc.toString()).toBe("- one\n - two\n - child");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("keeps the cursor with the indented text", () => {
|
|
88
|
+
const doc = "- one\n- two";
|
|
89
|
+
const start = doc.indexOf("two");
|
|
90
|
+
const v = createView(doc, start);
|
|
91
|
+
indentMarkdownList(v, false);
|
|
92
|
+
// Two spaces inserted before the line shift the cursor right by two.
|
|
93
|
+
expect(v.state.selection.main.head).toBe(start + 2);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { EditorSelection, EditorState } from "@codemirror/state";
|
|
2
|
+
import { EditorView } from "@codemirror/view";
|
|
3
|
+
import { Callout } from "@src/lib/markdown/editor/callout";
|
|
4
|
+
import { createMarkdownLanguage } from "@src/lib/markdown/editor/language";
|
|
5
|
+
import { DEFAULT_SLASH_COMMANDS } from "@src/lib/markdown/editor/slash/commands";
|
|
6
|
+
import { slashDecorationsField } from "@src/lib/markdown/editor/slash/decorations";
|
|
7
|
+
import { getSlashSession, slashSessionField } from "@src/lib/markdown/editor/slash/field";
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
|
|
10
|
+
function createView(doc: string, cursor = doc.length): EditorView {
|
|
11
|
+
const state = EditorState.create({
|
|
12
|
+
doc,
|
|
13
|
+
extensions: [
|
|
14
|
+
createMarkdownLanguage({ extensions: [Callout] }),
|
|
15
|
+
slashSessionField,
|
|
16
|
+
slashDecorationsField,
|
|
17
|
+
],
|
|
18
|
+
selection: EditorSelection.cursor(cursor),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const parent = document.createElement("div");
|
|
22
|
+
document.body.appendChild(parent);
|
|
23
|
+
|
|
24
|
+
return new EditorView({ state, parent });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Simulate typing `text` one character at a time as `input.type` events. */
|
|
28
|
+
function type(view: EditorView, text: string): void {
|
|
29
|
+
for (const char of text) {
|
|
30
|
+
const pos = view.state.selection.main.head;
|
|
31
|
+
view.dispatch({
|
|
32
|
+
changes: { from: pos, insert: char },
|
|
33
|
+
selection: EditorSelection.cursor(pos + 1),
|
|
34
|
+
userEvent: "input.type",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function command(id: string) {
|
|
40
|
+
const found = DEFAULT_SLASH_COMMANDS.find((c) => c.id === id);
|
|
41
|
+
if (!found) throw new Error(`unknown command ${id}`);
|
|
42
|
+
return found;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("slashSessionField", () => {
|
|
46
|
+
it("opens a session when typing / at the start of a line", () => {
|
|
47
|
+
const view = createView("", 0);
|
|
48
|
+
type(view, "/");
|
|
49
|
+
|
|
50
|
+
expect(getSlashSession(view.state)).toEqual({ from: 0, query: "" });
|
|
51
|
+
|
|
52
|
+
view.destroy();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("opens a session when typing / after whitespace", () => {
|
|
56
|
+
const view = createView("foo ", 4);
|
|
57
|
+
type(view, "/");
|
|
58
|
+
|
|
59
|
+
expect(getSlashSession(view.state)?.from).toBe(4);
|
|
60
|
+
|
|
61
|
+
view.destroy();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("does not open a session mid-word", () => {
|
|
65
|
+
const view = createView("foo", 3);
|
|
66
|
+
type(view, "/");
|
|
67
|
+
|
|
68
|
+
expect(getSlashSession(view.state)).toBeNull();
|
|
69
|
+
|
|
70
|
+
view.destroy();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("does not open a session inside a fenced code block", () => {
|
|
74
|
+
const view = createView("```\n\n```", 4);
|
|
75
|
+
type(view, "/");
|
|
76
|
+
|
|
77
|
+
expect(getSlashSession(view.state)).toBeNull();
|
|
78
|
+
|
|
79
|
+
view.destroy();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("tracks the query as the user continues typing", () => {
|
|
83
|
+
const view = createView("", 0);
|
|
84
|
+
type(view, "/head");
|
|
85
|
+
|
|
86
|
+
expect(getSlashSession(view.state)?.query).toBe("head");
|
|
87
|
+
|
|
88
|
+
view.destroy();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("keeps the session open across a single space but closes on two", () => {
|
|
92
|
+
const view = createView("", 0);
|
|
93
|
+
type(view, "/a ");
|
|
94
|
+
expect(getSlashSession(view.state)?.query).toBe("a ");
|
|
95
|
+
|
|
96
|
+
type(view, " ");
|
|
97
|
+
expect(getSlashSession(view.state)).toBeNull();
|
|
98
|
+
|
|
99
|
+
view.destroy();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("closes the session when the slash is deleted", () => {
|
|
103
|
+
const view = createView("", 0);
|
|
104
|
+
type(view, "/h");
|
|
105
|
+
const slashPos = getSlashSession(view.state)?.from ?? -1;
|
|
106
|
+
|
|
107
|
+
view.dispatch({
|
|
108
|
+
changes: { from: slashPos, to: slashPos + 1 },
|
|
109
|
+
userEvent: "delete",
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(getSlashSession(view.state)).toBeNull();
|
|
113
|
+
|
|
114
|
+
view.destroy();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("closes the session when the caret moves before the slash", () => {
|
|
118
|
+
const view = createView("", 0);
|
|
119
|
+
type(view, "/h");
|
|
120
|
+
|
|
121
|
+
view.dispatch({ selection: EditorSelection.cursor(0) });
|
|
122
|
+
|
|
123
|
+
expect(getSlashSession(view.state)).toBeNull();
|
|
124
|
+
|
|
125
|
+
view.destroy();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("DEFAULT_SLASH_COMMANDS", () => {
|
|
130
|
+
it("replaces the /query range with a heading prefix and positions the caret", () => {
|
|
131
|
+
const view = createView("", 0);
|
|
132
|
+
type(view, "/h1");
|
|
133
|
+
const session = getSlashSession(view.state);
|
|
134
|
+
expect(session).not.toBeNull();
|
|
135
|
+
|
|
136
|
+
command("heading-1").run(view, {
|
|
137
|
+
from: session?.from ?? 0,
|
|
138
|
+
to: view.state.selection.main.head,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(view.state.doc.toString()).toBe("# ");
|
|
142
|
+
expect(view.state.selection.main.head).toBe(2);
|
|
143
|
+
// Replacing the slash text closes the session.
|
|
144
|
+
expect(getSlashSession(view.state)).toBeNull();
|
|
145
|
+
|
|
146
|
+
view.destroy();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("inserts a fenced code block with the caret on the empty middle line", () => {
|
|
150
|
+
const view = createView("", 0);
|
|
151
|
+
type(view, "/code");
|
|
152
|
+
|
|
153
|
+
command("code-block").run(view, {
|
|
154
|
+
from: 0,
|
|
155
|
+
to: view.state.selection.main.head,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(view.state.doc.toString()).toBe("```\n\n```");
|
|
159
|
+
expect(view.state.selection.main.head).toBe(4);
|
|
160
|
+
|
|
161
|
+
view.destroy();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
function collectDecorations(view: EditorView) {
|
|
166
|
+
const set = view.state.field(slashDecorationsField);
|
|
167
|
+
const marks: Array<{ from: number; to: number }> = [];
|
|
168
|
+
let widgets = 0;
|
|
169
|
+
|
|
170
|
+
set.between(0, view.state.doc.length, (from, to, deco) => {
|
|
171
|
+
if (deco.spec.widget) {
|
|
172
|
+
widgets += 1;
|
|
173
|
+
} else {
|
|
174
|
+
marks.push({ from, to });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return { marks, widgets };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
describe("slashDecorationsField", () => {
|
|
182
|
+
it("emits no decorations when there is no active session", () => {
|
|
183
|
+
const view = createView("hello", 5);
|
|
184
|
+
|
|
185
|
+
expect(collectDecorations(view)).toEqual({ marks: [], widgets: 0 });
|
|
186
|
+
|
|
187
|
+
view.destroy();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("highlights the lone slash and shows the placeholder when the query is empty", () => {
|
|
191
|
+
const view = createView("", 0);
|
|
192
|
+
type(view, "/");
|
|
193
|
+
|
|
194
|
+
const { marks, widgets } = collectDecorations(view);
|
|
195
|
+
// Mark wraps the slash and the trailing placeholder widget (0..2).
|
|
196
|
+
expect(marks).toEqual([{ from: 0, to: 2 }]);
|
|
197
|
+
expect(widgets).toBe(1);
|
|
198
|
+
|
|
199
|
+
view.destroy();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("extends the highlight over the query and drops the placeholder once text is typed", () => {
|
|
203
|
+
const view = createView("", 0);
|
|
204
|
+
type(view, "/head");
|
|
205
|
+
|
|
206
|
+
const { marks, widgets } = collectDecorations(view);
|
|
207
|
+
// Mark spans the slash + "head" (positions 0..5).
|
|
208
|
+
expect(marks).toEqual([{ from: 0, to: 5 }]);
|
|
209
|
+
expect(widgets).toBe(0);
|
|
210
|
+
|
|
211
|
+
view.destroy();
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { EditorSelection, EditorState } from "@codemirror/state";
|
|
2
|
+
import { markdownTableFormatter, tableFormatSpec } from "@src/lib/markdown/editor/table-format";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
function stateWith(doc: string, caret: number): EditorState {
|
|
6
|
+
return EditorState.create({
|
|
7
|
+
doc,
|
|
8
|
+
selection: EditorSelection.cursor(caret),
|
|
9
|
+
extensions: [markdownTableFormatter()],
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("tableFormatSpec", () => {
|
|
14
|
+
it("aligns columns and pads cells with trailing spaces", () => {
|
|
15
|
+
const doc = "| a | b |\n| - | - |\n| longvalue | y |";
|
|
16
|
+
const state = stateWith(doc, doc.indexOf("longvalue"));
|
|
17
|
+
const spec = tableFormatSpec(state);
|
|
18
|
+
expect(spec).not.toBeNull();
|
|
19
|
+
const next = state.update(spec ?? {}).state;
|
|
20
|
+
expect(next.doc.toString()).toBe(
|
|
21
|
+
["| a | b |", "| --------- | --- |", "| longvalue | y |"].join("\n"),
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns null for an already-formatted table", () => {
|
|
26
|
+
const doc = ["| a | b |", "| --------- | --- |", "| longvalue | y |"].join(
|
|
27
|
+
"\n",
|
|
28
|
+
);
|
|
29
|
+
const state = stateWith(doc, doc.indexOf("longvalue"));
|
|
30
|
+
expect(tableFormatSpec(state)).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns null when the caret is not in a table", () => {
|
|
34
|
+
const doc = "just a paragraph with a | pipe";
|
|
35
|
+
const state = stateWith(doc, 3);
|
|
36
|
+
expect(tableFormatSpec(state)).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("keeps the caret within the edited cell after formatting", () => {
|
|
40
|
+
const doc = "| a | b |\n| - | - |\n| longvalue | y |";
|
|
41
|
+
// Caret right after "long" inside the body cell.
|
|
42
|
+
const caret = doc.indexOf("longvalue") + 4;
|
|
43
|
+
const state = stateWith(doc, caret);
|
|
44
|
+
const spec = tableFormatSpec(state);
|
|
45
|
+
const next = state.update(spec ?? {}).state;
|
|
46
|
+
const formatted = next.doc.toString();
|
|
47
|
+
const cellStart = formatted.indexOf("longvalue");
|
|
48
|
+
expect(next.selection.main.head).toBe(cellStart + 4);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("markdownTableFormatter", () => {
|
|
53
|
+
it("reformats the table after an edit inside it", () => {
|
|
54
|
+
const doc = "| a | b |\n| - | - |\n| x | y |";
|
|
55
|
+
const state = EditorState.create({
|
|
56
|
+
doc,
|
|
57
|
+
selection: EditorSelection.cursor(doc.indexOf("x") + 1),
|
|
58
|
+
extensions: [markdownTableFormatter()],
|
|
59
|
+
});
|
|
60
|
+
// Type "tra" after "x" → "xtra", which widens column 1.
|
|
61
|
+
const tr = state.update({
|
|
62
|
+
changes: { from: doc.indexOf("x") + 1, insert: "tra" },
|
|
63
|
+
selection: EditorSelection.cursor(doc.indexOf("x") + 4),
|
|
64
|
+
});
|
|
65
|
+
const next = tr.state;
|
|
66
|
+
expect(next.doc.toString()).toContain("| xtra | y |");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("does not loop on its own formatting transaction", () => {
|
|
70
|
+
const doc = "| a | b |\n| - | - |\n| x | y |";
|
|
71
|
+
const state = EditorState.create({
|
|
72
|
+
doc,
|
|
73
|
+
selection: EditorSelection.cursor(doc.indexOf("x") + 1),
|
|
74
|
+
extensions: [markdownTableFormatter()],
|
|
75
|
+
});
|
|
76
|
+
const tr = state.update({
|
|
77
|
+
changes: { from: doc.indexOf("x") + 1, insert: "tra" },
|
|
78
|
+
});
|
|
79
|
+
// A single user edit produces a stable, idempotent result.
|
|
80
|
+
const again = tr.state.update({ selection: EditorSelection.cursor(0) });
|
|
81
|
+
expect(again.state.doc.toString()).toBe(tr.state.doc.toString());
|
|
82
|
+
});
|
|
83
|
+
});
|