@surrealdb/ui 1.1.1 → 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.
Files changed (110) hide show
  1. package/.zed/settings.json +36 -0
  2. package/AGENTS.md +10 -5
  3. package/README.md +30 -0
  4. package/REVIEW.md +1 -1
  5. package/dist/assets/0d2c2f665b0f41ed.woff2 +0 -0
  6. package/dist/assets/12b57c6beacdbca0.woff2 +0 -0
  7. package/dist/assets/23645aad5ccc2b92.woff2 +0 -0
  8. package/dist/assets/8bbfa6e01a9e6a0f.woff2 +0 -0
  9. package/dist/assets/93fc40a807be6880.woff2 +0 -0
  10. package/dist/assets/9c9751ca111e97c2.woff2 +0 -0
  11. package/dist/assets/9ff55a8a9670220d.woff2 +0 -0
  12. package/dist/assets/a865edea076e0166.woff2 +0 -0
  13. package/dist/assets/b921df26851c5aca.woff2 +0 -0
  14. package/dist/assets/c6a3f4e555097159.woff2 +0 -0
  15. package/dist/assets/c6c31cb1350b2544.woff2 +0 -0
  16. package/dist/fonts.css +1 -0
  17. package/dist/{yoopta.d.ts → fonts.d.ts} +6 -6
  18. package/dist/fonts.js +2 -0
  19. package/dist/fonts.js.map +1 -0
  20. package/dist/icons.d.ts +6 -6
  21. package/dist/ui.css +1 -1
  22. package/dist/ui.d.ts +544 -531
  23. package/dist/ui.js +16325 -14683
  24. package/dist/ui.js.map +1 -1
  25. package/package.json +22 -24
  26. package/tests/_setup/e2e-helpers.tsx +169 -0
  27. package/tests/_setup/markdown-classes.ts +3 -0
  28. package/tests/_setup/portable-stories.ts +10 -0
  29. 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
  30. package/tests/e2e/MarkdownEditor/__screenshots__/content-blocks.test.tsx/MarkdownEditor---content-blocks-renders-a-diagram-for-the-fenced-mermaid-block-1.png +0 -0
  31. package/tests/e2e/MarkdownEditor/__screenshots__/content-blocks.test.tsx/MarkdownEditor---content-blocks-renders-a-horizontal-rule-as-a-separator-1.png +0 -0
  32. 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
  33. package/tests/e2e/MarkdownEditor/__screenshots__/content-blocks.test.tsx/MarkdownEditor---content-blocks-shows-blockquote-text-from-the-sample-document-1.png +0 -0
  34. package/tests/e2e/MarkdownEditor/__screenshots__/content-blocks.test.tsx/MarkdownEditor---content-blocks-shows-fenced-TypeScript-sample-code-in-the-document-1.png +0 -0
  35. 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
  36. 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
  37. package/tests/e2e/MarkdownEditor/__screenshots__/edits.test.tsx/MarkdownEditor---edits-reflects-typed-characters-in-the-underlying-document-1.png +0 -0
  38. 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
  39. 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
  40. 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
  41. 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
  42. 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
  43. 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
  44. 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
  45. 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
  46. 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
  47. 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
  48. 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
  49. 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
  50. 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
  51. 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
  52. package/tests/e2e/MarkdownEditor/__screenshots__/media-sizing.test.tsx/MarkdownEditor---media-sizing-matches-MarkdownViewer-image-width-in-the-split-playground-1.png +0 -0
  53. package/tests/e2e/MarkdownEditor/__screenshots__/modes.test.tsx/MarkdownEditor---modes-standalone-MarkdownViewer-is-not-a-CodeMirror-surface-1.png +0 -0
  54. package/tests/e2e/MarkdownEditor/__screenshots__/modes.test.tsx/MarkdownEditor---modes-toggling-the-SegmentedControl-swaps-mode-without-remounting-the-editor-1.png +0 -0
  55. package/tests/e2e/MarkdownEditor/__screenshots__/regressions.test.tsx/MarkdownEditor---regressions-repeated-checkbox-toggles-do-not-duplicate-or-drift-task-markers-1.png +0 -0
  56. 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
  57. 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
  58. 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
  59. 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
  60. 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
  61. 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
  62. package/tests/e2e/MarkdownEditor/__screenshots__/undo-redo.test.tsx/MarkdownEditor---undo-and-redo-restores-undone-document-edits-via-redo-1.png +0 -0
  63. package/tests/e2e/MarkdownEditor/content-blocks.test.tsx +152 -0
  64. package/tests/e2e/MarkdownEditor/edits.test.tsx +111 -0
  65. package/tests/e2e/MarkdownEditor/heading-fold.test.tsx +44 -0
  66. package/tests/e2e/MarkdownEditor/hybrid-widgets.test.tsx +192 -0
  67. package/tests/e2e/MarkdownEditor/jsx-block-content.test.tsx +242 -0
  68. package/tests/e2e/MarkdownEditor/jsx-highlight.test.tsx +68 -0
  69. package/tests/e2e/MarkdownEditor/jsx-inline-badges.test.tsx +59 -0
  70. package/tests/e2e/MarkdownEditor/jsx-selection.test.tsx +43 -0
  71. package/tests/e2e/MarkdownEditor/link-placeholder.test.tsx +67 -0
  72. package/tests/e2e/MarkdownEditor/media-align.test.tsx +57 -0
  73. package/tests/e2e/MarkdownEditor/media-edit.test.tsx +63 -0
  74. package/tests/e2e/MarkdownEditor/media-sizing.test.tsx +123 -0
  75. package/tests/e2e/MarkdownEditor/modes.test.tsx +93 -0
  76. package/tests/e2e/MarkdownEditor/regressions.test.tsx +182 -0
  77. package/tests/e2e/MarkdownEditor/slash-commands.test.tsx +99 -0
  78. package/tests/e2e/MarkdownEditor/table-click.test.tsx +47 -0
  79. package/tests/e2e/MarkdownEditor/table-controls.test.tsx +56 -0
  80. package/tests/e2e/MarkdownEditor/table-format.test.tsx +41 -0
  81. package/tests/e2e/MarkdownEditor/undo-redo.test.tsx +38 -0
  82. package/tests/e2e/MarkdownViewer/__screenshots__/parity.test.tsx/MarkdownViewer---editor-parity-matches-computed-font-size-on-first-heading-1.png +0 -0
  83. package/tests/e2e/MarkdownViewer/__screenshots__/parity.test.tsx/MarkdownViewer---editor-parity-matches-counts-for-shared-structural-classes-1.png +0 -0
  84. package/tests/e2e/MarkdownViewer/__screenshots__/parity.test.tsx/MarkdownViewer---editor-parity-matches-visible-text-between-preview-editor--blurred--and-MarkdownViewer-1.png +0 -0
  85. package/tests/e2e/MarkdownViewer/__screenshots__/render.test.tsx/MarkdownViewer---render-exercises-shared-preview-class-names-without-mounting-CodeMirror-1.png +0 -0
  86. package/tests/e2e/MarkdownViewer/parity.test.tsx +190 -0
  87. package/tests/e2e/MarkdownViewer/render.test.tsx +35 -0
  88. package/tests/unit/Editor/helpers.test.ts +42 -0
  89. package/tests/unit/MarkdownEditor/code-info.test.ts +63 -0
  90. package/tests/unit/MarkdownEditor/decorations.test.ts +488 -0
  91. package/tests/unit/MarkdownEditor/editor-ready.test.ts +36 -0
  92. package/tests/unit/MarkdownEditor/html-descriptors.test.ts +94 -0
  93. package/tests/unit/MarkdownEditor/jsx-attr-scan.test.ts +115 -0
  94. package/tests/unit/MarkdownEditor/jsx-tag-grammar.test.ts +88 -0
  95. package/tests/unit/MarkdownEditor/list-indent.test.ts +95 -0
  96. package/tests/unit/MarkdownEditor/slash-commands.test.ts +213 -0
  97. package/tests/unit/MarkdownEditor/table-format.test.ts +83 -0
  98. package/tests/unit/MarkdownEditor/table.test.ts +119 -0
  99. package/tests/unit/MarkdownEditor/triggers.test.ts +244 -0
  100. package/tests/unit/MarkdownEditor/widget-store.test.ts +105 -0
  101. package/tests/unit/MarkdownViewer/code-title.test.tsx +62 -0
  102. package/tests/unit/MarkdownViewer/features.test.tsx +110 -0
  103. package/tests/unit/MarkdownViewer/headings.test.tsx +40 -0
  104. package/tests/unit/MarkdownViewer/jsx.test.tsx +211 -0
  105. package/tests/unit/MarkdownViewer/list-bullets.test.tsx +49 -0
  106. package/tests/unit/MarkdownViewer/list-code.test.tsx +65 -0
  107. package/tests/unit/MarkdownViewer/renderers.test.tsx +79 -0
  108. package/tests/unit/MarkdownViewer/runnable.test.tsx +69 -0
  109. package/tests/unit/MarkdownViewer/ssr.test.tsx +93 -0
  110. package/dist/yoopta.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@surrealdb/ui",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "exports": {
@@ -15,8 +15,8 @@
15
15
  "./styles.css": {
16
16
  "import": "./dist/ui.css"
17
17
  },
18
- "./yoopta.css": {
19
- "import": "./dist/yoopta.css"
18
+ "./fonts.css": {
19
+ "import": "./dist/fonts.css"
20
20
  },
21
21
  "./mixins": {
22
22
  "import": "./res/_mixins.scss"
@@ -31,16 +31,25 @@
31
31
  "qau": "biome check . --write --unsafe",
32
32
  "qts": "tsc --noEmit",
33
33
  "qtsw": "tsc --noEmit --watch",
34
+ "test": "vitest run",
35
+ "test:unit": "vitest run --project=unit",
36
+ "test:e2e": "vitest run --project=e2e",
37
+ "test:storybook": "vitest run --project=storybook",
38
+ "test:watch": "vitest",
34
39
  "icons": "bun run tools/icon-parser.ts"
35
40
  },
36
41
  "dependencies": {
42
+ "@codemirror/lang-markdown": "^6.5.0",
37
43
  "@codemirror/legacy-modes": "^6.4.2",
44
+ "@fontsource-variable/geist": "^5.2.9",
45
+ "@fontsource-variable/jetbrains-mono": "^5.2.8",
38
46
  "@lezer/common": "^1.2.1",
39
47
  "@lezer/go": "^1.0.0",
40
48
  "@lezer/highlight": "^1.2.1",
41
49
  "@lezer/html": "^1.3.10",
42
50
  "@lezer/javascript": "^1.4.17",
43
51
  "@lezer/json": "^1.0.3",
52
+ "@lezer/markdown": "^1.6.3",
44
53
  "@lezer/php": "^1.0.2",
45
54
  "@lezer/python": "^1.1.14",
46
55
  "@lezer/rust": "^1.0.2",
@@ -52,7 +61,7 @@
52
61
  "vite-tsconfig-paths": "^6.0.5"
53
62
  },
54
63
  "devDependencies": {
55
- "@biomejs/biome": "2.1.2",
64
+ "@biomejs/biome": "2.4.12",
56
65
  "@chromatic-com/storybook": "^4.1.3",
57
66
  "@laynezh/vite-plugin-lib-assets": "^2.1.3",
58
67
  "@storybook/addon-a11y": "^10.1.11",
@@ -69,16 +78,8 @@
69
78
  "@vitejs/plugin-react": "^5.0.2",
70
79
  "@vitest/browser-playwright": "^4.0.16",
71
80
  "@vitest/coverage-v8": "^4.0.16",
72
- "@yoopta/blockquote": "^6.0.1",
73
- "@yoopta/callout": "^6.0.1",
74
- "@yoopta/divider": "^6.0.1",
75
- "@yoopta/editor": "^6.0.1",
76
- "@yoopta/headings": "^6.0.1",
77
- "@yoopta/lists": "^6.0.1",
78
- "@yoopta/marks": "^6.0.1",
79
- "@yoopta/paragraph": "^6.0.1",
80
- "@yoopta/ui": "^6.0.1",
81
81
  "jsdom": "^28.0.0",
82
+ "mermaid": "^11.14.0",
82
83
  "playwright": "^1.57.0",
83
84
  "sass-embedded": "^1.92.1",
84
85
  "slate": "^0.120.0",
@@ -100,20 +101,17 @@
100
101
  "@codemirror/search": "^6.5.6",
101
102
  "@codemirror/state": "^6.4.1",
102
103
  "@codemirror/view": "^6.24.1",
103
- "@mantine/core": "^9.0.2",
104
- "@mantine/hooks": "^9.0.2",
105
- "@yoopta/blockquote": "^6",
106
- "@yoopta/callout": "^6",
107
- "@yoopta/divider": "^6",
108
- "@yoopta/editor": "^6",
109
- "@yoopta/headings": "^6",
110
- "@yoopta/lists": "^6",
111
- "@yoopta/marks": "^6",
112
- "@yoopta/paragraph": "^6",
113
- "@yoopta/ui": "^6",
104
+ "@mantine/core": "^9.2.2",
105
+ "@mantine/hooks": "^9.2.2",
106
+ "mermaid": "^11",
114
107
  "react": "^19",
115
108
  "slate": "^0.120.0",
116
109
  "slate-dom": "^0.119.0",
117
110
  "slate-react": "^0.120.0"
111
+ },
112
+ "peerDependenciesMeta": {
113
+ "mermaid": {
114
+ "optional": true
115
+ }
118
116
  }
119
117
  }
@@ -0,0 +1,169 @@
1
+ import { EditorView } from "@codemirror/view";
2
+ import { page, userEvent } from "@vitest/browser/context";
3
+ import { type ComponentType, createElement, type ReactElement } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach } from "vitest";
6
+
7
+ interface MountedStory {
8
+ container: HTMLElement;
9
+ unmount: () => void;
10
+ }
11
+
12
+ const activeMounts = new Set<MountedStory>();
13
+
14
+ afterEach(() => {
15
+ for (const mount of activeMounts) {
16
+ mount.unmount();
17
+ }
18
+ activeMounts.clear();
19
+ });
20
+
21
+ /**
22
+ * Mount a composed Storybook story (or any React component) into a fresh
23
+ * detached container appended to `document.body`. Cleanup happens after
24
+ * each test automatically; tests can still call `unmount()` early.
25
+ */
26
+ export function mountStory(node: ReactElement | ComponentType): MountedStory {
27
+ const container = document.createElement("div");
28
+ container.dataset.testRoot = "true";
29
+ document.body.appendChild(container);
30
+
31
+ let root: Root | null = createRoot(container);
32
+ const element = isReactElement(node) ? node : createElement(node);
33
+ root.render(element);
34
+
35
+ const mount: MountedStory = {
36
+ container,
37
+ unmount: () => {
38
+ if (!root) return;
39
+ root.unmount();
40
+ root = null;
41
+ container.remove();
42
+ activeMounts.delete(mount);
43
+ },
44
+ };
45
+ activeMounts.add(mount);
46
+ return mount;
47
+ }
48
+
49
+ function isReactElement(node: ReactElement | ComponentType): node is ReactElement {
50
+ return typeof node !== "function" && typeof node === "object";
51
+ }
52
+
53
+ /**
54
+ * Wait for the CodeMirror editor to mount and return the underlying
55
+ * `EditorView`. Polls `EditorView.findFromDOM` so callers can read the
56
+ * authoritative state (selection, doc, mode) directly inside assertions.
57
+ */
58
+ export async function getEditorView(timeoutMs = 5000): Promise<EditorView> {
59
+ const start = Date.now();
60
+ while (Date.now() - start < timeoutMs) {
61
+ const dom = document.querySelector<HTMLElement>(".cm-editor");
62
+ if (dom) {
63
+ const view = EditorView.findFromDOM(dom);
64
+ if (view) return view;
65
+ }
66
+ await sleep(20);
67
+ }
68
+ throw new Error("Timed out waiting for the markdown EditorView to mount");
69
+ }
70
+
71
+ /** Returns the `.cm-content` editable element once it's in the DOM. */
72
+ export async function getContentElement(timeoutMs = 5000): Promise<HTMLElement> {
73
+ const start = Date.now();
74
+ while (Date.now() - start < timeoutMs) {
75
+ const dom = document.querySelector<HTMLElement>(".cm-content");
76
+ if (dom) return dom;
77
+ await sleep(20);
78
+ }
79
+ throw new Error("Timed out waiting for .cm-content");
80
+ }
81
+
82
+ /**
83
+ * Focus the underlying CodeMirror `EditorView` without moving the caret
84
+ * (unlike `userEvent.click`, which relocates the caret to the click
85
+ * target). Useful when the test has already positioned the selection via
86
+ * `view.dispatch({ selection: ... })`.
87
+ */
88
+ export async function focusEditor(): Promise<EditorView> {
89
+ const view = await getEditorView();
90
+ view.focus();
91
+ await getContentElement();
92
+ return view;
93
+ }
94
+
95
+ /** Type at the currently focused element. Caller should call `focusEditor()` first. */
96
+ export async function typeText(text: string): Promise<void> {
97
+ await userEvent.keyboard(text);
98
+ }
99
+
100
+ /** Convenience: focus the editor and type at the current caret. */
101
+ export async function focusAndType(text: string): Promise<void> {
102
+ await focusEditor();
103
+ await userEvent.keyboard(text);
104
+ }
105
+
106
+ /**
107
+ * Resolve when the next animation frame fires. Useful between dispatch
108
+ * and DOM assertions so CodeMirror's measure cycle has time to run.
109
+ */
110
+ export function nextFrame(): Promise<void> {
111
+ return new Promise((resolve) => requestAnimationFrame(() => resolve()));
112
+ }
113
+
114
+ /** Sleep helper for the polling loops above. */
115
+ export function sleep(ms: number): Promise<void> {
116
+ return new Promise((resolve) => setTimeout(resolve, ms));
117
+ }
118
+
119
+ /**
120
+ * Read the current markdown source out of the live EditorView.
121
+ */
122
+ export async function readDoc(): Promise<string> {
123
+ const view = await getEditorView();
124
+ return view.state.doc.toString();
125
+ }
126
+
127
+ /** Wait until the editor reports `data-markdown-mode="preview"`. */
128
+ export async function ensurePreviewMode(timeoutMs = 5000): Promise<void> {
129
+ const start = Date.now();
130
+ while (Date.now() - start < timeoutMs) {
131
+ const mode = document.querySelector(".cm-editor")?.getAttribute("data-markdown-mode");
132
+ if (mode === "preview") return;
133
+ await nextFrame();
134
+ }
135
+
136
+ const preview = page.getByText("Preview", { exact: true });
137
+
138
+ if (preview.elements().length > 0) {
139
+ await preview.click();
140
+ }
141
+
142
+ const retryStart = Date.now();
143
+ while (Date.now() - retryStart < timeoutMs) {
144
+ const mode = document.querySelector(".cm-editor")?.getAttribute("data-markdown-mode");
145
+ if (mode === "preview") return;
146
+ await nextFrame();
147
+ }
148
+
149
+ throw new Error("Timed out waiting for preview mode");
150
+ }
151
+
152
+ /** Mount helper: editor view + preview mode + focus. */
153
+ export async function preparePreviewEditor(): Promise<EditorView> {
154
+ const view = await getEditorView();
155
+ await ensurePreviewMode();
156
+ view.focus();
157
+ await nextFrame();
158
+ return view;
159
+ }
160
+
161
+ /** Scroll the editor so `pos` is centred in the viewport. */
162
+ export async function scrollEditorTo(view: EditorView, pos: number): Promise<void> {
163
+ view.dispatch({
164
+ effects: EditorView.scrollIntoView(pos, { y: "center" }),
165
+ });
166
+ await nextFrame();
167
+ }
168
+
169
+ export { page, userEvent };
@@ -0,0 +1,3 @@
1
+ /** Shared markdown CSS module class names for unit and E2E selectors. */
2
+ export { default as md } from "@src/lib/markdown/style/markdown.module.scss";
3
+ export { default as mdViewer } from "@src/lib/markdown/style/viewer.module.scss";
@@ -0,0 +1,10 @@
1
+ import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
2
+ import { setProjectAnnotations } from "@storybook/react-vite";
3
+ import * as projectAnnotations from "../../.storybook/preview";
4
+
5
+ // Calling `setProjectAnnotations` here ensures any `composeStories(...)` invocation
6
+ // inside the e2e suite gets the same Mantine provider, theme, font links, and a11y
7
+ // decorators that real Storybook stories receive in the iframe. The storybook
8
+ // vitest plugin does this for in-story `play` tests; the e2e project does it
9
+ // here so cross-feature browser tests can mount existing stories verbatim.
10
+ setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
@@ -0,0 +1,152 @@
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
+ focusAndType,
8
+ mountStory,
9
+ nextFrame,
10
+ preparePreviewEditor,
11
+ scrollEditorTo,
12
+ sleep,
13
+ } from "../../_setup/e2e-helpers";
14
+ import { md } from "../../_setup/markdown-classes";
15
+
16
+ const Stories = composeStories(MarkdownStories);
17
+
18
+ describe("MarkdownEditor / content blocks", () => {
19
+ it("continues an ordered list when Enter is pressed at the end of a numbered line", async () => {
20
+ mountStory(<Stories.Editor />);
21
+ const view = await preparePreviewEditor();
22
+ const doc = view.state.doc.toString();
23
+
24
+ const line = "4. Fourth step";
25
+ const idx = doc.indexOf(line);
26
+ expect(idx).toBeGreaterThan(-1);
27
+ const eol = idx + line.length;
28
+
29
+ await scrollEditorTo(view, idx);
30
+ view.dispatch({ selection: EditorSelection.cursor(eol) });
31
+ await nextFrame();
32
+
33
+ await focusAndType("{Enter}fifth step");
34
+ await nextFrame();
35
+
36
+ expect(view.state.doc.toString()).toMatch(/\n5\.\s*fifth step/);
37
+ });
38
+
39
+ it("shows blockquote text from the sample document", async () => {
40
+ mountStory(<Stories.Editor />);
41
+ const view = await preparePreviewEditor();
42
+ const doc = view.state.doc.toString();
43
+ expect(doc).toContain("> A blockquote");
44
+
45
+ const quotePos = doc.indexOf("> A blockquote");
46
+ await scrollEditorTo(view, quotePos);
47
+ await nextFrame();
48
+
49
+ expect(document.querySelector(`.${md.quote}`)?.textContent ?? "").toMatch(/blockquote/i);
50
+ });
51
+
52
+ it("renders a horizontal rule as a separator", async () => {
53
+ mountStory(<Stories.Editor />);
54
+ const view = await preparePreviewEditor();
55
+ const hrPos = view.state.doc.toString().indexOf("***");
56
+ expect(hrPos).toBeGreaterThan(-1);
57
+ await scrollEditorTo(view, hrPos);
58
+ view.dispatch({ selection: EditorSelection.cursor(0) });
59
+ await nextFrame();
60
+
61
+ let hrs = 0;
62
+ for (let i = 0; i < 40; i++) {
63
+ hrs = document.querySelectorAll(`hr.${md.hr}[aria-label="Horizontal rule"]`).length;
64
+ if (hrs > 0) break;
65
+ await nextFrame();
66
+ }
67
+
68
+ expect(hrs).toBeGreaterThan(0);
69
+ });
70
+
71
+ it("renders a read-only table preview when the table block is inactive", async () => {
72
+ mountStory(<Stories.Editor />);
73
+ const view = await preparePreviewEditor();
74
+ const doc = view.state.doc.toString();
75
+ const tablePos = doc.indexOf("| Feature | Status");
76
+ const afterTable = doc.indexOf("## HTML table");
77
+ expect(tablePos).toBeGreaterThan(-1);
78
+ expect(afterTable).toBeGreaterThan(-1);
79
+ view.dispatch({
80
+ selection: EditorSelection.cursor(afterTable),
81
+ effects: EditorView.scrollIntoView(tablePos, { y: "center" }),
82
+ });
83
+
84
+ let tables = 0;
85
+ for (let i = 0; i < 40; i++) {
86
+ tables = document.querySelectorAll(
87
+ `.cm-editor [data-md-widget="TableWidget"] table.${md.table}`,
88
+ ).length;
89
+ if (tables > 0) break;
90
+ await nextFrame();
91
+ }
92
+
93
+ expect(tables).toBeGreaterThan(0);
94
+ });
95
+
96
+ it("shows GFM table source when the table block is active", async () => {
97
+ mountStory(<Stories.Editor />);
98
+ const view = await preparePreviewEditor();
99
+ const doc = view.state.doc.toString();
100
+ const marker = "| Feature | Status";
101
+ const idx = doc.indexOf(marker);
102
+ expect(idx).toBeGreaterThan(-1);
103
+
104
+ await scrollEditorTo(view, idx);
105
+ view.dispatch({ selection: EditorSelection.cursor(idx + 2) });
106
+ await nextFrame();
107
+
108
+ expect(document.querySelector(`.${md.tableWidget}`)).toBeNull();
109
+ expect(view.state.doc.sliceString(idx, idx + marker.length)).toBe(marker);
110
+ });
111
+
112
+ it("shows fenced TypeScript sample code in the document", async () => {
113
+ mountStory(<Stories.Editor />);
114
+ const view = await preparePreviewEditor();
115
+ const doc = view.state.doc.toString();
116
+ expect(doc).toContain("function add");
117
+
118
+ const codePos = doc.indexOf("function add");
119
+ await scrollEditorTo(view, codePos);
120
+ await nextFrame();
121
+
122
+ expect(document.querySelector(".cm-editor")?.textContent ?? "").toContain("function add");
123
+ });
124
+
125
+ it("exposes clickable links with an href matching the Markdown target", async () => {
126
+ mountStory(<Stories.Editor />);
127
+ const view = await preparePreviewEditor();
128
+ const linkPos = view.state.doc.toString().indexOf("[SurrealDB website]");
129
+ expect(linkPos).toBeGreaterThan(-1);
130
+ await scrollEditorTo(view, linkPos);
131
+ view.dispatch({ selection: EditorSelection.cursor(0) });
132
+ await nextFrame();
133
+
134
+ expect(document.querySelector(`.${md.link}[data-href*='surrealdb.com']`)).not.toBeNull();
135
+ });
136
+
137
+ it("renders a diagram for the fenced mermaid block", async () => {
138
+ mountStory(<Stories.Editor />);
139
+ const view = await preparePreviewEditor();
140
+ const mermaidPos = view.state.doc.toString().indexOf("```mermaid");
141
+ expect(mermaidPos).toBeGreaterThan(-1);
142
+ await scrollEditorTo(view, mermaidPos);
143
+
144
+ let found = false;
145
+ for (let i = 0; i < 60; i++) {
146
+ found = !!document.querySelector('.cm-editor [aria-label="Mermaid diagram"] svg');
147
+ if (found) break;
148
+ await sleep(250);
149
+ }
150
+ expect(found).toBe(true);
151
+ });
152
+ });
@@ -0,0 +1,111 @@
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
+ } from "../../_setup/e2e-helpers";
12
+
13
+ const Stories = composeStories(MarkdownStories);
14
+
15
+ describe("MarkdownEditor / edits", () => {
16
+ it("reflects typed characters in the underlying document", async () => {
17
+ mountStory(<Stories.Editor />);
18
+ const view = await getEditorView();
19
+
20
+ view.dispatch({ selection: EditorSelection.cursor(view.state.doc.length) });
21
+ view.focus();
22
+
23
+ await focusAndType("\nfresh content");
24
+ await nextFrame();
25
+
26
+ const doc = await readDoc();
27
+ expect(doc.endsWith("\nfresh content")).toBe(true);
28
+ });
29
+
30
+ it("mirrors the source preview block in the story when the document changes", async () => {
31
+ mountStory(<Stories.Editor />);
32
+ const view = await getEditorView();
33
+
34
+ view.dispatch({
35
+ selection: EditorSelection.cursor(view.state.doc.length),
36
+ changes: { from: view.state.doc.length, insert: "\n\nMirror this line." },
37
+ });
38
+ await nextFrame();
39
+ await nextFrame();
40
+
41
+ const previewBlock = document.querySelector<HTMLPreElement>("pre");
42
+ expect(previewBlock).not.toBeNull();
43
+ expect(previewBlock?.textContent ?? "").toContain("Mirror this line.");
44
+ });
45
+
46
+ it("expands triple backtick into a fenced block with caret on the empty content line", async () => {
47
+ mountStory(<Stories.Editor />);
48
+ const view = await getEditorView();
49
+
50
+ view.focus();
51
+ const insertAt = view.state.doc.length;
52
+ view.dispatch({
53
+ changes: { from: insertAt, insert: "\n\n``" },
54
+ selection: EditorSelection.cursor(insertAt + 4),
55
+ });
56
+ await nextFrame();
57
+
58
+ await focusAndType("`");
59
+ await nextFrame();
60
+
61
+ const doc = await readDoc();
62
+ expect(doc.endsWith("\n\n```\n\n```")).toBe(true);
63
+
64
+ const main = view.state.selection.main;
65
+ expect(main.empty).toBe(true);
66
+ const contentLine = view.state.doc.lineAt(main.head);
67
+ expect(contentLine.text).toBe("");
68
+ expect(main.head).toBe(contentLine.from);
69
+ expect(view.state.doc.line(contentLine.number - 1).text).toBe("```");
70
+ expect(view.state.doc.line(contentLine.number + 1).text).toBe("```");
71
+ });
72
+
73
+ it("wraps a non-empty selection with backticks when ` is typed", async () => {
74
+ mountStory(<Stories.Editor />);
75
+ const view = await getEditorView();
76
+
77
+ const target = "First item";
78
+ const start = view.state.doc.toString().indexOf(target);
79
+ expect(start).toBeGreaterThan(-1);
80
+
81
+ view.focus();
82
+ view.dispatch({ selection: EditorSelection.range(start, start + target.length) });
83
+ await nextFrame();
84
+
85
+ await focusAndType("`");
86
+ await nextFrame();
87
+
88
+ const doc = await readDoc();
89
+ expect(doc).toContain("`First item`");
90
+ });
91
+
92
+ it("auto-continues a bullet list when Enter is pressed at the end of a list item", async () => {
93
+ mountStory(<Stories.Editor />);
94
+ const view = await getEditorView();
95
+
96
+ const target = "- First item";
97
+ const start = view.state.doc.toString().indexOf(target);
98
+ expect(start).toBeGreaterThan(-1);
99
+ const eol = start + target.length;
100
+
101
+ view.focus();
102
+ view.dispatch({ selection: EditorSelection.cursor(eol) });
103
+ await nextFrame();
104
+
105
+ await focusAndType("{Enter}continued");
106
+ await nextFrame();
107
+
108
+ const doc = await readDoc();
109
+ expect(doc).toContain("- First item\n- continued\n- Second item");
110
+ });
111
+ });