@surrealdb/ui 1.1.1 → 1.2.1
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/fonts.js +2 -0
- package/dist/fonts.js.map +1 -0
- package/dist/ui.css +1 -1
- package/dist/ui.d.ts +537 -523
- package/dist/ui.js +16328 -14682
- 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
- /package/dist/{yoopta.d.ts → fonts.d.ts} +0 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { EditorSelection } from "@codemirror/state";
|
|
2
|
+
import { MantineProvider, Paper, v8CssVariablesResolver } from "@mantine/core";
|
|
3
|
+
import { setMarkdownEditorFocused } from "@src/lib/markdown/editor/decorations/editor-focus";
|
|
4
|
+
import { MarkdownEditor } from "@src/primitives/MarkdownEditor";
|
|
5
|
+
import { MANTINE_THEME } from "@src/theme/mantine";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { getEditorView, mountStory, nextFrame } from "../../_setup/e2e-helpers";
|
|
8
|
+
|
|
9
|
+
function shell(doc: string) {
|
|
10
|
+
return (
|
|
11
|
+
<MantineProvider
|
|
12
|
+
theme={MANTINE_THEME}
|
|
13
|
+
cssVariablesResolver={v8CssVariablesResolver}
|
|
14
|
+
forceColorScheme="dark"
|
|
15
|
+
>
|
|
16
|
+
<Paper p="md">
|
|
17
|
+
<MarkdownEditor
|
|
18
|
+
mode="preview"
|
|
19
|
+
document={doc}
|
|
20
|
+
autoFocus={false}
|
|
21
|
+
/>
|
|
22
|
+
</Paper>
|
|
23
|
+
</MantineProvider>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function caretAt(doc: string, index: number) {
|
|
28
|
+
mountStory(shell(doc));
|
|
29
|
+
const view = await getEditorView();
|
|
30
|
+
view.focus();
|
|
31
|
+
view.dispatch({
|
|
32
|
+
selection: EditorSelection.cursor(index),
|
|
33
|
+
effects: setMarkdownEditorFocused.of(true),
|
|
34
|
+
});
|
|
35
|
+
await nextFrame();
|
|
36
|
+
await nextFrame();
|
|
37
|
+
return view;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("MarkdownEditor / link placeholders", () => {
|
|
41
|
+
it("shows an 'alt text' placeholder for an empty link label", async () => {
|
|
42
|
+
const doc = "[](https://surrealdb.com)";
|
|
43
|
+
await caretAt(doc, 1); // caret between the empty []
|
|
44
|
+
expect(document.querySelector(".cm-content")?.textContent).toContain("alt text");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("shows a 'link' placeholder for an empty link destination", async () => {
|
|
48
|
+
const doc = "[label]()";
|
|
49
|
+
await caretAt(doc, doc.indexOf("(") + 1); // caret between the empty ()
|
|
50
|
+
const text = document.querySelector(".cm-content")?.textContent ?? "";
|
|
51
|
+
expect(text).toContain("link");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("shows both placeholders for a fully empty link", async () => {
|
|
55
|
+
const doc = "[]()";
|
|
56
|
+
await caretAt(doc, 1);
|
|
57
|
+
const text = document.querySelector(".cm-content")?.textContent ?? "";
|
|
58
|
+
expect(text).toContain("alt text");
|
|
59
|
+
expect(text).toContain("link");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("does not show placeholders once the sections have content", async () => {
|
|
63
|
+
const doc = "[label](https://surrealdb.com)";
|
|
64
|
+
await caretAt(doc, 3);
|
|
65
|
+
expect(document.querySelector(".cm-content")?.textContent).not.toContain("alt text");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { EditorSelection } from "@codemirror/state";
|
|
2
|
+
import { MantineProvider, Paper, v8CssVariablesResolver } from "@mantine/core";
|
|
3
|
+
import { setMarkdownEditorFocused } from "@src/lib/markdown/editor/decorations/editor-focus";
|
|
4
|
+
import { MarkdownEditor } from "@src/primitives/MarkdownEditor";
|
|
5
|
+
import { MANTINE_THEME } from "@src/theme/mantine";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { getEditorView, mountStory, nextFrame, sleep } from "../../_setup/e2e-helpers";
|
|
8
|
+
|
|
9
|
+
const DOC =
|
|
10
|
+
"Regular paragraph line.\n\n\n\nAnother paragraph.\n";
|
|
11
|
+
|
|
12
|
+
/** Left edge of a line's rendered text content (text nodes have no element box). */
|
|
13
|
+
function textLeft(line: HTMLElement): number {
|
|
14
|
+
const range = document.createRange();
|
|
15
|
+
range.selectNodeContents(line);
|
|
16
|
+
return range.getBoundingClientRect().left;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("MarkdownEditor / media alignment", () => {
|
|
20
|
+
it("aligns the image preview's left edge with regular text", async () => {
|
|
21
|
+
mountStory(
|
|
22
|
+
<MantineProvider
|
|
23
|
+
theme={MANTINE_THEME}
|
|
24
|
+
cssVariablesResolver={v8CssVariablesResolver}
|
|
25
|
+
forceColorScheme="dark"
|
|
26
|
+
>
|
|
27
|
+
<Paper p="md">
|
|
28
|
+
<MarkdownEditor
|
|
29
|
+
mode="preview"
|
|
30
|
+
document={DOC}
|
|
31
|
+
autoFocus={false}
|
|
32
|
+
/>
|
|
33
|
+
</Paper>
|
|
34
|
+
</MantineProvider>,
|
|
35
|
+
);
|
|
36
|
+
const view = await getEditorView();
|
|
37
|
+
view.focus();
|
|
38
|
+
view.dispatch({
|
|
39
|
+
selection: EditorSelection.cursor(view.state.doc.length),
|
|
40
|
+
effects: setMarkdownEditorFocused.of(true),
|
|
41
|
+
});
|
|
42
|
+
await sleep(200);
|
|
43
|
+
await nextFrame();
|
|
44
|
+
|
|
45
|
+
const para = Array.from(document.querySelectorAll<HTMLElement>(".cm-line")).find((l) =>
|
|
46
|
+
(l.textContent ?? "").includes("Regular"),
|
|
47
|
+
);
|
|
48
|
+
const img = document.querySelector<HTMLElement>('[data-md-widget="ImageWidget"] img');
|
|
49
|
+
expect(para).toBeTruthy();
|
|
50
|
+
expect(img).toBeTruthy();
|
|
51
|
+
|
|
52
|
+
const paraLeft = textLeft(para as HTMLElement);
|
|
53
|
+
const imageLeft = (img as HTMLElement).getBoundingClientRect().left;
|
|
54
|
+
// Within a pixel of the text inset (previously 6px to the left).
|
|
55
|
+
expect(Math.abs(imageLeft - paraLeft)).toBeLessThanOrEqual(1.5);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { EditorSelection } from "@codemirror/state";
|
|
2
|
+
import { MantineProvider, Paper, v8CssVariablesResolver } from "@mantine/core";
|
|
3
|
+
import { setMarkdownEditorFocused } from "@src/lib/markdown/editor/decorations/editor-focus";
|
|
4
|
+
import { MarkdownEditor } from "@src/primitives/MarkdownEditor";
|
|
5
|
+
import { MANTINE_THEME } from "@src/theme/mantine";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { getEditorView, mountStory, nextFrame, sleep } from "../../_setup/e2e-helpers";
|
|
8
|
+
|
|
9
|
+
const IMAGE_DOC = "\n\nParagraph after.\n";
|
|
10
|
+
|
|
11
|
+
function shell(doc: string) {
|
|
12
|
+
return (
|
|
13
|
+
<MantineProvider
|
|
14
|
+
theme={MANTINE_THEME}
|
|
15
|
+
cssVariablesResolver={v8CssVariablesResolver}
|
|
16
|
+
forceColorScheme="dark"
|
|
17
|
+
>
|
|
18
|
+
<Paper
|
|
19
|
+
p="md"
|
|
20
|
+
withBorder
|
|
21
|
+
>
|
|
22
|
+
<MarkdownEditor
|
|
23
|
+
mode="preview"
|
|
24
|
+
document={doc}
|
|
25
|
+
autoFocus={false}
|
|
26
|
+
/>
|
|
27
|
+
</Paper>
|
|
28
|
+
</MantineProvider>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("MarkdownEditor / media edit action", () => {
|
|
33
|
+
it("shows an image preview with an edit-source action that reveals the source", async () => {
|
|
34
|
+
mountStory(shell(IMAGE_DOC));
|
|
35
|
+
const view = await getEditorView();
|
|
36
|
+
|
|
37
|
+
// Focused, with the caret away from the image, so the image renders as a
|
|
38
|
+
// preview widget rather than raw source.
|
|
39
|
+
view.focus();
|
|
40
|
+
view.dispatch({
|
|
41
|
+
selection: EditorSelection.cursor(view.state.doc.length),
|
|
42
|
+
effects: setMarkdownEditorFocused.of(true),
|
|
43
|
+
});
|
|
44
|
+
await nextFrame();
|
|
45
|
+
await nextFrame();
|
|
46
|
+
|
|
47
|
+
expect(document.querySelector(".cm-content")?.textContent).not.toContain("![A logo]");
|
|
48
|
+
expect(document.querySelector("img")).not.toBeNull();
|
|
49
|
+
|
|
50
|
+
const action = document.querySelector<HTMLButtonElement>(
|
|
51
|
+
'button[aria-label="Edit source"]',
|
|
52
|
+
);
|
|
53
|
+
expect(action).not.toBeNull();
|
|
54
|
+
|
|
55
|
+
action?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
|
|
56
|
+
await sleep(120);
|
|
57
|
+
await nextFrame();
|
|
58
|
+
|
|
59
|
+
// The image markdown source becomes visible and is selected.
|
|
60
|
+
expect(document.querySelector(".cm-content")?.textContent).toContain("![A logo]");
|
|
61
|
+
expect(view.state.selection.main.empty).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { EditorSelection } from "@codemirror/state";
|
|
2
|
+
import { EditorView } from "@codemirror/view";
|
|
3
|
+
import * as MarkdownStories from "@src/stories/playground/Markdown.stories";
|
|
4
|
+
import { composeStories } from "@storybook/react-vite";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
mountStory,
|
|
8
|
+
nextFrame,
|
|
9
|
+
preparePreviewEditor,
|
|
10
|
+
scrollEditorTo,
|
|
11
|
+
} from "../../_setup/e2e-helpers";
|
|
12
|
+
import { md, mdViewer } from "../../_setup/markdown-classes";
|
|
13
|
+
|
|
14
|
+
const Stories = composeStories(MarkdownStories);
|
|
15
|
+
|
|
16
|
+
function findLineStart(doc: string, prefix: string): number {
|
|
17
|
+
const idx = doc.indexOf(prefix);
|
|
18
|
+
if (idx < 0) throw new Error(`prefix not found in doc: ${prefix}`);
|
|
19
|
+
return idx;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("MarkdownEditor / media sizing", () => {
|
|
23
|
+
it("images and videos do not exceed the editor content width", async () => {
|
|
24
|
+
mountStory(<Stories.Editor />);
|
|
25
|
+
const view = await preparePreviewEditor();
|
|
26
|
+
|
|
27
|
+
const docStr = view.state.doc.toString();
|
|
28
|
+
const imageLine = findLineStart(docStr, "![SurrealDB logo]");
|
|
29
|
+
const videoLine = findLineStart(docStr, "![Product demo]");
|
|
30
|
+
|
|
31
|
+
// Caret away from media lines so replace widgets render.
|
|
32
|
+
view.dispatch({ selection: EditorSelection.cursor(0) });
|
|
33
|
+
await scrollEditorTo(view, imageLine);
|
|
34
|
+
await nextFrame();
|
|
35
|
+
|
|
36
|
+
const scroller = view.scrollDOM;
|
|
37
|
+
const scrollerWidth = scroller.clientWidth;
|
|
38
|
+
expect(scrollerWidth).toBeGreaterThan(0);
|
|
39
|
+
|
|
40
|
+
let imgs: NodeListOf<HTMLImageElement> = document.querySelectorAll(
|
|
41
|
+
`.cm-editor [data-md-widget="ImageWidget"] img, .cm-editor .${md.image}`,
|
|
42
|
+
);
|
|
43
|
+
for (let i = 0; i < 40 && imgs.length === 0; i++) {
|
|
44
|
+
await nextFrame();
|
|
45
|
+
imgs = document.querySelectorAll(
|
|
46
|
+
`.cm-editor [data-md-widget="ImageWidget"] img, .cm-editor .${md.image}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
expect(imgs.length).toBeGreaterThan(0);
|
|
51
|
+
for (const img of Array.from(imgs)) {
|
|
52
|
+
const rect = img.getBoundingClientRect();
|
|
53
|
+
expect(
|
|
54
|
+
rect.width,
|
|
55
|
+
`image ${img.src} width ${rect.width} vs scroller ${scrollerWidth}`,
|
|
56
|
+
).toBeLessThanOrEqual(scrollerWidth + 2);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await scrollEditorTo(view, videoLine);
|
|
60
|
+
await nextFrame();
|
|
61
|
+
|
|
62
|
+
let videos: NodeListOf<HTMLElement> = document.querySelectorAll(
|
|
63
|
+
`.cm-editor [data-md-widget="VideoWidget"] video, .cm-editor [data-md-widget="VideoWidget"] .${md.image}`,
|
|
64
|
+
);
|
|
65
|
+
for (let i = 0; i < 40 && videos.length === 0; i++) {
|
|
66
|
+
await nextFrame();
|
|
67
|
+
videos = document.querySelectorAll(
|
|
68
|
+
`.cm-editor [data-md-widget="VideoWidget"] video, .cm-editor [data-md-widget="VideoWidget"] .${md.image}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
expect(videos.length).toBeGreaterThan(0);
|
|
73
|
+
for (const video of Array.from(videos)) {
|
|
74
|
+
const host =
|
|
75
|
+
video.closest("[data-md-widget='VideoWidget']") ?? video.closest(`.${md.image}`);
|
|
76
|
+
const rect = (host ?? video).getBoundingClientRect();
|
|
77
|
+
expect(
|
|
78
|
+
rect.width,
|
|
79
|
+
`video width ${rect.width} vs scroller ${scrollerWidth}`,
|
|
80
|
+
).toBeLessThanOrEqual(scrollerWidth + 2);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("matches MarkdownViewer image width in the split playground", async () => {
|
|
85
|
+
mountStory(<Stories.SideBySide />);
|
|
86
|
+
const view = await preparePreviewEditor();
|
|
87
|
+
|
|
88
|
+
const docStr = view.state.doc.toString();
|
|
89
|
+
const imagePos = findLineStart(docStr, "![SurrealDB logo]");
|
|
90
|
+
view.dispatch({
|
|
91
|
+
selection: EditorSelection.cursor(0),
|
|
92
|
+
effects: EditorView.scrollIntoView(imagePos, { y: "center" }),
|
|
93
|
+
});
|
|
94
|
+
await nextFrame();
|
|
95
|
+
|
|
96
|
+
let editorImg: HTMLImageElement | null = null;
|
|
97
|
+
let viewerImg: HTMLImageElement | null = null;
|
|
98
|
+
let editorW = 0;
|
|
99
|
+
let viewerW = 0;
|
|
100
|
+
|
|
101
|
+
for (let i = 0; i < 40; i++) {
|
|
102
|
+
editorImg = document.querySelector<HTMLImageElement>(
|
|
103
|
+
`.cm-editor [data-md-widget="ImageWidget"] img`,
|
|
104
|
+
);
|
|
105
|
+
viewerImg = document.querySelector<HTMLImageElement>(
|
|
106
|
+
`.${mdViewer.viewerRoot} img.${md.image}`,
|
|
107
|
+
);
|
|
108
|
+
if (editorImg && viewerImg) {
|
|
109
|
+
editorW = editorImg.getBoundingClientRect().width;
|
|
110
|
+
viewerW = viewerImg.getBoundingClientRect().width;
|
|
111
|
+
if (editorW > 0 && viewerW > 0 && Math.abs(editorW - viewerW) <= 12) {
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
await nextFrame();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
expect(editorImg).not.toBeNull();
|
|
119
|
+
expect(viewerImg).not.toBeNull();
|
|
120
|
+
// Editor `.cm-line` uses 6px inline padding mirrored in viewer.module.scss.
|
|
121
|
+
expect(Math.abs(editorW - viewerW)).toBeLessThanOrEqual(12);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { EditorSelection } from "@codemirror/state";
|
|
2
|
+
import * as MarkdownStories from "@src/stories/playground/Markdown.stories";
|
|
3
|
+
import { composeStories } from "@storybook/react-vite";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
getEditorView,
|
|
7
|
+
mountStory,
|
|
8
|
+
nextFrame,
|
|
9
|
+
page,
|
|
10
|
+
readDoc,
|
|
11
|
+
userEvent,
|
|
12
|
+
} from "../../_setup/e2e-helpers";
|
|
13
|
+
import { md, mdViewer } from "../../_setup/markdown-classes";
|
|
14
|
+
|
|
15
|
+
const Stories = composeStories(MarkdownStories);
|
|
16
|
+
|
|
17
|
+
async function getMode(): Promise<string | null> {
|
|
18
|
+
const editor = document.querySelector<HTMLElement>(".cm-editor");
|
|
19
|
+
return editor?.getAttribute("data-markdown-mode") ?? null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("MarkdownEditor / modes", () => {
|
|
23
|
+
it("toggling the SegmentedControl swaps mode without remounting the editor", async () => {
|
|
24
|
+
mountStory(<Stories.Editor />);
|
|
25
|
+
await getEditorView();
|
|
26
|
+
|
|
27
|
+
const initialEditor = document.querySelector(".cm-editor");
|
|
28
|
+
expect(initialEditor).not.toBeNull();
|
|
29
|
+
expect(await getMode()).toBe("preview");
|
|
30
|
+
|
|
31
|
+
// Mantine's SegmentedControl hides the underlying radio inputs and
|
|
32
|
+
// styles a sibling label, so we click the visible label text.
|
|
33
|
+
await userEvent.click(page.getByText("Source", { exact: true }));
|
|
34
|
+
await nextFrame();
|
|
35
|
+
expect(await getMode()).toBe("source");
|
|
36
|
+
expect(document.querySelector(".cm-editor")).toBe(initialEditor);
|
|
37
|
+
|
|
38
|
+
await userEvent.click(page.getByText("Preview", { exact: true }));
|
|
39
|
+
await nextFrame();
|
|
40
|
+
expect(await getMode()).toBe("preview");
|
|
41
|
+
expect(document.querySelector(".cm-editor")).toBe(initialEditor);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("standalone MarkdownViewer is not a CodeMirror surface", async () => {
|
|
45
|
+
mountStory(<Stories.Viewer />);
|
|
46
|
+
await nextFrame();
|
|
47
|
+
await nextFrame();
|
|
48
|
+
|
|
49
|
+
expect(document.querySelector(`.${mdViewer.viewerRoot}`)).not.toBeNull();
|
|
50
|
+
expect(document.querySelector(".cm-editor")).toBeNull();
|
|
51
|
+
expect(document.querySelector(".cm-content")).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("source mode renders raw markdown markers (no hidden marks)", async () => {
|
|
55
|
+
mountStory(<Stories.Editor />);
|
|
56
|
+
await getEditorView();
|
|
57
|
+
await userEvent.click(page.getByText("Source", { exact: true }));
|
|
58
|
+
await nextFrame();
|
|
59
|
+
await getEditorView();
|
|
60
|
+
|
|
61
|
+
await nextFrame();
|
|
62
|
+
const hidden = document.querySelectorAll(`.${md.markHidden}`);
|
|
63
|
+
expect(hidden.length).toBe(0);
|
|
64
|
+
|
|
65
|
+
const text = document.querySelector(".cm-content")?.textContent ?? "";
|
|
66
|
+
expect(text).toContain("# Welcome to the live preview editor");
|
|
67
|
+
expect(text).toContain("**bold**");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("keeps edits when cycling Source then Preview after a document change", async () => {
|
|
71
|
+
mountStory(<Stories.Editor />);
|
|
72
|
+
const view = await getEditorView();
|
|
73
|
+
|
|
74
|
+
const token = `\nuniq-e2e-${Date.now()}`;
|
|
75
|
+
view.dispatch({
|
|
76
|
+
selection: EditorSelection.cursor(view.state.doc.length),
|
|
77
|
+
changes: { from: view.state.doc.length, insert: token },
|
|
78
|
+
});
|
|
79
|
+
await nextFrame();
|
|
80
|
+
|
|
81
|
+
await userEvent.click(page.getByText("Source", { exact: true }));
|
|
82
|
+
await nextFrame();
|
|
83
|
+
expect((await readDoc()).includes(token.trim())).toBe(true);
|
|
84
|
+
|
|
85
|
+
await userEvent.click(page.getByText("Preview", { exact: true }));
|
|
86
|
+
await nextFrame();
|
|
87
|
+
|
|
88
|
+
expect((await readDoc()).includes(token.trim())).toBe(true);
|
|
89
|
+
expect(document.querySelector(".cm-content")?.getAttribute("aria-readonly")).not.toBe(
|
|
90
|
+
"true",
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { EditorSelection } from "@codemirror/state";
|
|
2
|
+
import { MantineProvider, v8CssVariablesResolver } from "@mantine/core";
|
|
3
|
+
import { MarkdownEditor } from "@src/primitives/MarkdownEditor";
|
|
4
|
+
import * as MarkdownStories from "@src/stories/playground/Markdown.stories";
|
|
5
|
+
import { MANTINE_THEME } from "@src/theme/mantine";
|
|
6
|
+
import { composeStories } from "@storybook/react-vite";
|
|
7
|
+
import { type ReactElement, useState } from "react";
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import {
|
|
10
|
+
mountStory,
|
|
11
|
+
nextFrame,
|
|
12
|
+
preparePreviewEditor,
|
|
13
|
+
scrollEditorTo,
|
|
14
|
+
userEvent,
|
|
15
|
+
} from "../../_setup/e2e-helpers";
|
|
16
|
+
|
|
17
|
+
const Stories = composeStories(MarkdownStories);
|
|
18
|
+
|
|
19
|
+
function getCheckboxes(): HTMLElement[] {
|
|
20
|
+
return Array.from(document.querySelectorAll<HTMLElement>("[data-md-task-marker]"));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const SCROLL_FIXTURE = `# Scroll fixture
|
|
24
|
+
|
|
25
|
+
${Array.from({ length: 80 }, (_, i) => `Line ${i + 1} of fixture content.`).join("\n")}
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
function ScrollFixture(): ReactElement {
|
|
29
|
+
const [doc, setDoc] = useState(SCROLL_FIXTURE);
|
|
30
|
+
// Mounting the primitive directly here means we have to provide our own
|
|
31
|
+
// MantineProvider; portable-stories decorators do not apply to ad-hoc JSX.
|
|
32
|
+
return (
|
|
33
|
+
<MantineProvider
|
|
34
|
+
theme={MANTINE_THEME}
|
|
35
|
+
cssVariablesResolver={v8CssVariablesResolver}
|
|
36
|
+
forceColorScheme="dark"
|
|
37
|
+
>
|
|
38
|
+
<div style={{ height: 240, overflow: "hidden" }}>
|
|
39
|
+
<MarkdownEditor
|
|
40
|
+
mode="preview"
|
|
41
|
+
document={doc}
|
|
42
|
+
onChangeDocument={setDoc}
|
|
43
|
+
style={{ height: "100%" }}
|
|
44
|
+
/>
|
|
45
|
+
</div>
|
|
46
|
+
</MantineProvider>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("MarkdownEditor / regressions", () => {
|
|
51
|
+
it("task checkbox toggles between checked and unchecked in the source document", async () => {
|
|
52
|
+
mountStory(<Stories.Editor />);
|
|
53
|
+
const view = await preparePreviewEditor();
|
|
54
|
+
const taskPos = view.state.doc.toString().indexOf("- [ ] Read the docs");
|
|
55
|
+
|
|
56
|
+
// Park caret outside the task list so checkbox widgets render for every item
|
|
57
|
+
// (preview mode only swaps to raw `[…]` when the selection hits the marker).
|
|
58
|
+
view.dispatch({ selection: EditorSelection.cursor(0) });
|
|
59
|
+
await scrollEditorTo(view, taskPos);
|
|
60
|
+
await nextFrame();
|
|
61
|
+
|
|
62
|
+
const initialDoc = view.state.doc.toString();
|
|
63
|
+
expect(initialDoc).toContain("- [ ] Read the docs");
|
|
64
|
+
expect(initialDoc).toContain("- [x] Star the repo");
|
|
65
|
+
|
|
66
|
+
let first: HTMLElement | undefined;
|
|
67
|
+
for (let i = 0; i < 40; i++) {
|
|
68
|
+
[first] = getCheckboxes();
|
|
69
|
+
if (first) break;
|
|
70
|
+
await nextFrame();
|
|
71
|
+
}
|
|
72
|
+
expect(first).toBeDefined();
|
|
73
|
+
if (!first) throw new Error("no task marker rendered");
|
|
74
|
+
|
|
75
|
+
await userEvent.click(first);
|
|
76
|
+
await nextFrame();
|
|
77
|
+
await nextFrame();
|
|
78
|
+
|
|
79
|
+
expect(view.state.doc.toString()).toContain("- [x] Read the docs");
|
|
80
|
+
expect(view.state.doc.toString()).not.toContain("- [ ] Read the docs");
|
|
81
|
+
|
|
82
|
+
// Toggle again — single mousedown handler, no double-toggle on click.
|
|
83
|
+
const [firstAgain] = getCheckboxes();
|
|
84
|
+
if (!firstAgain) throw new Error("task marker disappeared after toggle");
|
|
85
|
+
await userEvent.click(firstAgain);
|
|
86
|
+
await nextFrame();
|
|
87
|
+
await nextFrame();
|
|
88
|
+
expect(view.state.doc.toString()).toContain("- [ ] Read the docs");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("repeated checkbox toggles do not duplicate or drift task markers", async () => {
|
|
92
|
+
mountStory(<Stories.Editor />);
|
|
93
|
+
const view = await preparePreviewEditor();
|
|
94
|
+
const taskPos = view.state.doc.toString().indexOf("- [ ] Read the docs");
|
|
95
|
+
|
|
96
|
+
view.dispatch({ selection: EditorSelection.cursor(0) });
|
|
97
|
+
await scrollEditorTo(view, taskPos);
|
|
98
|
+
await nextFrame();
|
|
99
|
+
|
|
100
|
+
const baselineCount = (view.state.doc.toString().match(/- \[[ x]\] /g) ?? []).length;
|
|
101
|
+
expect(baselineCount).toBeGreaterThanOrEqual(3);
|
|
102
|
+
|
|
103
|
+
let markers = getCheckboxes();
|
|
104
|
+
for (let i = 0; i < 40 && markers.length < 2; i++) {
|
|
105
|
+
await nextFrame();
|
|
106
|
+
markers = getCheckboxes();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (let i = 0; i < 6; i++) {
|
|
110
|
+
markers = getCheckboxes();
|
|
111
|
+
const target = markers[1];
|
|
112
|
+
if (!target) throw new Error("task marker missing during loop");
|
|
113
|
+
await userEvent.click(target);
|
|
114
|
+
await nextFrame();
|
|
115
|
+
await nextFrame();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const finalDoc = view.state.doc.toString();
|
|
119
|
+
const finalCount = (finalDoc.match(/- \[[ x]\] /g) ?? []).length;
|
|
120
|
+
expect(finalCount).toBe(baselineCount);
|
|
121
|
+
// Star the repo started as `[x]`, so 6 toggles → still `[x]`.
|
|
122
|
+
expect(finalDoc).toContain("- [x] Star the repo");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("toggle via checkbox preserves task marker widgets visible from the previous caret position", async () => {
|
|
126
|
+
mountStory(<Stories.Editor />);
|
|
127
|
+
const view = await preparePreviewEditor();
|
|
128
|
+
const taskPos = view.state.doc.toString().indexOf("- [ ] Read the docs");
|
|
129
|
+
|
|
130
|
+
view.dispatch({ selection: EditorSelection.cursor(0) });
|
|
131
|
+
await scrollEditorTo(view, taskPos);
|
|
132
|
+
view.contentDOM.blur();
|
|
133
|
+
await nextFrame();
|
|
134
|
+
|
|
135
|
+
let initialMarkers = 0;
|
|
136
|
+
for (let i = 0; i < 40; i++) {
|
|
137
|
+
initialMarkers = getCheckboxes().length;
|
|
138
|
+
if (initialMarkers >= 3) break;
|
|
139
|
+
await nextFrame();
|
|
140
|
+
}
|
|
141
|
+
expect(initialMarkers).toBeGreaterThanOrEqual(3);
|
|
142
|
+
|
|
143
|
+
const [marker] = getCheckboxes();
|
|
144
|
+
if (!marker) throw new Error("no task marker");
|
|
145
|
+
await userEvent.click(marker);
|
|
146
|
+
await nextFrame();
|
|
147
|
+
await nextFrame();
|
|
148
|
+
|
|
149
|
+
// If the click had dropped the checkbox widget for that line, the count
|
|
150
|
+
// would fall (caret would need to sit on the task marker for that).
|
|
151
|
+
const afterMarkers = getCheckboxes().length;
|
|
152
|
+
expect(afterMarkers).toBe(initialMarkers);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("scroll position is preserved when an out-of-view edit is dispatched", async () => {
|
|
156
|
+
mountStory(<ScrollFixture />);
|
|
157
|
+
const view = await preparePreviewEditor();
|
|
158
|
+
const scroller = document.querySelector<HTMLElement>(".cm-scroller");
|
|
159
|
+
if (!scroller) throw new Error("no .cm-scroller in mounted story");
|
|
160
|
+
|
|
161
|
+
// Wait for layout to settle before measuring scroll height.
|
|
162
|
+
await nextFrame();
|
|
163
|
+
await nextFrame();
|
|
164
|
+
expect(scroller.scrollHeight).toBeGreaterThan(scroller.clientHeight);
|
|
165
|
+
|
|
166
|
+
scroller.scrollTop = 200;
|
|
167
|
+
await nextFrame();
|
|
168
|
+
const scrollBefore = scroller.scrollTop;
|
|
169
|
+
expect(scrollBefore).toBeGreaterThan(0);
|
|
170
|
+
|
|
171
|
+
// Insert at offset 5 (well above the current viewport) without
|
|
172
|
+
// moving the selection. The regression was: ANY content change
|
|
173
|
+
// reset `.cm-scroller` scrollTop to 0. The fix preserves scroll
|
|
174
|
+
// unless the dispatch explicitly requests a scrollIntoView.
|
|
175
|
+
view.dispatch({ changes: { from: 5, insert: "X" } });
|
|
176
|
+
await nextFrame();
|
|
177
|
+
await nextFrame();
|
|
178
|
+
|
|
179
|
+
expect(Math.abs(scroller.scrollTop - scrollBefore)).toBeLessThan(20);
|
|
180
|
+
expect(scroller.scrollTop).toBeGreaterThan(0);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { EditorSelection } from "@codemirror/state";
|
|
2
|
+
import * as MarkdownStories from "@src/stories/playground/Markdown.stories";
|
|
3
|
+
import { composeStories } from "@storybook/react-vite";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
focusAndType,
|
|
7
|
+
getEditorView,
|
|
8
|
+
mountStory,
|
|
9
|
+
nextFrame,
|
|
10
|
+
readDoc,
|
|
11
|
+
sleep,
|
|
12
|
+
typeText,
|
|
13
|
+
} from "../../_setup/e2e-helpers";
|
|
14
|
+
|
|
15
|
+
const Stories = composeStories(MarkdownStories);
|
|
16
|
+
|
|
17
|
+
/** Query the visible slash menu's command items (rendered in a portal). */
|
|
18
|
+
function menuItems(): HTMLElement[] {
|
|
19
|
+
return Array.from(document.querySelectorAll<HTMLElement>("[data-navigation-item-id]"));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Move the caret to a fresh empty line at the end of the document. */
|
|
23
|
+
async function caretOnFreshLine() {
|
|
24
|
+
const view = await getEditorView();
|
|
25
|
+
view.focus();
|
|
26
|
+
const end = view.state.doc.length;
|
|
27
|
+
view.dispatch({
|
|
28
|
+
changes: { from: end, insert: "\n\n" },
|
|
29
|
+
selection: EditorSelection.cursor(end + 2),
|
|
30
|
+
scrollIntoView: true,
|
|
31
|
+
});
|
|
32
|
+
await nextFrame();
|
|
33
|
+
await nextFrame();
|
|
34
|
+
return view;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("MarkdownEditor / slash commands", () => {
|
|
38
|
+
it("opens a filtered, keyboard-navigable menu and inserts boilerplate on Enter", async () => {
|
|
39
|
+
mountStory(<Stories.Editor />);
|
|
40
|
+
await caretOnFreshLine();
|
|
41
|
+
|
|
42
|
+
await focusAndType("/head");
|
|
43
|
+
await sleep(50);
|
|
44
|
+
|
|
45
|
+
const items = menuItems();
|
|
46
|
+
expect(items.length).toBeGreaterThan(0);
|
|
47
|
+
// Filtering by "head" should only surface heading commands.
|
|
48
|
+
expect(items.every((el) => el.dataset.navigationItemId?.startsWith("heading"))).toBe(true);
|
|
49
|
+
|
|
50
|
+
// Navigate to the second heading and select it.
|
|
51
|
+
await typeText("{ArrowDown}");
|
|
52
|
+
await sleep(20);
|
|
53
|
+
await typeText("{Enter}");
|
|
54
|
+
await sleep(50);
|
|
55
|
+
|
|
56
|
+
const doc = await readDoc();
|
|
57
|
+
// The "/head" text is replaced by a heading prefix; no stray newline.
|
|
58
|
+
expect(doc).toContain("## ");
|
|
59
|
+
expect(doc).not.toContain("/head");
|
|
60
|
+
expect(menuItems().length).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("dismisses on Escape and leaves the slash as literal text", async () => {
|
|
64
|
+
mountStory(<Stories.Editor />);
|
|
65
|
+
await caretOnFreshLine();
|
|
66
|
+
|
|
67
|
+
await focusAndType("/quote");
|
|
68
|
+
await sleep(50);
|
|
69
|
+
expect(menuItems().length).toBeGreaterThan(0);
|
|
70
|
+
|
|
71
|
+
await typeText("{Escape}");
|
|
72
|
+
await sleep(50);
|
|
73
|
+
|
|
74
|
+
expect(menuItems().length).toBe(0);
|
|
75
|
+
const doc = await readDoc();
|
|
76
|
+
expect(doc).toContain("/quote");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("highlights the slash and shows an Enter command placeholder until text is typed", async () => {
|
|
80
|
+
mountStory(<Stories.Editor />);
|
|
81
|
+
await caretOnFreshLine();
|
|
82
|
+
|
|
83
|
+
await focusAndType("/");
|
|
84
|
+
await sleep(50);
|
|
85
|
+
|
|
86
|
+
const highlight = document.querySelector(".cm-md-slash-active");
|
|
87
|
+
expect(highlight).not.toBeNull();
|
|
88
|
+
const placeholder = document.querySelector(".cm-md-slash-placeholder");
|
|
89
|
+
expect(placeholder?.textContent).toBe("Type to search");
|
|
90
|
+
|
|
91
|
+
// Typing the query removes the placeholder but keeps the highlight.
|
|
92
|
+
await typeText("head");
|
|
93
|
+
await sleep(50);
|
|
94
|
+
|
|
95
|
+
expect(document.querySelector(".cm-md-slash-placeholder")).toBeNull();
|
|
96
|
+
const stillHighlighted = document.querySelector(".cm-md-slash-active");
|
|
97
|
+
expect(stillHighlighted?.textContent).toBe("/head");
|
|
98
|
+
});
|
|
99
|
+
});
|