@surrealdb/ui 1.2.11 → 1.2.13

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 (101) hide show
  1. package/.claude/launch.json +17 -0
  2. package/.oxfmtrc.json +27 -0
  3. package/.oxlintrc.json +23 -0
  4. package/.zed/settings.json +34 -34
  5. package/AGENTS.md +21 -19
  6. package/README.md +15 -10
  7. package/dist/assets/245b1ccaa4927818.svg +3 -0
  8. package/dist/assets/333e1fe6e67ca4a2.svg +10 -0
  9. package/dist/assets/4b0e10c655814886.svg +21 -0
  10. package/dist/assets/9932da008ea5dd32.svg +3 -0
  11. package/dist/assets/a372168d5789daa2.svg +4 -0
  12. package/dist/assets/b140e44ee4ae7e35.svg +5 -0
  13. package/dist/assets/e028075817d928e4.svg +3 -0
  14. package/dist/assets/ee1065ad5a8f7f14.svg +6 -0
  15. package/dist/ui.css +1 -1
  16. package/dist/ui.d.ts +579 -1
  17. package/dist/ui.js +11553 -8988
  18. package/dist/ui.js.map +1 -1
  19. package/doc/code-style.md +264 -0
  20. package/doc/stacker-plan.md +219 -0
  21. package/package.json +119 -115
  22. package/res/_mixins.scss +15 -11
  23. package/tests/_setup/e2e-helpers.tsx +85 -85
  24. package/tests/_setup/portable-stories.ts +11 -10
  25. package/tests/e2e/MarkdownEditor/content-blocks.test.tsx +140 -139
  26. package/tests/e2e/MarkdownEditor/edits.test.tsx +101 -100
  27. package/tests/e2e/MarkdownEditor/heading-fold.test.tsx +35 -34
  28. package/tests/e2e/MarkdownEditor/hybrid-widgets.test.tsx +178 -177
  29. package/tests/e2e/MarkdownEditor/image-on-image.test.tsx +50 -49
  30. package/tests/e2e/MarkdownEditor/jsx-block-content.test.tsx +195 -194
  31. package/tests/e2e/MarkdownEditor/jsx-highlight.test.tsx +47 -46
  32. package/tests/e2e/MarkdownEditor/jsx-inline-badges.test.tsx +35 -34
  33. package/tests/e2e/MarkdownEditor/jsx-inline-click.test.tsx +39 -38
  34. package/tests/e2e/MarkdownEditor/jsx-selection.test.tsx +28 -27
  35. package/tests/e2e/MarkdownEditor/link-placeholder.test.tsx +49 -48
  36. package/tests/e2e/MarkdownEditor/media-align.test.tsx +40 -39
  37. package/tests/e2e/MarkdownEditor/media-edit.test.tsx +49 -48
  38. package/tests/e2e/MarkdownEditor/media-sizing.test.tsx +108 -107
  39. package/tests/e2e/MarkdownEditor/modes.test.tsx +79 -78
  40. package/tests/e2e/MarkdownEditor/onimage-parity.test.tsx +29 -28
  41. package/tests/e2e/MarkdownEditor/regressions.test.tsx +194 -193
  42. package/tests/e2e/MarkdownEditor/slash-commands.test.tsx +81 -80
  43. package/tests/e2e/MarkdownEditor/table-click.test.tsx +36 -35
  44. package/tests/e2e/MarkdownEditor/table-controls.test.tsx +44 -43
  45. package/tests/e2e/MarkdownEditor/table-format.test.tsx +28 -27
  46. package/tests/e2e/MarkdownEditor/undo-redo.test.tsx +22 -21
  47. package/tests/e2e/MarkdownViewer/parity.test.tsx +144 -143
  48. package/tests/e2e/MarkdownViewer/render.test.tsx +20 -19
  49. package/tests/e2e/Stacker/stacker.test.tsx +304 -0
  50. package/tests/unit/Editor/helpers.test.ts +26 -26
  51. package/tests/unit/MarkdownConformance/error-isolation.test.tsx +26 -25
  52. package/tests/unit/MarkdownConformance/indentation.test.tsx +20 -19
  53. package/tests/unit/MarkdownConformance/inline-code-fuzz.test.tsx +255 -0
  54. package/tests/unit/MarkdownConformance/jsx-attributes.test.tsx +39 -38
  55. package/tests/unit/MarkdownConformance/jsx-fuzz.test.tsx +122 -122
  56. package/tests/unit/MarkdownConformance/links.test.tsx +78 -54
  57. package/tests/unit/MarkdownConformance/render-helpers.tsx +60 -60
  58. package/tests/unit/MarkdownEditor/code-info.test.ts +58 -58
  59. package/tests/unit/MarkdownEditor/decorations.test.ts +423 -421
  60. package/tests/unit/MarkdownEditor/editor-ready.test.ts +22 -22
  61. package/tests/unit/MarkdownEditor/html-descriptors.test.ts +70 -70
  62. package/tests/unit/MarkdownEditor/indented-fence.test.ts +80 -80
  63. package/tests/unit/MarkdownEditor/jsx-attr-scan.test.ts +87 -87
  64. package/tests/unit/MarkdownEditor/jsx-block-widget.test.ts +51 -51
  65. package/tests/unit/MarkdownEditor/jsx-tag-grammar.test.ts +63 -63
  66. package/tests/unit/MarkdownEditor/list-indent.test.ts +67 -67
  67. package/tests/unit/MarkdownEditor/slash-commands.test.ts +150 -150
  68. package/tests/unit/MarkdownEditor/table-format.test.ts +67 -67
  69. package/tests/unit/MarkdownEditor/table.test.ts +96 -96
  70. package/tests/unit/MarkdownEditor/triggers.test.ts +167 -167
  71. package/tests/unit/MarkdownEditor/widget-store.test.ts +90 -90
  72. package/tests/unit/MarkdownViewer/callout.test.tsx +42 -42
  73. package/tests/unit/MarkdownViewer/code-highlight.test.tsx +41 -41
  74. package/tests/unit/MarkdownViewer/code-title.test.tsx +45 -45
  75. package/tests/unit/MarkdownViewer/features.test.tsx +129 -129
  76. package/tests/unit/MarkdownViewer/headings.test.tsx +28 -28
  77. package/tests/unit/MarkdownViewer/indented-jsx-children.test.tsx +53 -53
  78. package/tests/unit/MarkdownViewer/jsx-block-nesting.test.tsx +137 -137
  79. package/tests/unit/MarkdownViewer/jsx.test.tsx +188 -188
  80. package/tests/unit/MarkdownViewer/list-bullets.test.tsx +31 -31
  81. package/tests/unit/MarkdownViewer/list-code.test.tsx +45 -45
  82. package/tests/unit/MarkdownViewer/preserve-newlines.test.tsx +109 -109
  83. package/tests/unit/MarkdownViewer/read-time.test.ts +13 -13
  84. package/tests/unit/MarkdownViewer/renderers.test.tsx +75 -75
  85. package/tests/unit/MarkdownViewer/runnable.test.tsx +51 -51
  86. package/tests/unit/MarkdownViewer/ssr.test.tsx +56 -55
  87. package/tests/unit/MarkdownViewer/syntax-highlighting.test.tsx +43 -42
  88. package/tests/unit/MarkdownViewer/tabs-fenced-code.test.tsx +120 -120
  89. package/tests/unit/MarkdownViewer/tabs.test.tsx +83 -82
  90. package/tests/unit/Stacker/drag.test.ts +21 -0
  91. package/tests/unit/Stacker/helpers.test.ts +105 -0
  92. package/tests/unit/Stacker/reducer.test.ts +107 -0
  93. package/tools/duplicate-vector-mappings.json +141 -141
  94. package/tools/glass-export-overrides.json +6 -6
  95. package/tools/icon-parser.ts +41 -40
  96. package/tools/integration-gradient-exports.json +55 -55
  97. package/tools/picto-compare.ts +32 -32
  98. package/tools/picto-merge-urls.ts +19 -19
  99. package/tools/picto-sync.ts +543 -543
  100. package/tools/picto-verify-gradients.ts +50 -50
  101. package/tools/vector-export-nodes.json +975 -975
@@ -6,18 +6,19 @@ import { MANTINE_THEME } from "@src/theme/mantine";
6
6
  import { composeStories } from "@storybook/react-vite";
7
7
  import { type ReactElement, useState } from "react";
8
8
  import { describe, expect, it } from "vitest";
9
+
9
10
  import {
10
- mountStory,
11
- nextFrame,
12
- preparePreviewEditor,
13
- scrollEditorTo,
14
- userEvent,
11
+ mountStory,
12
+ nextFrame,
13
+ preparePreviewEditor,
14
+ scrollEditorTo,
15
+ userEvent,
15
16
  } from "../../_setup/e2e-helpers";
16
17
 
17
18
  const Stories = composeStories(MarkdownStories);
18
19
 
19
20
  function getCheckboxes(): HTMLElement[] {
20
- return Array.from(document.querySelectorAll<HTMLElement>("[data-md-task-marker]"));
21
+ return Array.from(document.querySelectorAll<HTMLElement>("[data-md-task-marker]"));
21
22
  }
22
23
 
23
24
  const SCROLL_FIXTURE = `# Scroll fixture
@@ -26,197 +27,197 @@ ${Array.from({ length: 80 }, (_, i) => `Line ${i + 1} of fixture content.`).join
26
27
  `;
27
28
 
28
29
  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
- );
30
+ const [doc, setDoc] = useState(SCROLL_FIXTURE);
31
+ // Mounting the primitive directly here means we have to provide our own
32
+ // MantineProvider; portable-stories decorators do not apply to ad-hoc JSX.
33
+ return (
34
+ <MantineProvider
35
+ theme={MANTINE_THEME}
36
+ cssVariablesResolver={v8CssVariablesResolver}
37
+ forceColorScheme="dark"
38
+ >
39
+ <div style={{ height: 240, overflow: "hidden" }}>
40
+ <MarkdownEditor
41
+ mode="preview"
42
+ document={doc}
43
+ onChangeDocument={setDoc}
44
+ style={{ height: "100%" }}
45
+ />
46
+ </div>
47
+ </MantineProvider>
48
+ );
48
49
  }
49
50
 
50
51
  function WrapFixture(): ReactElement {
51
- const longParagraph = "Word ".repeat(120).trim();
52
- const [doc, setDoc] = useState(longParagraph);
53
-
54
- return (
55
- <MantineProvider
56
- theme={MANTINE_THEME}
57
- cssVariablesResolver={v8CssVariablesResolver}
58
- forceColorScheme="dark"
59
- >
60
- <div style={{ width: 360, overflow: "hidden" }}>
61
- <MarkdownEditor
62
- mode="preview"
63
- document={doc}
64
- onChangeDocument={setDoc}
65
- style={{ height: 240 }}
66
- />
67
- </div>
68
- </MantineProvider>
69
- );
52
+ const longParagraph = "Word ".repeat(120).trim();
53
+ const [doc, setDoc] = useState(longParagraph);
54
+
55
+ return (
56
+ <MantineProvider
57
+ theme={MANTINE_THEME}
58
+ cssVariablesResolver={v8CssVariablesResolver}
59
+ forceColorScheme="dark"
60
+ >
61
+ <div style={{ width: 360, overflow: "hidden" }}>
62
+ <MarkdownEditor
63
+ mode="preview"
64
+ document={doc}
65
+ onChangeDocument={setDoc}
66
+ style={{ height: 240 }}
67
+ />
68
+ </div>
69
+ </MantineProvider>
70
+ );
70
71
  }
71
72
 
72
73
  describe("MarkdownEditor / regressions", () => {
73
- it("task checkbox toggles between checked and unchecked in the source document", async () => {
74
- mountStory(<Stories.Editor />);
75
- const view = await preparePreviewEditor();
76
- const taskPos = view.state.doc.toString().indexOf("- [ ] Read the docs");
77
-
78
- // Park caret outside the task list so checkbox widgets render for every item
79
- // (preview mode only swaps to raw `[…]` when the selection hits the marker).
80
- view.dispatch({ selection: EditorSelection.cursor(0) });
81
- await scrollEditorTo(view, taskPos);
82
- await nextFrame();
83
-
84
- const initialDoc = view.state.doc.toString();
85
- expect(initialDoc).toContain("- [ ] Read the docs");
86
- expect(initialDoc).toContain("- [x] Star the repo");
87
-
88
- let first: HTMLElement | undefined;
89
- for (let i = 0; i < 40; i++) {
90
- [first] = getCheckboxes();
91
- if (first) break;
92
- await nextFrame();
93
- }
94
- expect(first).toBeDefined();
95
- if (!first) throw new Error("no task marker rendered");
96
-
97
- await userEvent.click(first);
98
- await nextFrame();
99
- await nextFrame();
100
-
101
- expect(view.state.doc.toString()).toContain("- [x] Read the docs");
102
- expect(view.state.doc.toString()).not.toContain("- [ ] Read the docs");
103
-
104
- // Toggle again — single mousedown handler, no double-toggle on click.
105
- const [firstAgain] = getCheckboxes();
106
- if (!firstAgain) throw new Error("task marker disappeared after toggle");
107
- await userEvent.click(firstAgain);
108
- await nextFrame();
109
- await nextFrame();
110
- expect(view.state.doc.toString()).toContain("- [ ] Read the docs");
111
- });
112
-
113
- it("repeated checkbox toggles do not duplicate or drift task markers", async () => {
114
- mountStory(<Stories.Editor />);
115
- const view = await preparePreviewEditor();
116
- const taskPos = view.state.doc.toString().indexOf("- [ ] Read the docs");
117
-
118
- view.dispatch({ selection: EditorSelection.cursor(0) });
119
- await scrollEditorTo(view, taskPos);
120
- await nextFrame();
121
-
122
- const baselineCount = (view.state.doc.toString().match(/- \[[ x]\] /g) ?? []).length;
123
- expect(baselineCount).toBeGreaterThanOrEqual(3);
124
-
125
- let markers = getCheckboxes();
126
- for (let i = 0; i < 40 && markers.length < 2; i++) {
127
- await nextFrame();
128
- markers = getCheckboxes();
129
- }
130
-
131
- for (let i = 0; i < 6; i++) {
132
- markers = getCheckboxes();
133
- const target = markers[1];
134
- if (!target) throw new Error("task marker missing during loop");
135
- await userEvent.click(target);
136
- await nextFrame();
137
- await nextFrame();
138
- }
139
-
140
- const finalDoc = view.state.doc.toString();
141
- const finalCount = (finalDoc.match(/- \[[ x]\] /g) ?? []).length;
142
- expect(finalCount).toBe(baselineCount);
143
- // Star the repo started as `[x]`, so 6 toggles → still `[x]`.
144
- expect(finalDoc).toContain("- [x] Star the repo");
145
- });
146
-
147
- it("toggle via checkbox preserves task marker widgets visible from the previous caret position", async () => {
148
- mountStory(<Stories.Editor />);
149
- const view = await preparePreviewEditor();
150
- const taskPos = view.state.doc.toString().indexOf("- [ ] Read the docs");
151
-
152
- view.dispatch({ selection: EditorSelection.cursor(0) });
153
- await scrollEditorTo(view, taskPos);
154
- view.contentDOM.blur();
155
- await nextFrame();
156
-
157
- let initialMarkers = 0;
158
- for (let i = 0; i < 40; i++) {
159
- initialMarkers = getCheckboxes().length;
160
- if (initialMarkers >= 3) break;
161
- await nextFrame();
162
- }
163
- expect(initialMarkers).toBeGreaterThanOrEqual(3);
164
-
165
- const [marker] = getCheckboxes();
166
- if (!marker) throw new Error("no task marker");
167
- await userEvent.click(marker);
168
- await nextFrame();
169
- await nextFrame();
170
-
171
- // If the click had dropped the checkbox widget for that line, the count
172
- // would fall (caret would need to sit on the task marker for that).
173
- const afterMarkers = getCheckboxes().length;
174
- expect(afterMarkers).toBe(initialMarkers);
175
- });
176
-
177
- it("scroll position is preserved when an out-of-view edit is dispatched", async () => {
178
- mountStory(<ScrollFixture />);
179
- const view = await preparePreviewEditor();
180
- const scroller = document.querySelector<HTMLElement>(".cm-scroller");
181
- if (!scroller) throw new Error("no .cm-scroller in mounted story");
182
-
183
- // Wait for layout to settle before measuring scroll height.
184
- await nextFrame();
185
- await nextFrame();
186
- expect(scroller.scrollHeight).toBeGreaterThan(scroller.clientHeight);
187
-
188
- scroller.scrollTop = 200;
189
- await nextFrame();
190
- const scrollBefore = scroller.scrollTop;
191
- expect(scrollBefore).toBeGreaterThan(0);
192
-
193
- // Insert at offset 5 (well above the current viewport) without
194
- // moving the selection. The regression was: ANY content change
195
- // reset `.cm-scroller` scrollTop to 0. The fix preserves scroll
196
- // unless the dispatch explicitly requests a scrollIntoView.
197
- view.dispatch({ changes: { from: 5, insert: "X" } });
198
- await nextFrame();
199
- await nextFrame();
200
-
201
- expect(Math.abs(scroller.scrollTop - scrollBefore)).toBeLessThan(20);
202
- expect(scroller.scrollTop).toBeGreaterThan(0);
203
- });
204
-
205
- it("wraps long paragraph lines instead of extending the editor width", async () => {
206
- mountStory(<WrapFixture />);
207
- await nextFrame();
208
- await nextFrame();
209
-
210
- const scroller = document.querySelector<HTMLElement>(".cm-scroller");
211
- const content = document.querySelector<HTMLElement>(".cm-content");
212
-
213
- // `EditorView.lineWrapping` applies via contentAttributes, so the
214
- // `cm-lineWrapping` class lands on `.cm-content` (not the outer editor).
215
- expect(content?.classList.contains("cm-lineWrapping")).toBe(true);
216
- expect(scroller).toBeTruthy();
217
- expect(content).toBeTruthy();
218
- if (!scroller || !content) return;
219
-
220
- expect(content.scrollWidth).toBeLessThanOrEqual(scroller.clientWidth + 2);
221
- });
74
+ it("task checkbox toggles between checked and unchecked in the source document", async () => {
75
+ mountStory(<Stories.Editor />);
76
+ const view = await preparePreviewEditor();
77
+ const taskPos = view.state.doc.toString().indexOf("- [ ] Read the docs");
78
+
79
+ // Park caret outside the task list so checkbox widgets render for every item
80
+ // (preview mode only swaps to raw `[…]` when the selection hits the marker).
81
+ view.dispatch({ selection: EditorSelection.cursor(0) });
82
+ await scrollEditorTo(view, taskPos);
83
+ await nextFrame();
84
+
85
+ const initialDoc = view.state.doc.toString();
86
+ expect(initialDoc).toContain("- [ ] Read the docs");
87
+ expect(initialDoc).toContain("- [x] Star the repo");
88
+
89
+ let first: HTMLElement | undefined;
90
+ for (let i = 0; i < 40; i++) {
91
+ [first] = getCheckboxes();
92
+ if (first) break;
93
+ await nextFrame();
94
+ }
95
+ expect(first).toBeDefined();
96
+ if (!first) throw new Error("no task marker rendered");
97
+
98
+ await userEvent.click(first);
99
+ await nextFrame();
100
+ await nextFrame();
101
+
102
+ expect(view.state.doc.toString()).toContain("- [x] Read the docs");
103
+ expect(view.state.doc.toString()).not.toContain("- [ ] Read the docs");
104
+
105
+ // Toggle again — single mousedown handler, no double-toggle on click.
106
+ const [firstAgain] = getCheckboxes();
107
+ if (!firstAgain) throw new Error("task marker disappeared after toggle");
108
+ await userEvent.click(firstAgain);
109
+ await nextFrame();
110
+ await nextFrame();
111
+ expect(view.state.doc.toString()).toContain("- [ ] Read the docs");
112
+ });
113
+
114
+ it("repeated checkbox toggles do not duplicate or drift task markers", async () => {
115
+ mountStory(<Stories.Editor />);
116
+ const view = await preparePreviewEditor();
117
+ const taskPos = view.state.doc.toString().indexOf("- [ ] Read the docs");
118
+
119
+ view.dispatch({ selection: EditorSelection.cursor(0) });
120
+ await scrollEditorTo(view, taskPos);
121
+ await nextFrame();
122
+
123
+ const baselineCount = (view.state.doc.toString().match(/- \[[ x]\] /g) ?? []).length;
124
+ expect(baselineCount).toBeGreaterThanOrEqual(3);
125
+
126
+ let markers = getCheckboxes();
127
+ for (let i = 0; i < 40 && markers.length < 2; i++) {
128
+ await nextFrame();
129
+ markers = getCheckboxes();
130
+ }
131
+
132
+ for (let i = 0; i < 6; i++) {
133
+ markers = getCheckboxes();
134
+ const target = markers[1];
135
+ if (!target) throw new Error("task marker missing during loop");
136
+ await userEvent.click(target);
137
+ await nextFrame();
138
+ await nextFrame();
139
+ }
140
+
141
+ const finalDoc = view.state.doc.toString();
142
+ const finalCount = (finalDoc.match(/- \[[ x]\] /g) ?? []).length;
143
+ expect(finalCount).toBe(baselineCount);
144
+ // Star the repo started as `[x]`, so 6 toggles → still `[x]`.
145
+ expect(finalDoc).toContain("- [x] Star the repo");
146
+ });
147
+
148
+ it("toggle via checkbox preserves task marker widgets visible from the previous caret position", async () => {
149
+ mountStory(<Stories.Editor />);
150
+ const view = await preparePreviewEditor();
151
+ const taskPos = view.state.doc.toString().indexOf("- [ ] Read the docs");
152
+
153
+ view.dispatch({ selection: EditorSelection.cursor(0) });
154
+ await scrollEditorTo(view, taskPos);
155
+ view.contentDOM.blur();
156
+ await nextFrame();
157
+
158
+ let initialMarkers = 0;
159
+ for (let i = 0; i < 40; i++) {
160
+ initialMarkers = getCheckboxes().length;
161
+ if (initialMarkers >= 3) break;
162
+ await nextFrame();
163
+ }
164
+ expect(initialMarkers).toBeGreaterThanOrEqual(3);
165
+
166
+ const [marker] = getCheckboxes();
167
+ if (!marker) throw new Error("no task marker");
168
+ await userEvent.click(marker);
169
+ await nextFrame();
170
+ await nextFrame();
171
+
172
+ // If the click had dropped the checkbox widget for that line, the count
173
+ // would fall (caret would need to sit on the task marker for that).
174
+ const afterMarkers = getCheckboxes().length;
175
+ expect(afterMarkers).toBe(initialMarkers);
176
+ });
177
+
178
+ it("scroll position is preserved when an out-of-view edit is dispatched", async () => {
179
+ mountStory(<ScrollFixture />);
180
+ const view = await preparePreviewEditor();
181
+ const scroller = document.querySelector<HTMLElement>(".cm-scroller");
182
+ if (!scroller) throw new Error("no .cm-scroller in mounted story");
183
+
184
+ // Wait for layout to settle before measuring scroll height.
185
+ await nextFrame();
186
+ await nextFrame();
187
+ expect(scroller.scrollHeight).toBeGreaterThan(scroller.clientHeight);
188
+
189
+ scroller.scrollTop = 200;
190
+ await nextFrame();
191
+ const scrollBefore = scroller.scrollTop;
192
+ expect(scrollBefore).toBeGreaterThan(0);
193
+
194
+ // Insert at offset 5 (well above the current viewport) without
195
+ // moving the selection. The regression was: ANY content change
196
+ // reset `.cm-scroller` scrollTop to 0. The fix preserves scroll
197
+ // unless the dispatch explicitly requests a scrollIntoView.
198
+ view.dispatch({ changes: { from: 5, insert: "X" } });
199
+ await nextFrame();
200
+ await nextFrame();
201
+
202
+ expect(Math.abs(scroller.scrollTop - scrollBefore)).toBeLessThan(20);
203
+ expect(scroller.scrollTop).toBeGreaterThan(0);
204
+ });
205
+
206
+ it("wraps long paragraph lines instead of extending the editor width", async () => {
207
+ mountStory(<WrapFixture />);
208
+ await nextFrame();
209
+ await nextFrame();
210
+
211
+ const scroller = document.querySelector<HTMLElement>(".cm-scroller");
212
+ const content = document.querySelector<HTMLElement>(".cm-content");
213
+
214
+ // `EditorView.lineWrapping` applies via contentAttributes, so the
215
+ // `cm-lineWrapping` class lands on `.cm-content` (not the outer editor).
216
+ expect(content?.classList.contains("cm-lineWrapping")).toBe(true);
217
+ expect(scroller).toBeTruthy();
218
+ expect(content).toBeTruthy();
219
+ if (!scroller || !content) return;
220
+
221
+ expect(content.scrollWidth).toBeLessThanOrEqual(scroller.clientWidth + 2);
222
+ });
222
223
  });
@@ -2,98 +2,99 @@ import { EditorSelection } from "@codemirror/state";
2
2
  import * as MarkdownStories from "@src/stories/playground/Markdown.stories";
3
3
  import { composeStories } from "@storybook/react-vite";
4
4
  import { describe, expect, it } from "vitest";
5
+
5
6
  import {
6
- focusAndType,
7
- getEditorView,
8
- mountStory,
9
- nextFrame,
10
- readDoc,
11
- sleep,
12
- typeText,
7
+ focusAndType,
8
+ getEditorView,
9
+ mountStory,
10
+ nextFrame,
11
+ readDoc,
12
+ sleep,
13
+ typeText,
13
14
  } from "../../_setup/e2e-helpers";
14
15
 
15
16
  const Stories = composeStories(MarkdownStories);
16
17
 
17
18
  /** Query the visible slash menu's command items (rendered in a portal). */
18
19
  function menuItems(): HTMLElement[] {
19
- return Array.from(document.querySelectorAll<HTMLElement>("[data-navigation-item-id]"));
20
+ return Array.from(document.querySelectorAll<HTMLElement>("[data-navigation-item-id]"));
20
21
  }
21
22
 
22
23
  /** Move the caret to a fresh empty line at the end of the document. */
23
24
  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;
25
+ const view = await getEditorView();
26
+ view.focus();
27
+ const end = view.state.doc.length;
28
+ view.dispatch({
29
+ changes: { from: end, insert: "\n\n" },
30
+ selection: EditorSelection.cursor(end + 2),
31
+ scrollIntoView: true,
32
+ });
33
+ await nextFrame();
34
+ await nextFrame();
35
+ return view;
35
36
  }
36
37
 
37
38
  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
- });
39
+ it("opens a filtered, keyboard-navigable menu and inserts boilerplate on Enter", async () => {
40
+ mountStory(<Stories.Editor />);
41
+ await caretOnFreshLine();
42
+
43
+ await focusAndType("/head");
44
+ await sleep(50);
45
+
46
+ const items = menuItems();
47
+ expect(items.length).toBeGreaterThan(0);
48
+ // Filtering by "head" should only surface heading commands.
49
+ expect(items.every((el) => el.dataset.navigationItemId?.startsWith("heading"))).toBe(true);
50
+
51
+ // Navigate to the second heading and select it.
52
+ await typeText("{ArrowDown}");
53
+ await sleep(20);
54
+ await typeText("{Enter}");
55
+ await sleep(50);
56
+
57
+ const doc = await readDoc();
58
+ // The "/head" text is replaced by a heading prefix; no stray newline.
59
+ expect(doc).toContain("## ");
60
+ expect(doc).not.toContain("/head");
61
+ expect(menuItems().length).toBe(0);
62
+ });
63
+
64
+ it("dismisses on Escape and leaves the slash as literal text", async () => {
65
+ mountStory(<Stories.Editor />);
66
+ await caretOnFreshLine();
67
+
68
+ await focusAndType("/quote");
69
+ await sleep(50);
70
+ expect(menuItems().length).toBeGreaterThan(0);
71
+
72
+ await typeText("{Escape}");
73
+ await sleep(50);
74
+
75
+ expect(menuItems().length).toBe(0);
76
+ const doc = await readDoc();
77
+ expect(doc).toContain("/quote");
78
+ });
79
+
80
+ it("highlights the slash and shows an Enter command placeholder until text is typed", async () => {
81
+ mountStory(<Stories.Editor />);
82
+ await caretOnFreshLine();
83
+
84
+ await focusAndType("/");
85
+ await sleep(50);
86
+
87
+ const highlight = document.querySelector(".cm-md-slash-active");
88
+ expect(highlight).not.toBeNull();
89
+ const placeholder = document.querySelector(".cm-md-slash-placeholder");
90
+ expect(placeholder?.textContent).toBe("Type to search");
91
+
92
+ // Typing the query removes the placeholder but keeps the highlight.
93
+ await typeText("head");
94
+ await sleep(50);
95
+
96
+ expect(document.querySelector(".cm-md-slash-placeholder")).toBeNull();
97
+ const stillHighlighted = document.querySelector(".cm-md-slash-active");
98
+ expect(stillHighlighted?.textContent).toBe("/head");
99
+ });
99
100
  });