@surrealdb/ui 1.2.12 → 1.2.14

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 (99) 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 +25 -19
  6. package/CLAUDE.md +111 -0
  7. package/README.md +15 -10
  8. package/dist/icons.d.ts +4 -0
  9. package/dist/icons.js +91 -89
  10. package/dist/icons.js.map +1 -1
  11. package/dist/ui.css +1 -1
  12. package/dist/ui.d.ts +259 -1
  13. package/dist/ui.js +9807 -12265
  14. package/dist/ui.js.map +1 -1
  15. package/doc/code-style.md +264 -0
  16. package/doc/release.md +102 -0
  17. package/doc/stacker-plan.md +219 -0
  18. package/package.json +120 -116
  19. package/release-notes.md +0 -0
  20. package/res/_mixins.scss +15 -11
  21. package/tests/_setup/e2e-helpers.tsx +85 -85
  22. package/tests/_setup/portable-stories.ts +11 -10
  23. package/tests/e2e/MarkdownEditor/content-blocks.test.tsx +140 -139
  24. package/tests/e2e/MarkdownEditor/edits.test.tsx +101 -100
  25. package/tests/e2e/MarkdownEditor/heading-fold.test.tsx +35 -34
  26. package/tests/e2e/MarkdownEditor/hybrid-widgets.test.tsx +178 -177
  27. package/tests/e2e/MarkdownEditor/image-on-image.test.tsx +50 -49
  28. package/tests/e2e/MarkdownEditor/jsx-block-content.test.tsx +195 -194
  29. package/tests/e2e/MarkdownEditor/jsx-highlight.test.tsx +47 -46
  30. package/tests/e2e/MarkdownEditor/jsx-inline-badges.test.tsx +35 -34
  31. package/tests/e2e/MarkdownEditor/jsx-inline-click.test.tsx +39 -38
  32. package/tests/e2e/MarkdownEditor/jsx-selection.test.tsx +28 -27
  33. package/tests/e2e/MarkdownEditor/link-placeholder.test.tsx +49 -48
  34. package/tests/e2e/MarkdownEditor/media-align.test.tsx +40 -39
  35. package/tests/e2e/MarkdownEditor/media-edit.test.tsx +49 -48
  36. package/tests/e2e/MarkdownEditor/media-sizing.test.tsx +108 -107
  37. package/tests/e2e/MarkdownEditor/modes.test.tsx +79 -78
  38. package/tests/e2e/MarkdownEditor/onimage-parity.test.tsx +29 -28
  39. package/tests/e2e/MarkdownEditor/regressions.test.tsx +194 -193
  40. package/tests/e2e/MarkdownEditor/slash-commands.test.tsx +81 -80
  41. package/tests/e2e/MarkdownEditor/table-click.test.tsx +36 -35
  42. package/tests/e2e/MarkdownEditor/table-controls.test.tsx +44 -43
  43. package/tests/e2e/MarkdownEditor/table-format.test.tsx +28 -27
  44. package/tests/e2e/MarkdownEditor/undo-redo.test.tsx +22 -21
  45. package/tests/e2e/MarkdownViewer/parity.test.tsx +144 -143
  46. package/tests/e2e/MarkdownViewer/render.test.tsx +20 -19
  47. package/tests/e2e/Stacker/stacker.test.tsx +304 -0
  48. package/tests/unit/Editor/helpers.test.ts +26 -26
  49. package/tests/unit/MarkdownConformance/error-isolation.test.tsx +26 -25
  50. package/tests/unit/MarkdownConformance/indentation.test.tsx +20 -19
  51. package/tests/unit/MarkdownConformance/inline-code-fuzz.test.tsx +255 -0
  52. package/tests/unit/MarkdownConformance/jsx-attributes.test.tsx +39 -38
  53. package/tests/unit/MarkdownConformance/jsx-fuzz.test.tsx +122 -122
  54. package/tests/unit/MarkdownConformance/links.test.tsx +78 -54
  55. package/tests/unit/MarkdownConformance/render-helpers.tsx +60 -60
  56. package/tests/unit/MarkdownEditor/code-info.test.ts +58 -58
  57. package/tests/unit/MarkdownEditor/decorations.test.ts +423 -421
  58. package/tests/unit/MarkdownEditor/editor-ready.test.ts +22 -22
  59. package/tests/unit/MarkdownEditor/html-descriptors.test.ts +70 -70
  60. package/tests/unit/MarkdownEditor/indented-fence.test.ts +80 -80
  61. package/tests/unit/MarkdownEditor/jsx-attr-scan.test.ts +87 -87
  62. package/tests/unit/MarkdownEditor/jsx-block-widget.test.ts +51 -51
  63. package/tests/unit/MarkdownEditor/jsx-tag-grammar.test.ts +63 -63
  64. package/tests/unit/MarkdownEditor/list-indent.test.ts +67 -67
  65. package/tests/unit/MarkdownEditor/slash-commands.test.ts +150 -150
  66. package/tests/unit/MarkdownEditor/table-format.test.ts +67 -67
  67. package/tests/unit/MarkdownEditor/table.test.ts +96 -96
  68. package/tests/unit/MarkdownEditor/triggers.test.ts +167 -167
  69. package/tests/unit/MarkdownEditor/widget-store.test.ts +90 -90
  70. package/tests/unit/MarkdownViewer/callout.test.tsx +42 -42
  71. package/tests/unit/MarkdownViewer/code-highlight.test.tsx +41 -41
  72. package/tests/unit/MarkdownViewer/code-title.test.tsx +45 -45
  73. package/tests/unit/MarkdownViewer/features.test.tsx +129 -129
  74. package/tests/unit/MarkdownViewer/headings.test.tsx +28 -28
  75. package/tests/unit/MarkdownViewer/indented-jsx-children.test.tsx +53 -53
  76. package/tests/unit/MarkdownViewer/jsx-block-nesting.test.tsx +137 -137
  77. package/tests/unit/MarkdownViewer/jsx.test.tsx +188 -188
  78. package/tests/unit/MarkdownViewer/list-bullets.test.tsx +31 -31
  79. package/tests/unit/MarkdownViewer/list-code.test.tsx +45 -45
  80. package/tests/unit/MarkdownViewer/preserve-newlines.test.tsx +109 -109
  81. package/tests/unit/MarkdownViewer/read-time.test.ts +13 -13
  82. package/tests/unit/MarkdownViewer/renderers.test.tsx +75 -75
  83. package/tests/unit/MarkdownViewer/runnable.test.tsx +51 -51
  84. package/tests/unit/MarkdownViewer/ssr.test.tsx +56 -55
  85. package/tests/unit/MarkdownViewer/syntax-highlighting.test.tsx +43 -42
  86. package/tests/unit/MarkdownViewer/tabs-fenced-code.test.tsx +120 -120
  87. package/tests/unit/MarkdownViewer/tabs.test.tsx +83 -82
  88. package/tests/unit/Stacker/drag.test.ts +21 -0
  89. package/tests/unit/Stacker/helpers.test.ts +105 -0
  90. package/tests/unit/Stacker/reducer.test.ts +107 -0
  91. package/tools/duplicate-vector-mappings.json +141 -141
  92. package/tools/glass-export-overrides.json +6 -6
  93. package/tools/icon-parser.ts +41 -40
  94. package/tools/integration-gradient-exports.json +55 -55
  95. package/tools/picto-compare.ts +32 -32
  96. package/tools/picto-merge-urls.ts +19 -19
  97. package/tools/picto-sync.ts +543 -543
  98. package/tools/picto-verify-gradients.ts +50 -50
  99. package/tools/vector-export-nodes.json +975 -975
@@ -2,18 +2,18 @@ import { syntaxTree } from "@codemirror/language";
2
2
  import { EditorSelection, EditorState } from "@codemirror/state";
3
3
  import { type Decoration, type DecorationSet, EditorView } from "@codemirror/view";
4
4
  import {
5
- DEFAULT_MARKDOWN_MODE,
6
- type MarkdownEditMode,
7
- markdownModeField,
8
- setMarkdownMode,
5
+ DEFAULT_MARKDOWN_MODE,
6
+ type MarkdownEditMode,
7
+ markdownModeField,
8
+ setMarkdownMode,
9
9
  } from "@src/lib/markdown";
10
10
  import { markdownDecorations } from "@src/lib/markdown/editor/decorations";
11
11
  import { setMarkdownEditorFocused } from "@src/lib/markdown/editor/decorations/editor-focus";
12
12
  import { CalloutHeaderWidget } from "@src/lib/markdown/editor/widgets/callout-header-widget";
13
13
  import { CheckboxWidget } from "@src/lib/markdown/editor/widgets/checkbox-widget";
14
14
  import {
15
- CodeFenceCopyWidget,
16
- CodeFenceLanguageMenuWidget,
15
+ CodeFenceCopyWidget,
16
+ CodeFenceLanguageMenuWidget,
17
17
  } from "@src/lib/markdown/editor/widgets/code-fence-widget";
18
18
  import { CustomCodeBlockWidget } from "@src/lib/markdown/editor/widgets/custom-code-block-widget";
19
19
  import { HeadingFoldWidget } from "@src/lib/markdown/editor/widgets/heading-fold-widget";
@@ -24,465 +24,467 @@ import { Callout } from "@src/lib/markdown/grammar/callout";
24
24
  import { createMarkdownLanguage } from "@src/lib/markdown/grammar/language";
25
25
  import type { CodeRenderer } from "@src/lib/markdown/types";
26
26
  import { describe, expect, it } from "vitest";
27
+
27
28
  import { md } from "../../_setup/markdown-classes";
28
29
 
29
30
  interface DecorationDescriptor {
30
- from: number;
31
- to: number;
32
- spec: ReturnType<Decoration["spec"]> | Decoration["spec"];
31
+ from: number;
32
+ to: number;
33
+ spec: ReturnType<Decoration["spec"]> | Decoration["spec"];
33
34
  }
34
35
 
35
36
  function createView(
36
- doc: string,
37
- mode: MarkdownEditMode = DEFAULT_MARKDOWN_MODE,
38
- options: { codeRenderers?: Record<string, CodeRenderer> } = {},
37
+ doc: string,
38
+ mode: MarkdownEditMode = DEFAULT_MARKDOWN_MODE,
39
+ options: { codeRenderers?: Record<string, CodeRenderer> } = {},
39
40
  ): EditorView {
40
- const widgetStore = createWidgetStore();
41
- const state = EditorState.create({
42
- doc,
43
- extensions: [
44
- createMarkdownLanguage({ extensions: [Callout] }),
45
- markdownModeField,
46
- markdownDecorations({ widgetStore, codeRenderers: options.codeRenderers }),
47
- ],
48
- });
49
-
50
- const parent = document.createElement("div");
51
- document.body.appendChild(parent);
52
-
53
- const view = new EditorView({
54
- state,
55
- parent,
56
- });
57
-
58
- if (mode !== DEFAULT_MARKDOWN_MODE) {
59
- view.dispatch({ effects: setMarkdownMode.of(mode) });
60
- }
61
-
62
- view.focus();
63
- view.dispatch({ effects: setMarkdownEditorFocused.of(true) });
64
-
65
- return view;
41
+ const widgetStore = createWidgetStore();
42
+ const state = EditorState.create({
43
+ doc,
44
+ extensions: [
45
+ createMarkdownLanguage({ extensions: [Callout] }),
46
+ markdownModeField,
47
+ markdownDecorations({ widgetStore, codeRenderers: options.codeRenderers }),
48
+ ],
49
+ });
50
+
51
+ const parent = document.createElement("div");
52
+ document.body.appendChild(parent);
53
+
54
+ const view = new EditorView({
55
+ state,
56
+ parent,
57
+ });
58
+
59
+ if (mode !== DEFAULT_MARKDOWN_MODE) {
60
+ view.dispatch({ effects: setMarkdownMode.of(mode) });
61
+ }
62
+
63
+ view.focus();
64
+ view.dispatch({ effects: setMarkdownEditorFocused.of(true) });
65
+
66
+ return view;
66
67
  }
67
68
 
68
69
  function getDecorations(view: EditorView): DecorationDescriptor[] {
69
- const out: DecorationDescriptor[] = [];
70
- for (const value of (view as unknown as { docView: { decorations: DecorationSet[] } }).docView
71
- .decorations) {
72
- const cursor = value.iter();
73
- while (cursor.value) {
74
- out.push({ from: cursor.from, to: cursor.to, spec: cursor.value.spec });
75
- cursor.next();
76
- }
77
- }
78
- return out;
70
+ const out: DecorationDescriptor[] = [];
71
+ for (const value of (view as unknown as { docView: { decorations: DecorationSet[] } }).docView
72
+ .decorations) {
73
+ const cursor = value.iter();
74
+ while (cursor.value) {
75
+ out.push({ from: cursor.from, to: cursor.to, spec: cursor.value.spec });
76
+ cursor.next();
77
+ }
78
+ }
79
+ return out;
79
80
  }
80
81
 
81
82
  function collectWidgets(view: EditorView): unknown[] {
82
- return getDecorations(view)
83
- .map((d) => (d.spec as { widget?: unknown }).widget)
84
- .filter((value): value is object => value != null);
83
+ return getDecorations(view)
84
+ .map((d) => (d.spec as { widget?: unknown }).widget)
85
+ .filter((value): value is object => value != null);
85
86
  }
86
87
 
87
88
  function getDecorationClasses(view: EditorView): string[] {
88
- return getDecorations(view)
89
- .map((d) => (d.spec as { class?: string }).class)
90
- .filter((value): value is string => typeof value === "string");
89
+ return getDecorations(view)
90
+ .map((d) => (d.spec as { class?: string }).class)
91
+ .filter((value): value is string => typeof value === "string");
91
92
  }
92
93
 
93
94
  function getHiddenRanges(view: EditorView): Array<{ from: number; to: number }> {
94
- return getDecorations(view)
95
- .filter((d) => (d.spec as { class?: string }).class === md.markHidden)
96
- .map((d) => ({ from: d.from, to: d.to }));
95
+ return getDecorations(view)
96
+ .filter((d) => (d.spec as { class?: string }).class === md.markHidden)
97
+ .map((d) => ({ from: d.from, to: d.to }));
97
98
  }
98
99
 
99
100
  describe("CheckboxWidget", () => {
100
- it("compare is referential so TileCache must not skip updateDOM for a fresh instance", () => {
101
- const store = createWidgetStore();
102
- const a = new CheckboxWidget(
103
- { id: "checkbox:10-13", checked: true, markerFrom: 11, markerTo: 12 },
104
- store,
105
- );
106
- const b = new CheckboxWidget(
107
- { id: "checkbox:10-13", checked: true, markerFrom: 11, markerTo: 12 },
108
- store,
109
- );
110
- expect(a.compare(b)).toBe(false);
111
- expect(a.compare(a)).toBe(true);
112
- });
101
+ it("compare is referential so TileCache must not skip updateDOM for a fresh instance", () => {
102
+ const store = createWidgetStore();
103
+ const a = new CheckboxWidget(
104
+ { id: "checkbox:10-13", checked: true, markerFrom: 11, markerTo: 12 },
105
+ store,
106
+ );
107
+ const b = new CheckboxWidget(
108
+ { id: "checkbox:10-13", checked: true, markerFrom: 11, markerTo: 12 },
109
+ store,
110
+ );
111
+ expect(a.compare(b)).toBe(false);
112
+ expect(a.compare(a)).toBe(true);
113
+ });
113
114
  });
114
115
 
115
116
  describe("markdownDecorations", () => {
116
- it("emits heading line classes", () => {
117
- const view = createView("# Hello world\n\nbody");
118
- const classes = getDecorationClasses(view);
119
- expect(classes.some((c) => c.includes(md.h1))).toBe(true);
120
- view.destroy();
121
- });
122
-
123
- it("hides heading marks when block is inactive in preview mode", () => {
124
- const view = createView("# Title\n\nparagraph after the cursor");
125
- view.dispatch({ selection: EditorSelection.cursor(view.state.doc.length) });
126
- const classes = getDecorationClasses(view);
127
- expect(classes).toContain(md.markHidden);
128
- view.destroy();
129
- });
130
-
131
- it("shows heading marks when cursor is on the heading line in preview mode", () => {
132
- const view = createView("# Title\n");
133
- view.dispatch({ selection: EditorSelection.cursor(3) });
134
- const classes = getDecorationClasses(view);
135
- expect(classes).toContain(md.markShown);
136
- expect(classes).not.toContain(md.markHidden);
137
- view.destroy();
138
- });
139
-
140
- it("hides heading hashes in preview when unfocused even if the caret is on the heading line", () => {
141
- const view = createView("# Title\n");
142
- view.dispatch({
143
- selection: EditorSelection.cursor(3),
144
- effects: setMarkdownEditorFocused.of(false),
145
- });
146
- const classes = getDecorationClasses(view);
147
- expect(classes).toContain(md.markHidden);
148
- expect(classes).not.toContain(md.markShown);
149
- view.destroy();
150
- });
151
-
152
- it("hides custom {#id} heading suffix when the heading line is inactive in preview mode", () => {
153
- const doc = "## Playground {#playground-anchor}\n\nbody";
154
- const view = createView(doc);
155
- view.dispatch({ selection: EditorSelection.cursor(doc.length) });
156
- const hidden = getHiddenRanges(view);
157
- const suffixFrom = doc.indexOf("{#playground-anchor}");
158
- const suffixTo = suffixFrom + "{#playground-anchor}".length;
159
- expect(hidden.some((r) => r.from <= suffixFrom && r.to >= suffixTo)).toBe(true);
160
- view.destroy();
161
- });
162
-
163
- it("shows custom {#id} heading suffix when the heading line is active in preview mode", () => {
164
- const doc = "## Playground {#playground-anchor}\n";
165
- const view = createView(doc);
166
- view.dispatch({ selection: EditorSelection.cursor(doc.indexOf("Playground") + 2) });
167
- const classes = getDecorationClasses(view);
168
- expect(classes).toContain(md.markShown);
169
- view.destroy();
170
- });
171
-
172
- it("does not emit hidden marks in source mode", () => {
173
- const view = createView("# Title\n\n**bold**", "source");
174
- const classes = getDecorationClasses(view);
175
- expect(classes).not.toContain(md.markHidden);
176
- view.destroy();
177
- });
178
-
179
- it("reveals inline strong markers only when caret intersects the node", () => {
180
- // Document: "before **bold** after"
181
- // positions: 0 4 8 10 14 16 ...
182
- const view = createView("before **bold** after");
183
- // Caret on the word "bold" (inside the StrongEmphasis node).
184
- view.dispatch({ selection: EditorSelection.cursor(11) });
185
- let classes = getDecorationClasses(view);
186
- expect(classes).toContain(md.markShown);
187
- expect(classes).not.toContain(md.markHidden);
188
-
189
- // Caret elsewhere on the same line but outside the **bold** node.
190
- view.dispatch({ selection: EditorSelection.cursor(2) });
191
- classes = getDecorationClasses(view);
192
- expect(classes).toContain(md.markHidden);
193
- expect(classes).not.toContain(md.markShown);
194
-
195
- view.destroy();
196
- });
197
-
198
- it("keeps inline source visible when caret sits at the node boundary", () => {
199
- const view = createView("before **bold** after");
200
- // The StrongEmphasis node spans positions 7..15 (inclusive of `**`).
201
- // Caret immediately before the opening `**` should still reveal it.
202
- view.dispatch({ selection: EditorSelection.cursor(7) });
203
- let classes = getDecorationClasses(view);
204
- expect(classes).toContain(md.markShown);
205
- expect(classes).not.toContain(md.markHidden);
206
-
207
- // And likewise immediately after the closing `**`.
208
- view.dispatch({ selection: EditorSelection.cursor(15) });
209
- classes = getDecorationClasses(view);
210
- expect(classes).toContain(md.markShown);
211
- expect(classes).not.toContain(md.markHidden);
212
-
213
- view.destroy();
214
- });
215
-
216
- it("does not emit a ListBulletWidget for task list items in preview mode", () => {
217
- // Cursor on line 1 keeps the list line (line 3) inactive so that
218
- // both the bullet and checkbox widgets would normally render.
219
- const view = createView("body\n\n- [ ] item");
220
- view.dispatch({ selection: EditorSelection.cursor(0) });
221
-
222
- const widgets = collectWidgets(view);
223
- expect(widgets.some((w) => w instanceof CheckboxWidget)).toBe(true);
224
- expect(widgets.some((w) => w instanceof ListBulletWidget)).toBe(false);
225
-
226
- view.destroy();
227
- });
228
-
229
- it("still emits a ListBulletWidget for plain bullet list items", () => {
230
- const view = createView("body\n\n- item");
231
- view.dispatch({ selection: EditorSelection.cursor(0) });
232
-
233
- const widgets = collectWidgets(view);
234
- expect(widgets.some((w) => w instanceof ListBulletWidget)).toBe(true);
235
- expect(widgets.some((w) => w instanceof CheckboxWidget)).toBe(false);
236
-
237
- view.destroy();
238
- });
239
-
240
- it("keeps ListBulletWidget when the caret is in the list item body in preview mode", () => {
241
- const view = createView("- item\n");
242
- const listLine = view.state.doc.line(1);
243
- const caretInBody = listLine.from + "- item".indexOf("i");
244
- view.dispatch({ selection: EditorSelection.cursor(caretInBody) });
245
-
246
- expect(collectWidgets(view).some((w) => w instanceof ListBulletWidget)).toBe(true);
247
-
248
- view.destroy();
249
- });
250
-
251
- it("omits ListBulletWidget when the caret is inside the list mark in preview mode", () => {
252
- const view = createView("- item\n");
253
- let listMarkFrom = -1;
254
- syntaxTree(view.state).iterate({
255
- from: 0,
256
- to: view.state.doc.length,
257
- enter(n) {
258
- if (n.name === "ListMark") listMarkFrom = n.from;
259
- },
260
- });
261
- expect(listMarkFrom).toBeGreaterThanOrEqual(0);
262
- view.dispatch({ selection: EditorSelection.cursor(listMarkFrom + 1) });
263
- view.dispatch({ selection: EditorSelection.cursor(listMarkFrom) });
264
-
265
- expect(collectWidgets(view).some((w) => w instanceof ListBulletWidget)).toBe(false);
266
-
267
- view.destroy();
268
- });
269
-
270
- it("keeps CheckboxWidget when the caret is in the task item body in preview mode", () => {
271
- const view = createView("- [ ] task\n");
272
- const listLine = view.state.doc.line(1);
273
- const caretInBody = listLine.from + "- [ ] task".indexOf("t");
274
- view.dispatch({ selection: EditorSelection.cursor(caretInBody) });
275
-
276
- expect(collectWidgets(view).some((w) => w instanceof CheckboxWidget)).toBe(true);
277
-
278
- view.destroy();
279
- });
280
-
281
- it("omits CheckboxWidget when the caret is inside the task marker in preview mode", () => {
282
- const view = createView("- [ ] task\n");
283
- const bracketPos = view.state.doc.toString().indexOf("[");
284
- view.dispatch({ selection: EditorSelection.cursor(bracketPos) });
285
-
286
- expect(collectWidgets(view).some((w) => w instanceof CheckboxWidget)).toBe(false);
287
-
288
- view.destroy();
289
- });
290
-
291
- it("does not emit HeadingFoldWidget in preview or source mode", () => {
292
- const preview = createView("# Heading\n\nbody", "preview");
293
- expect(collectWidgets(preview).some((w) => w instanceof HeadingFoldWidget)).toBe(false);
294
- preview.destroy();
295
-
296
- const source = createView("# Heading\n\nbody", "source");
297
- expect(collectWidgets(source).some((w) => w instanceof HeadingFoldWidget)).toBe(false);
298
- source.destroy();
299
- });
300
-
301
- it("does not emit a CalloutHeaderWidget when the caret is on the callout title row", () => {
302
- // `> [!warning] Heads up\n> body`
303
- // Position 2 is `[`; position 13 is somewhere inside `Heads up`.
304
- const view = createView("> [!warning] Heads up\n> body");
305
- view.dispatch({ selection: EditorSelection.cursor(13) });
306
-
307
- const widgets = collectWidgets(view);
308
- expect(widgets.some((w) => w instanceof CalloutHeaderWidget)).toBe(false);
309
-
310
- view.destroy();
311
- });
312
-
313
- it("emits a CalloutHeaderWidget when the caret is far from the callout", () => {
314
- const view = createView("body line\n\n> [!warning] Heads up\n> inside");
315
- view.dispatch({ selection: EditorSelection.cursor(0) });
316
-
317
- const widgets = collectWidgets(view);
318
- expect(widgets.some((w) => w instanceof CalloutHeaderWidget)).toBe(true);
319
-
320
- view.destroy();
321
- });
322
-
323
- it("does not emit the CalloutHeaderWidget when the caret sits at the title's end-of-line", () => {
324
- // Caret at the end of the first line (right after `Heads up`)
325
- // should still count as touching the header range.
326
- const view = createView("> [!warning] Heads up\n> body");
327
- const eol = view.state.doc.line(1).to;
328
- view.dispatch({ selection: EditorSelection.cursor(eol) });
329
-
330
- const widgets = collectWidgets(view);
331
- expect(widgets.some((w) => w instanceof CalloutHeaderWidget)).toBe(false);
332
-
333
- view.destroy();
334
- });
335
-
336
- it("does not emit a CalloutHeaderWidget when the caret is anywhere inside the callout block", () => {
337
- const view = createView("> [!warning] Heads up\n> body");
338
- const bodyStart = view.state.doc.line(2).from + 2;
339
- view.dispatch({ selection: EditorSelection.cursor(bodyStart) });
340
-
341
- const widgets = collectWidgets(view);
342
- expect(widgets.some((w) => w instanceof CalloutHeaderWidget)).toBe(false);
343
-
344
- view.destroy();
345
- });
346
-
347
- it("emits blockquote / callout line decorations", () => {
348
- const view = createView("> [!warning] Heads up\n> body");
349
- const decorations = getDecorations(view);
350
- const callout = decorations.find(
351
- (d) => (d.spec as { class?: string }).class === md.callout,
352
- );
353
- expect(callout).toBeDefined();
354
- expect(
355
- (callout?.spec as { attributes?: Record<string, string> }).attributes?.[
356
- "data-callout-kind"
357
- ],
358
- ).toBe("warning");
359
- view.destroy();
360
- });
117
+ it("emits heading line classes", () => {
118
+ const view = createView("# Hello world\n\nbody");
119
+ const classes = getDecorationClasses(view);
120
+ expect(classes.some((c) => c.includes(md.h1))).toBe(true);
121
+ view.destroy();
122
+ });
123
+
124
+ it("hides heading marks when block is inactive in preview mode", () => {
125
+ const view = createView("# Title\n\nparagraph after the cursor");
126
+ view.dispatch({ selection: EditorSelection.cursor(view.state.doc.length) });
127
+ const classes = getDecorationClasses(view);
128
+ expect(classes).toContain(md.markHidden);
129
+ view.destroy();
130
+ });
131
+
132
+ it("shows heading marks when cursor is on the heading line in preview mode", () => {
133
+ const view = createView("# Title\n");
134
+ view.dispatch({ selection: EditorSelection.cursor(3) });
135
+ const classes = getDecorationClasses(view);
136
+ expect(classes).toContain(md.markShown);
137
+ expect(classes).not.toContain(md.markHidden);
138
+ view.destroy();
139
+ });
140
+
141
+ it("hides heading hashes in preview when unfocused even if the caret is on the heading line", () => {
142
+ const view = createView("# Title\n");
143
+ view.dispatch({
144
+ selection: EditorSelection.cursor(3),
145
+ effects: setMarkdownEditorFocused.of(false),
146
+ });
147
+ const classes = getDecorationClasses(view);
148
+ expect(classes).toContain(md.markHidden);
149
+ expect(classes).not.toContain(md.markShown);
150
+ view.destroy();
151
+ });
152
+
153
+ it("hides custom {#id} heading suffix when the heading line is inactive in preview mode", () => {
154
+ const doc = "## Playground {#playground-anchor}\n\nbody";
155
+ const view = createView(doc);
156
+ view.dispatch({ selection: EditorSelection.cursor(doc.length) });
157
+ const hidden = getHiddenRanges(view);
158
+ const suffixFrom = doc.indexOf("{#playground-anchor}");
159
+ const suffixTo = suffixFrom + "{#playground-anchor}".length;
160
+ expect(hidden.some((r) => r.from <= suffixFrom && r.to >= suffixTo)).toBe(true);
161
+ view.destroy();
162
+ });
163
+
164
+ it("shows custom {#id} heading suffix when the heading line is active in preview mode", () => {
165
+ const doc = "## Playground {#playground-anchor}\n";
166
+ const view = createView(doc);
167
+ view.dispatch({ selection: EditorSelection.cursor(doc.indexOf("Playground") + 2) });
168
+ const classes = getDecorationClasses(view);
169
+ expect(classes).toContain(md.markShown);
170
+ view.destroy();
171
+ });
172
+
173
+ it("does not emit hidden marks in source mode", () => {
174
+ const view = createView("# Title\n\n**bold**", "source");
175
+ const classes = getDecorationClasses(view);
176
+ expect(classes).not.toContain(md.markHidden);
177
+ view.destroy();
178
+ });
179
+
180
+ it("reveals inline strong markers only when caret intersects the node", () => {
181
+ // Document: "before **bold** after"
182
+ // positions: 0 4 8 10 14 16 ...
183
+ const view = createView("before **bold** after");
184
+ // Caret on the word "bold" (inside the StrongEmphasis node).
185
+ view.dispatch({ selection: EditorSelection.cursor(11) });
186
+ let classes = getDecorationClasses(view);
187
+ expect(classes).toContain(md.markShown);
188
+ expect(classes).not.toContain(md.markHidden);
189
+
190
+ // Caret elsewhere on the same line but outside the **bold** node.
191
+ view.dispatch({ selection: EditorSelection.cursor(2) });
192
+ classes = getDecorationClasses(view);
193
+ expect(classes).toContain(md.markHidden);
194
+ expect(classes).not.toContain(md.markShown);
195
+
196
+ view.destroy();
197
+ });
198
+
199
+ it("keeps inline source visible when caret sits at the node boundary", () => {
200
+ const view = createView("before **bold** after");
201
+ // The StrongEmphasis node spans positions 7..15 (inclusive of `**`).
202
+ // Caret immediately before the opening `**` should still reveal it.
203
+ view.dispatch({ selection: EditorSelection.cursor(7) });
204
+ let classes = getDecorationClasses(view);
205
+ expect(classes).toContain(md.markShown);
206
+ expect(classes).not.toContain(md.markHidden);
207
+
208
+ // And likewise immediately after the closing `**`.
209
+ view.dispatch({ selection: EditorSelection.cursor(15) });
210
+ classes = getDecorationClasses(view);
211
+ expect(classes).toContain(md.markShown);
212
+ expect(classes).not.toContain(md.markHidden);
213
+
214
+ view.destroy();
215
+ });
216
+
217
+ it("does not emit a ListBulletWidget for task list items in preview mode", () => {
218
+ // Cursor on line 1 keeps the list line (line 3) inactive so that
219
+ // both the bullet and checkbox widgets would normally render.
220
+ const view = createView("body\n\n- [ ] item");
221
+ view.dispatch({ selection: EditorSelection.cursor(0) });
222
+
223
+ const widgets = collectWidgets(view);
224
+ expect(widgets.some((w) => w instanceof CheckboxWidget)).toBe(true);
225
+ expect(widgets.some((w) => w instanceof ListBulletWidget)).toBe(false);
226
+
227
+ view.destroy();
228
+ });
229
+
230
+ it("still emits a ListBulletWidget for plain bullet list items", () => {
231
+ const view = createView("body\n\n- item");
232
+ view.dispatch({ selection: EditorSelection.cursor(0) });
233
+
234
+ const widgets = collectWidgets(view);
235
+ expect(widgets.some((w) => w instanceof ListBulletWidget)).toBe(true);
236
+ expect(widgets.some((w) => w instanceof CheckboxWidget)).toBe(false);
237
+
238
+ view.destroy();
239
+ });
240
+
241
+ it("keeps ListBulletWidget when the caret is in the list item body in preview mode", () => {
242
+ const view = createView("- item\n");
243
+ const listLine = view.state.doc.line(1);
244
+ const caretInBody = listLine.from + "- item".indexOf("i");
245
+ view.dispatch({ selection: EditorSelection.cursor(caretInBody) });
246
+
247
+ expect(collectWidgets(view).some((w) => w instanceof ListBulletWidget)).toBe(true);
248
+
249
+ view.destroy();
250
+ });
251
+
252
+ it("omits ListBulletWidget when the caret is inside the list mark in preview mode", () => {
253
+ const view = createView("- item\n");
254
+ let listMarkFrom = -1;
255
+ syntaxTree(view.state).iterate({
256
+ from: 0,
257
+ to: view.state.doc.length,
258
+ enter(n) {
259
+ if (n.name === "ListMark") listMarkFrom = n.from;
260
+ },
261
+ });
262
+ expect(listMarkFrom).toBeGreaterThanOrEqual(0);
263
+ view.dispatch({ selection: EditorSelection.cursor(listMarkFrom + 1) });
264
+ view.dispatch({ selection: EditorSelection.cursor(listMarkFrom) });
265
+
266
+ expect(collectWidgets(view).some((w) => w instanceof ListBulletWidget)).toBe(false);
267
+
268
+ view.destroy();
269
+ });
270
+
271
+ it("keeps CheckboxWidget when the caret is in the task item body in preview mode", () => {
272
+ const view = createView("- [ ] task\n");
273
+ const listLine = view.state.doc.line(1);
274
+ const caretInBody = listLine.from + "- [ ] task".indexOf("t");
275
+ view.dispatch({ selection: EditorSelection.cursor(caretInBody) });
276
+
277
+ expect(collectWidgets(view).some((w) => w instanceof CheckboxWidget)).toBe(true);
278
+
279
+ view.destroy();
280
+ });
281
+
282
+ it("omits CheckboxWidget when the caret is inside the task marker in preview mode", () => {
283
+ const view = createView("- [ ] task\n");
284
+ const bracketPos = view.state.doc.toString().indexOf("[");
285
+ view.dispatch({ selection: EditorSelection.cursor(bracketPos) });
286
+
287
+ expect(collectWidgets(view).some((w) => w instanceof CheckboxWidget)).toBe(false);
288
+
289
+ view.destroy();
290
+ });
291
+
292
+ it("does not emit HeadingFoldWidget in preview or source mode", () => {
293
+ const preview = createView("# Heading\n\nbody", "preview");
294
+ expect(collectWidgets(preview).some((w) => w instanceof HeadingFoldWidget)).toBe(false);
295
+ preview.destroy();
296
+
297
+ const source = createView("# Heading\n\nbody", "source");
298
+ expect(collectWidgets(source).some((w) => w instanceof HeadingFoldWidget)).toBe(false);
299
+ source.destroy();
300
+ });
301
+
302
+ it("does not emit a CalloutHeaderWidget when the caret is on the callout title row", () => {
303
+ // `> [!warning] Heads up\n> body`
304
+ // Position 2 is `[`; position 13 is somewhere inside `Heads up`.
305
+ const view = createView("> [!warning] Heads up\n> body");
306
+ view.dispatch({ selection: EditorSelection.cursor(13) });
307
+
308
+ const widgets = collectWidgets(view);
309
+ expect(widgets.some((w) => w instanceof CalloutHeaderWidget)).toBe(false);
310
+
311
+ view.destroy();
312
+ });
313
+
314
+ it("emits a CalloutHeaderWidget when the caret is far from the callout", () => {
315
+ const view = createView("body line\n\n> [!warning] Heads up\n> inside");
316
+ view.dispatch({ selection: EditorSelection.cursor(0) });
317
+
318
+ const widgets = collectWidgets(view);
319
+ expect(widgets.some((w) => w instanceof CalloutHeaderWidget)).toBe(true);
320
+
321
+ view.destroy();
322
+ });
323
+
324
+ it("does not emit the CalloutHeaderWidget when the caret sits at the title's end-of-line", () => {
325
+ // Caret at the end of the first line (right after `Heads up`)
326
+ // should still count as touching the header range.
327
+ const view = createView("> [!warning] Heads up\n> body");
328
+ const eol = view.state.doc.line(1).to;
329
+ view.dispatch({ selection: EditorSelection.cursor(eol) });
330
+
331
+ const widgets = collectWidgets(view);
332
+ expect(widgets.some((w) => w instanceof CalloutHeaderWidget)).toBe(false);
333
+
334
+ view.destroy();
335
+ });
336
+
337
+ it("does not emit a CalloutHeaderWidget when the caret is anywhere inside the callout block", () => {
338
+ const view = createView("> [!warning] Heads up\n> body");
339
+ const bodyStart = view.state.doc.line(2).from + 2;
340
+ view.dispatch({ selection: EditorSelection.cursor(bodyStart) });
341
+
342
+ const widgets = collectWidgets(view);
343
+ expect(widgets.some((w) => w instanceof CalloutHeaderWidget)).toBe(false);
344
+
345
+ view.destroy();
346
+ });
347
+
348
+ it("emits blockquote / callout line decorations", () => {
349
+ const view = createView("> [!warning] Heads up\n> body");
350
+ const decorations = getDecorations(view);
351
+ const callout = decorations.find(
352
+ (d) => (d.spec as { class?: string }).class === md.callout,
353
+ );
354
+ expect(callout).toBeDefined();
355
+ expect(
356
+ // oxlint-disable-next-line no-unsafe-optional-chaining
357
+ (callout?.spec as { attributes?: Record<string, string> }).attributes?.[
358
+ "data-callout-kind"
359
+ ],
360
+ ).toBe("warning");
361
+ view.destroy();
362
+ });
361
363
  });
362
364
 
363
365
  describe("markdownDecorations / GFM tables", () => {
364
- const TABLE = `| Feature | Status |
366
+ const TABLE = `| Feature | Status |
365
367
  | --- | --- |
366
368
  | Headers | Live |`;
367
369
 
368
- it("emits TableWidget when the table block is inactive in preview mode", () => {
369
- const view = createView(`intro\n\n${TABLE}\n\nafter`);
370
- view.dispatch({ selection: EditorSelection.cursor(0) });
370
+ it("emits TableWidget when the table block is inactive in preview mode", () => {
371
+ const view = createView(`intro\n\n${TABLE}\n\nafter`);
372
+ view.dispatch({ selection: EditorSelection.cursor(0) });
371
373
 
372
- expect(collectWidgets(view).some((w) => w instanceof TableWidget)).toBe(true);
374
+ expect(collectWidgets(view).some((w) => w instanceof TableWidget)).toBe(true);
373
375
 
374
- view.destroy();
375
- });
376
+ view.destroy();
377
+ });
376
378
 
377
- it("omits TableWidget when the caret is inside the table block in preview mode", () => {
378
- const view = createView(TABLE);
379
- const inside = view.state.doc.toString().indexOf("Feature") + 2;
380
- view.dispatch({ selection: EditorSelection.cursor(inside) });
379
+ it("omits TableWidget when the caret is inside the table block in preview mode", () => {
380
+ const view = createView(TABLE);
381
+ const inside = view.state.doc.toString().indexOf("Feature") + 2;
382
+ view.dispatch({ selection: EditorSelection.cursor(inside) });
381
383
 
382
- expect(collectWidgets(view).some((w) => w instanceof TableWidget)).toBe(false);
384
+ expect(collectWidgets(view).some((w) => w instanceof TableWidget)).toBe(false);
383
385
 
384
- view.destroy();
385
- });
386
+ view.destroy();
387
+ });
386
388
 
387
- it("does not emit TableWidget in source mode", () => {
388
- const view = createView(TABLE, "source");
389
- expect(collectWidgets(view).some((w) => w instanceof TableWidget)).toBe(false);
390
- view.destroy();
391
- });
389
+ it("does not emit TableWidget in source mode", () => {
390
+ const view = createView(TABLE, "source");
391
+ expect(collectWidgets(view).some((w) => w instanceof TableWidget)).toBe(false);
392
+ view.destroy();
393
+ });
392
394
 
393
- it("applies monospace to the table and colours delimiters when the block is active", () => {
394
- const view = createView(TABLE);
395
- const inside = view.state.doc.toString().indexOf("Feature") + 2;
396
- view.dispatch({ selection: EditorSelection.cursor(inside) });
395
+ it("applies monospace to the table and colours delimiters when the block is active", () => {
396
+ const view = createView(TABLE);
397
+ const inside = view.state.doc.toString().indexOf("Feature") + 2;
398
+ view.dispatch({ selection: EditorSelection.cursor(inside) });
397
399
 
398
- const classes = getDecorationClasses(view);
399
- expect(classes).toContain(md.tableSource);
400
- expect(classes).toContain(md.tableDelimiter);
400
+ const classes = getDecorationClasses(view);
401
+ expect(classes).toContain(md.tableSource);
402
+ expect(classes).toContain(md.tableDelimiter);
401
403
 
402
- view.destroy();
403
- });
404
+ view.destroy();
405
+ });
404
406
 
405
- it("applies monospace but not delimiter colour when the block is inactive in preview mode", () => {
406
- const view = createView(`intro\n\n${TABLE}\n\nafter`);
407
- view.dispatch({ selection: EditorSelection.cursor(0) });
407
+ it("applies monospace but not delimiter colour when the block is inactive in preview mode", () => {
408
+ const view = createView(`intro\n\n${TABLE}\n\nafter`);
409
+ view.dispatch({ selection: EditorSelection.cursor(0) });
408
410
 
409
- const classes = getDecorationClasses(view);
410
- expect(classes).toContain(md.tableSource);
411
- expect(classes).not.toContain(md.tableDelimiter);
411
+ const classes = getDecorationClasses(view);
412
+ expect(classes).toContain(md.tableSource);
413
+ expect(classes).not.toContain(md.tableDelimiter);
412
414
 
413
- view.destroy();
414
- });
415
+ view.destroy();
416
+ });
415
417
  });
416
418
 
417
419
  describe("markdownDecorations / fenced code", () => {
418
- const FENCED = "```rust\nfn main() {}\n```";
419
-
420
- it("hides fence and language marks when the block is inactive in preview mode", () => {
421
- const view = createView(`paragraph\n\n${FENCED}\n`, "preview");
422
- view.dispatch({ selection: EditorSelection.cursor(0) });
423
-
424
- const classes = getDecorationClasses(view);
425
- expect(classes).toContain(md.markHidden);
426
-
427
- const widgets = collectWidgets(view);
428
- expect(widgets.some((w) => w instanceof CodeFenceCopyWidget)).toBe(true);
429
- expect(widgets.some((w) => w instanceof CodeFenceLanguageMenuWidget)).toBe(false);
430
-
431
- view.destroy();
432
- });
433
-
434
- it("renders CodeFenceLanguageMenuWidget when the block is active in preview focused mode", () => {
435
- const view = createView(FENCED, "preview");
436
- const codeBody = view.state.doc.toString().indexOf("fn ");
437
- view.dispatch({ selection: EditorSelection.cursor(codeBody) });
438
-
439
- const widgets = collectWidgets(view);
440
- expect(widgets.some((w) => w instanceof CodeFenceLanguageMenuWidget)).toBe(true);
441
- expect(widgets.some((w) => w instanceof CodeFenceCopyWidget)).toBe(false);
442
-
443
- view.destroy();
444
- });
445
-
446
- it("does not emit fenced-code chrome widgets in source mode", () => {
447
- const view = createView(FENCED, "source");
448
- const widgets = collectWidgets(view);
449
- expect(widgets.some((w) => w instanceof CodeFenceLanguageMenuWidget)).toBe(false);
450
- expect(widgets.some((w) => w instanceof CodeFenceCopyWidget)).toBe(false);
451
- view.destroy();
452
- });
453
-
454
- it("invokes a registered code renderer for matching languages when inactive", () => {
455
- const renderer: CodeRenderer = () => null;
456
- const view = createView(`paragraph\n\n\`\`\`mermaid\ngraph TD;A-->B\n\`\`\`\n`, "preview", {
457
- codeRenderers: { mermaid: renderer },
458
- });
459
-
460
- const widgets = collectWidgets(view);
461
- expect(widgets.some((w) => w instanceof CustomCodeBlockWidget)).toBe(true);
462
-
463
- view.destroy();
464
- });
465
-
466
- it("falls back to fence widgets when an active block has a custom renderer", () => {
467
- const renderer: CodeRenderer = () => null;
468
- const doc = `\`\`\`mermaid\ngraph TD;A-->B\n\`\`\`\n`;
469
- const view = createView(doc, "preview", { codeRenderers: { mermaid: renderer } });
470
- const codeBody = view.state.doc.toString().indexOf("graph");
471
- view.dispatch({ selection: EditorSelection.cursor(codeBody) });
472
-
473
- const widgets = collectWidgets(view);
474
- expect(widgets.some((w) => w instanceof CustomCodeBlockWidget)).toBe(false);
475
- expect(widgets.some((w) => w instanceof CodeFenceLanguageMenuWidget)).toBe(true);
476
-
477
- view.destroy();
478
- });
479
-
480
- it("emits code-block line classes regardless of mode", () => {
481
- for (const mode of ["source", "preview"] as const) {
482
- const view = createView(FENCED, mode);
483
- const classes = getDecorationClasses(view);
484
- expect(classes).toContain(md.codeBlock);
485
- view.destroy();
486
- }
487
- });
420
+ const FENCED = "```rust\nfn main() {}\n```";
421
+
422
+ it("hides fence and language marks when the block is inactive in preview mode", () => {
423
+ const view = createView(`paragraph\n\n${FENCED}\n`, "preview");
424
+ view.dispatch({ selection: EditorSelection.cursor(0) });
425
+
426
+ const classes = getDecorationClasses(view);
427
+ expect(classes).toContain(md.markHidden);
428
+
429
+ const widgets = collectWidgets(view);
430
+ expect(widgets.some((w) => w instanceof CodeFenceCopyWidget)).toBe(true);
431
+ expect(widgets.some((w) => w instanceof CodeFenceLanguageMenuWidget)).toBe(false);
432
+
433
+ view.destroy();
434
+ });
435
+
436
+ it("renders CodeFenceLanguageMenuWidget when the block is active in preview focused mode", () => {
437
+ const view = createView(FENCED, "preview");
438
+ const codeBody = view.state.doc.toString().indexOf("fn ");
439
+ view.dispatch({ selection: EditorSelection.cursor(codeBody) });
440
+
441
+ const widgets = collectWidgets(view);
442
+ expect(widgets.some((w) => w instanceof CodeFenceLanguageMenuWidget)).toBe(true);
443
+ expect(widgets.some((w) => w instanceof CodeFenceCopyWidget)).toBe(false);
444
+
445
+ view.destroy();
446
+ });
447
+
448
+ it("does not emit fenced-code chrome widgets in source mode", () => {
449
+ const view = createView(FENCED, "source");
450
+ const widgets = collectWidgets(view);
451
+ expect(widgets.some((w) => w instanceof CodeFenceLanguageMenuWidget)).toBe(false);
452
+ expect(widgets.some((w) => w instanceof CodeFenceCopyWidget)).toBe(false);
453
+ view.destroy();
454
+ });
455
+
456
+ it("invokes a registered code renderer for matching languages when inactive", () => {
457
+ const renderer: CodeRenderer = () => null;
458
+ const view = createView(`paragraph\n\n\`\`\`mermaid\ngraph TD;A-->B\n\`\`\`\n`, "preview", {
459
+ codeRenderers: { mermaid: renderer },
460
+ });
461
+
462
+ const widgets = collectWidgets(view);
463
+ expect(widgets.some((w) => w instanceof CustomCodeBlockWidget)).toBe(true);
464
+
465
+ view.destroy();
466
+ });
467
+
468
+ it("falls back to fence widgets when an active block has a custom renderer", () => {
469
+ const renderer: CodeRenderer = () => null;
470
+ const doc = `\`\`\`mermaid\ngraph TD;A-->B\n\`\`\`\n`;
471
+ const view = createView(doc, "preview", { codeRenderers: { mermaid: renderer } });
472
+ const codeBody = view.state.doc.toString().indexOf("graph");
473
+ view.dispatch({ selection: EditorSelection.cursor(codeBody) });
474
+
475
+ const widgets = collectWidgets(view);
476
+ expect(widgets.some((w) => w instanceof CustomCodeBlockWidget)).toBe(false);
477
+ expect(widgets.some((w) => w instanceof CodeFenceLanguageMenuWidget)).toBe(true);
478
+
479
+ view.destroy();
480
+ });
481
+
482
+ it("emits code-block line classes regardless of mode", () => {
483
+ for (const mode of ["source", "preview"] as const) {
484
+ const view = createView(FENCED, mode);
485
+ const classes = getDecorationClasses(view);
486
+ expect(classes).toContain(md.codeBlock);
487
+ view.destroy();
488
+ }
489
+ });
488
490
  });