quasar-ui-danx 0.5.0 → 0.5.2

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 (81) hide show
  1. package/.claude/settings.local.json +8 -0
  2. package/dist/danx.es.js +16119 -10641
  3. package/dist/danx.es.js.map +1 -1
  4. package/dist/danx.umd.js +202 -123
  5. package/dist/danx.umd.js.map +1 -1
  6. package/dist/style.css +1 -1
  7. package/package.json +8 -1
  8. package/src/components/Utility/Buttons/ActionButton.vue +15 -5
  9. package/src/components/Utility/Code/CodeViewer.vue +41 -16
  10. package/src/components/Utility/Code/CodeViewerCollapsed.vue +2 -0
  11. package/src/components/Utility/Code/CodeViewerFooter.vue +3 -1
  12. package/src/components/Utility/Code/LanguageBadge.vue +278 -5
  13. package/src/components/Utility/Code/MarkdownContent.vue +31 -163
  14. package/src/components/Utility/Code/index.ts +3 -0
  15. package/src/components/Utility/Markdown/ContextMenu.vue +314 -0
  16. package/src/components/Utility/Markdown/HotkeyHelpPopover.vue +259 -0
  17. package/src/components/Utility/Markdown/LineTypeMenu.vue +226 -0
  18. package/src/components/Utility/Markdown/LinkPopover.vue +331 -0
  19. package/src/components/Utility/Markdown/MarkdownEditor.vue +233 -0
  20. package/src/components/Utility/Markdown/MarkdownEditorContent.vue +296 -0
  21. package/src/components/Utility/Markdown/MarkdownEditorFooter.vue +50 -0
  22. package/src/components/Utility/Markdown/TablePopover.vue +420 -0
  23. package/src/components/Utility/Markdown/index.ts +11 -0
  24. package/src/components/Utility/Markdown/types.ts +27 -0
  25. package/src/components/Utility/Widgets/LabelPillWidget.vue +20 -0
  26. package/src/components/Utility/index.ts +1 -0
  27. package/src/composables/index.ts +1 -0
  28. package/src/composables/markdown/features/useBlockquotes.spec.ts +428 -0
  29. package/src/composables/markdown/features/useBlockquotes.ts +248 -0
  30. package/src/composables/markdown/features/useCodeBlockManager.ts +369 -0
  31. package/src/composables/markdown/features/useCodeBlocks.spec.ts +805 -0
  32. package/src/composables/markdown/features/useCodeBlocks.ts +774 -0
  33. package/src/composables/markdown/features/useContextMenu.ts +444 -0
  34. package/src/composables/markdown/features/useFocusTracking.ts +116 -0
  35. package/src/composables/markdown/features/useHeadings.spec.ts +834 -0
  36. package/src/composables/markdown/features/useHeadings.ts +290 -0
  37. package/src/composables/markdown/features/useInlineFormatting.spec.ts +705 -0
  38. package/src/composables/markdown/features/useInlineFormatting.ts +402 -0
  39. package/src/composables/markdown/features/useLineTypeMenu.ts +285 -0
  40. package/src/composables/markdown/features/useLinks.spec.ts +388 -0
  41. package/src/composables/markdown/features/useLinks.ts +374 -0
  42. package/src/composables/markdown/features/useLists.spec.ts +834 -0
  43. package/src/composables/markdown/features/useLists.ts +747 -0
  44. package/src/composables/markdown/features/usePopoverManager.ts +181 -0
  45. package/src/composables/markdown/features/useTables.spec.ts +1601 -0
  46. package/src/composables/markdown/features/useTables.ts +1107 -0
  47. package/src/composables/markdown/index.ts +16 -0
  48. package/src/composables/markdown/useMarkdownEditor.spec.ts +332 -0
  49. package/src/composables/markdown/useMarkdownEditor.ts +1077 -0
  50. package/src/composables/markdown/useMarkdownHotkeys.spec.ts +791 -0
  51. package/src/composables/markdown/useMarkdownHotkeys.ts +266 -0
  52. package/src/composables/markdown/useMarkdownSelection.ts +219 -0
  53. package/src/composables/markdown/useMarkdownSync.ts +549 -0
  54. package/src/composables/useCodeFormat.ts +17 -10
  55. package/src/composables/useCodeViewerEditor.spec.ts +655 -0
  56. package/src/composables/useCodeViewerEditor.ts +174 -20
  57. package/src/helpers/formats/highlightCSS.ts +236 -0
  58. package/src/helpers/formats/highlightHTML.ts +483 -0
  59. package/src/helpers/formats/highlightJavaScript.ts +346 -0
  60. package/src/helpers/formats/highlightSyntax.ts +15 -4
  61. package/src/helpers/formats/index.ts +3 -0
  62. package/src/helpers/formats/markdown/htmlToMarkdown/convertHeadings.ts +41 -0
  63. package/src/helpers/formats/markdown/htmlToMarkdown/index.spec.ts +489 -0
  64. package/src/helpers/formats/markdown/htmlToMarkdown/index.ts +425 -0
  65. package/src/helpers/formats/markdown/index.ts +7 -0
  66. package/src/helpers/formats/markdown/linePatterns.spec.ts +498 -0
  67. package/src/helpers/formats/markdown/linePatterns.ts +172 -0
  68. package/src/styles/danx.scss +3 -3
  69. package/src/styles/index.scss +5 -5
  70. package/src/styles/themes/danx/code.scss +257 -1
  71. package/src/styles/themes/danx/index.scss +10 -10
  72. package/src/styles/themes/danx/markdown.scss +59 -0
  73. package/src/test/helpers/editorTestUtils.spec.ts +296 -0
  74. package/src/test/helpers/editorTestUtils.ts +253 -0
  75. package/src/test/helpers/index.ts +1 -0
  76. package/src/test/highlighters.test.ts +153 -0
  77. package/src/test/setup.test.ts +12 -0
  78. package/src/test/setup.ts +12 -0
  79. package/src/types/widgets.d.ts +2 -2
  80. package/vite.config.js +5 -1
  81. package/vitest.config.ts +19 -0
@@ -0,0 +1,834 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { useLists } from './useLists';
3
+ import { useMarkdownSelection } from '../useMarkdownSelection';
4
+ import { createTestEditor, TestEditorResult } from '../../../test/helpers/editorTestUtils';
5
+
6
+ describe('useLists', () => {
7
+ let editor: TestEditorResult;
8
+ let onContentChange: ReturnType<typeof vi.fn>;
9
+
10
+ beforeEach(() => {
11
+ editor = createTestEditor('<p>Hello world</p>');
12
+ onContentChange = vi.fn();
13
+ });
14
+
15
+ afterEach(() => {
16
+ if (editor) {
17
+ editor.destroy();
18
+ }
19
+ });
20
+
21
+ function createLists() {
22
+ const selection = useMarkdownSelection(editor.contentRef);
23
+ return useLists({
24
+ contentRef: editor.contentRef,
25
+ selection,
26
+ onContentChange
27
+ });
28
+ }
29
+
30
+ /**
31
+ * Helper to set cursor in a list item by finding its text node
32
+ */
33
+ function setCursorInListItem(li: HTMLLIElement, offset: number): void {
34
+ const walker = document.createTreeWalker(li, NodeFilter.SHOW_TEXT);
35
+ let textNode = walker.nextNode() as Text | null;
36
+
37
+ // Skip text nodes inside nested lists
38
+ while (textNode) {
39
+ let parent: Node | null = textNode.parentNode;
40
+ let inNestedList = false;
41
+ while (parent && parent !== li) {
42
+ if (parent.nodeName === 'UL' || parent.nodeName === 'OL') {
43
+ inNestedList = true;
44
+ break;
45
+ }
46
+ parent = parent.parentNode;
47
+ }
48
+ if (!inNestedList) break;
49
+ textNode = walker.nextNode() as Text | null;
50
+ }
51
+
52
+ if (textNode) {
53
+ editor.setCursor(textNode, Math.min(offset, textNode.textContent?.length || 0));
54
+ } else {
55
+ // If no text node, set cursor at the li itself
56
+ const range = document.createRange();
57
+ range.setStart(li, 0);
58
+ range.collapse(true);
59
+ const sel = window.getSelection();
60
+ sel?.removeAllRanges();
61
+ sel?.addRange(range);
62
+ }
63
+ }
64
+
65
+ describe('toggleUnorderedList', () => {
66
+ it('converts paragraph to unordered list', () => {
67
+ const lists = createLists();
68
+ editor.setCursorInBlock(0, 0);
69
+
70
+ lists.toggleUnorderedList();
71
+
72
+ expect(editor.getHtml()).toBe('<ul><li>Hello world</li></ul>');
73
+ expect(onContentChange).toHaveBeenCalled();
74
+ });
75
+
76
+ it('converts unordered list back to paragraph', () => {
77
+ editor = createTestEditor('<ul><li>Hello world</li></ul>');
78
+ const lists = createLists();
79
+ const li = editor.container.querySelector('li')!;
80
+ setCursorInListItem(li, 0);
81
+
82
+ lists.toggleUnorderedList();
83
+
84
+ expect(editor.getHtml()).toBe('<p>Hello world</p>');
85
+ expect(onContentChange).toHaveBeenCalled();
86
+ });
87
+
88
+ it('converts ordered list to unordered list', () => {
89
+ editor = createTestEditor('<ol><li>Item one</li></ol>');
90
+ const lists = createLists();
91
+ const li = editor.container.querySelector('li')!;
92
+ setCursorInListItem(li, 0);
93
+
94
+ lists.toggleUnorderedList();
95
+
96
+ expect(editor.getHtml()).toBe('<ul><li>Item one</li></ul>');
97
+ expect(onContentChange).toHaveBeenCalled();
98
+ });
99
+
100
+ it('converts multi-item ordered list to unordered list', () => {
101
+ editor = createTestEditor('<ol><li>First</li><li>Second</li><li>Third</li></ol>');
102
+ const lists = createLists();
103
+ const firstLi = editor.container.querySelector('li')!;
104
+ setCursorInListItem(firstLi, 0);
105
+
106
+ lists.toggleUnorderedList();
107
+
108
+ const html = editor.getHtml();
109
+ expect(html).toContain('<ul>');
110
+ expect(html).not.toContain('<ol>');
111
+ expect(editor.container.querySelectorAll('li').length).toBe(3);
112
+ });
113
+
114
+ it('preserves content when toggling', () => {
115
+ editor = createTestEditor('<p>Content with <strong>bold</strong> text</p>');
116
+ const lists = createLists();
117
+ editor.setCursorInBlock(0, 0);
118
+
119
+ lists.toggleUnorderedList();
120
+
121
+ expect(editor.container.querySelector('li strong')).not.toBeNull();
122
+ expect(editor.container.textContent).toContain('Content with bold text');
123
+ });
124
+ });
125
+
126
+ describe('toggleOrderedList', () => {
127
+ it('converts paragraph to ordered list', () => {
128
+ const lists = createLists();
129
+ editor.setCursorInBlock(0, 0);
130
+
131
+ lists.toggleOrderedList();
132
+
133
+ expect(editor.getHtml()).toBe('<ol><li>Hello world</li></ol>');
134
+ expect(onContentChange).toHaveBeenCalled();
135
+ });
136
+
137
+ it('converts ordered list back to paragraph', () => {
138
+ editor = createTestEditor('<ol><li>Numbered item</li></ol>');
139
+ const lists = createLists();
140
+ const li = editor.container.querySelector('li')!;
141
+ setCursorInListItem(li, 0);
142
+
143
+ lists.toggleOrderedList();
144
+
145
+ expect(editor.getHtml()).toBe('<p>Numbered item</p>');
146
+ expect(onContentChange).toHaveBeenCalled();
147
+ });
148
+
149
+ it('converts unordered list to ordered list', () => {
150
+ editor = createTestEditor('<ul><li>Bullet item</li></ul>');
151
+ const lists = createLists();
152
+ const li = editor.container.querySelector('li')!;
153
+ setCursorInListItem(li, 0);
154
+
155
+ lists.toggleOrderedList();
156
+
157
+ expect(editor.getHtml()).toBe('<ol><li>Bullet item</li></ol>');
158
+ expect(onContentChange).toHaveBeenCalled();
159
+ });
160
+
161
+ it('converts multi-item unordered list to ordered list', () => {
162
+ editor = createTestEditor('<ul><li>One</li><li>Two</li></ul>');
163
+ const lists = createLists();
164
+ const li = editor.container.querySelector('li')!;
165
+ setCursorInListItem(li, 0);
166
+
167
+ lists.toggleOrderedList();
168
+
169
+ const html = editor.getHtml();
170
+ expect(html).toContain('<ol>');
171
+ expect(html).not.toContain('<ul>');
172
+ expect(editor.container.querySelectorAll('li').length).toBe(2);
173
+ });
174
+ });
175
+
176
+ describe('checkAndConvertListPattern', () => {
177
+ it('converts "- item" to unordered list', () => {
178
+ editor = createTestEditor('<p>- my item</p>');
179
+ const lists = createLists();
180
+ editor.setCursorInBlock(0, 9);
181
+
182
+ const converted = lists.checkAndConvertListPattern();
183
+
184
+ expect(converted).toBe(true);
185
+ expect(editor.getHtml()).toBe('<ul><li>my item</li></ul>');
186
+ expect(onContentChange).toHaveBeenCalled();
187
+ });
188
+
189
+ it('converts "* item" to unordered list', () => {
190
+ editor = createTestEditor('<p>* bullet point</p>');
191
+ const lists = createLists();
192
+ editor.setCursorInBlock(0, 14);
193
+
194
+ const converted = lists.checkAndConvertListPattern();
195
+
196
+ expect(converted).toBe(true);
197
+ expect(editor.container.querySelector('ul')).not.toBeNull();
198
+ expect(editor.container.querySelector('li')?.textContent).toBe('bullet point');
199
+ });
200
+
201
+ it('converts "+ item" to unordered list', () => {
202
+ editor = createTestEditor('<p>+ plus item</p>');
203
+ const lists = createLists();
204
+ editor.setCursorInBlock(0, 11);
205
+
206
+ const converted = lists.checkAndConvertListPattern();
207
+
208
+ expect(converted).toBe(true);
209
+ expect(editor.container.querySelector('ul')).not.toBeNull();
210
+ expect(editor.container.querySelector('li')?.textContent).toBe('plus item');
211
+ });
212
+
213
+ it('converts "1. item" to ordered list', () => {
214
+ editor = createTestEditor('<p>1. first item</p>');
215
+ const lists = createLists();
216
+ editor.setCursorInBlock(0, 13);
217
+
218
+ const converted = lists.checkAndConvertListPattern();
219
+
220
+ expect(converted).toBe(true);
221
+ expect(editor.getHtml()).toBe('<ol><li>first item</li></ol>');
222
+ expect(onContentChange).toHaveBeenCalled();
223
+ });
224
+
225
+ it('converts "42. item" to ordered list', () => {
226
+ editor = createTestEditor('<p>42. numbered item</p>');
227
+ const lists = createLists();
228
+ editor.setCursorInBlock(0, 17);
229
+
230
+ const converted = lists.checkAndConvertListPattern();
231
+
232
+ expect(converted).toBe(true);
233
+ expect(editor.container.querySelector('ol')).not.toBeNull();
234
+ expect(editor.container.querySelector('li')?.textContent).toBe('numbered item');
235
+ });
236
+
237
+ it('does not convert text without list pattern', () => {
238
+ editor = createTestEditor('<p>Normal paragraph</p>');
239
+ const lists = createLists();
240
+ editor.setCursorInBlock(0, 0);
241
+
242
+ const converted = lists.checkAndConvertListPattern();
243
+
244
+ expect(converted).toBe(false);
245
+ expect(editor.getHtml()).toBe('<p>Normal paragraph</p>');
246
+ expect(onContentChange).not.toHaveBeenCalled();
247
+ });
248
+
249
+ it('does not convert existing list items', () => {
250
+ editor = createTestEditor('<ul><li>- nested dash</li></ul>');
251
+ const lists = createLists();
252
+ const li = editor.container.querySelector('li')!;
253
+ setCursorInListItem(li, 0);
254
+
255
+ const converted = lists.checkAndConvertListPattern();
256
+
257
+ expect(converted).toBe(false);
258
+ // Should still be a single list
259
+ expect(editor.container.querySelectorAll('ul').length).toBe(1);
260
+ });
261
+
262
+ it('handles empty content after pattern', () => {
263
+ editor = createTestEditor('<p>- </p>');
264
+ const lists = createLists();
265
+ editor.setCursorInBlock(0, 2);
266
+
267
+ const converted = lists.checkAndConvertListPattern();
268
+
269
+ expect(converted).toBe(true);
270
+ expect(editor.container.querySelector('ul')).not.toBeNull();
271
+ expect(editor.container.querySelector('li')?.textContent).toBe('');
272
+ });
273
+ });
274
+
275
+ describe('handleListEnter', () => {
276
+ it('creates new list item when current item has content', () => {
277
+ editor = createTestEditor('<ul><li>Item 1</li></ul>');
278
+ const lists = createLists();
279
+ const li = editor.container.querySelector('li')!;
280
+ setCursorInListItem(li, 6); // End of "Item 1"
281
+
282
+ const handled = lists.handleListEnter();
283
+
284
+ expect(handled).toBe(true);
285
+ expect(editor.container.querySelectorAll('li').length).toBe(2);
286
+ expect(onContentChange).toHaveBeenCalled();
287
+ });
288
+
289
+ it('splits content when cursor is in middle', () => {
290
+ editor = createTestEditor('<ul><li>Hello World</li></ul>');
291
+ const lists = createLists();
292
+ const li = editor.container.querySelector('li')!;
293
+ setCursorInListItem(li, 5); // After "Hello"
294
+
295
+ lists.handleListEnter();
296
+
297
+ const items = editor.container.querySelectorAll('li');
298
+ expect(items.length).toBe(2);
299
+ expect(items[0].textContent).toBe('Hello');
300
+ expect(items[1].textContent).toBe(' World');
301
+ });
302
+
303
+ it('exits list when item is empty at top level', () => {
304
+ editor = createTestEditor('<ul><li>Item 1</li><li></li></ul>');
305
+ const lists = createLists();
306
+ const emptyLi = editor.container.querySelectorAll('li')[1];
307
+ setCursorInListItem(emptyLi, 0);
308
+
309
+ lists.handleListEnter();
310
+
311
+ // Should convert empty li to paragraph
312
+ expect(editor.container.querySelector('p')).not.toBeNull();
313
+ expect(onContentChange).toHaveBeenCalled();
314
+ });
315
+
316
+ it('exits list and removes list when only empty item remains', () => {
317
+ editor = createTestEditor('<ul><li></li></ul>');
318
+ const lists = createLists();
319
+ const li = editor.container.querySelector('li')!;
320
+ setCursorInListItem(li, 0);
321
+
322
+ lists.handleListEnter();
323
+
324
+ // List should be gone, replaced with paragraph
325
+ expect(editor.container.querySelector('ul')).toBeNull();
326
+ expect(editor.container.querySelector('p')).not.toBeNull();
327
+ });
328
+
329
+ it('outdents when empty nested item', () => {
330
+ editor = createTestEditor('<ul><li>Parent<ul><li></li></ul></li></ul>');
331
+ const lists = createLists();
332
+ const nestedLi = editor.container.querySelector('ul ul li')!;
333
+ setCursorInListItem(nestedLi as HTMLLIElement, 0);
334
+
335
+ lists.handleListEnter();
336
+
337
+ // Should outdent the empty nested item
338
+ expect(onContentChange).toHaveBeenCalled();
339
+ });
340
+
341
+ it('returns false when not in a list', () => {
342
+ editor = createTestEditor('<p>Not a list</p>');
343
+ const lists = createLists();
344
+ editor.setCursorInBlock(0, 0);
345
+
346
+ const handled = lists.handleListEnter();
347
+
348
+ expect(handled).toBe(false);
349
+ expect(onContentChange).not.toHaveBeenCalled();
350
+ });
351
+
352
+ it('works with ordered lists', () => {
353
+ editor = createTestEditor('<ol><li>First</li></ol>');
354
+ const lists = createLists();
355
+ const li = editor.container.querySelector('li')!;
356
+ setCursorInListItem(li, 5);
357
+
358
+ lists.handleListEnter();
359
+
360
+ expect(editor.container.querySelectorAll('ol li').length).toBe(2);
361
+ });
362
+ });
363
+
364
+ describe('indentListItem', () => {
365
+ it('indents second item under first', () => {
366
+ editor = createTestEditor('<ul><li>First</li><li>Second</li></ul>');
367
+ const lists = createLists();
368
+ const secondLi = editor.container.querySelectorAll('li')[1];
369
+ setCursorInListItem(secondLi as HTMLLIElement, 3);
370
+
371
+ const handled = lists.indentListItem();
372
+
373
+ expect(handled).toBe(true);
374
+ expect(editor.getHtml()).toContain('<li>First<ul><li>Second</li></ul></li>');
375
+ expect(onContentChange).toHaveBeenCalled();
376
+ });
377
+
378
+ it('cannot indent first item (no previous sibling)', () => {
379
+ editor = createTestEditor('<ul><li>First</li><li>Second</li></ul>');
380
+ const lists = createLists();
381
+ const firstLi = editor.container.querySelector('li')!;
382
+ setCursorInListItem(firstLi, 0);
383
+
384
+ const handled = lists.indentListItem();
385
+
386
+ expect(handled).toBe(false);
387
+ expect(onContentChange).not.toHaveBeenCalled();
388
+ });
389
+
390
+ it('preserves cursor position after indent', () => {
391
+ editor = createTestEditor('<ul><li>First</li><li>Second</li></ul>');
392
+ const lists = createLists();
393
+ const secondLi = editor.container.querySelectorAll('li')[1];
394
+ setCursorInListItem(secondLi as HTMLLIElement, 3); // In middle of "Second"
395
+
396
+ lists.indentListItem();
397
+
398
+ // Cursor should still be within the text
399
+ const sel = window.getSelection();
400
+ expect(sel?.rangeCount).toBeGreaterThan(0);
401
+ });
402
+
403
+ it('appends to existing nested list', () => {
404
+ editor = createTestEditor('<ul><li>First<ul><li>Nested</li></ul></li><li>Third</li></ul>');
405
+ const lists = createLists();
406
+ const thirdLi = editor.container.querySelectorAll(':scope > ul > li')[1] as HTMLLIElement;
407
+ setCursorInListItem(thirdLi, 0);
408
+
409
+ lists.indentListItem();
410
+
411
+ // Third should now be in the nested list
412
+ const nestedItems = editor.container.querySelectorAll('ul ul li');
413
+ expect(nestedItems.length).toBe(2);
414
+ });
415
+
416
+ it('preserves list type when indenting', () => {
417
+ editor = createTestEditor('<ol><li>First</li><li>Second</li></ol>');
418
+ const lists = createLists();
419
+ const secondLi = editor.container.querySelectorAll('li')[1];
420
+ setCursorInListItem(secondLi as HTMLLIElement, 0);
421
+
422
+ lists.indentListItem();
423
+
424
+ // Nested list should also be ordered
425
+ expect(editor.container.querySelector('ol ol')).not.toBeNull();
426
+ });
427
+
428
+ it('returns false when not in a list', () => {
429
+ editor = createTestEditor('<p>Paragraph</p>');
430
+ const lists = createLists();
431
+ editor.setCursorInBlock(0, 0);
432
+
433
+ const handled = lists.indentListItem();
434
+
435
+ expect(handled).toBe(false);
436
+ });
437
+ });
438
+
439
+ describe('outdentListItem', () => {
440
+ it('outdents nested item to parent level', () => {
441
+ editor = createTestEditor('<ul><li>Parent<ul><li>Nested</li></ul></li></ul>');
442
+ const lists = createLists();
443
+ const nestedLi = editor.container.querySelector('ul ul li')!;
444
+ setCursorInListItem(nestedLi as HTMLLIElement, 0);
445
+
446
+ const handled = lists.outdentListItem();
447
+
448
+ expect(handled).toBe(true);
449
+ // Nested item should now be at top level
450
+ const topLevelItems = editor.container.querySelectorAll(':scope > ul > li');
451
+ expect(topLevelItems.length).toBe(2);
452
+ expect(onContentChange).toHaveBeenCalled();
453
+ });
454
+
455
+ it('converts top-level item to paragraph', () => {
456
+ editor = createTestEditor('<ul><li>Item</li></ul>');
457
+ const lists = createLists();
458
+ const li = editor.container.querySelector('li')!;
459
+ setCursorInListItem(li, 0);
460
+
461
+ const handled = lists.outdentListItem();
462
+
463
+ expect(handled).toBe(true);
464
+ expect(editor.container.querySelector('ul')).toBeNull();
465
+ expect(editor.container.querySelector('p')).not.toBeNull();
466
+ expect(editor.container.querySelector('p')?.textContent).toBe('Item');
467
+ });
468
+
469
+ it('preserves cursor position after outdent', () => {
470
+ editor = createTestEditor('<ul><li>Parent<ul><li>Nested</li></ul></li></ul>');
471
+ const lists = createLists();
472
+ const nestedLi = editor.container.querySelector('ul ul li')!;
473
+ setCursorInListItem(nestedLi as HTMLLIElement, 3);
474
+
475
+ lists.outdentListItem();
476
+
477
+ // Cursor should still be within text
478
+ const sel = window.getSelection();
479
+ expect(sel?.rangeCount).toBeGreaterThan(0);
480
+ });
481
+
482
+ it('moves following siblings to nested list in current item', () => {
483
+ editor = createTestEditor('<ul><li>Parent<ul><li>First</li><li>Second</li><li>Third</li></ul></li></ul>');
484
+ const lists = createLists();
485
+ // Outdent "Second" - "Third" should become nested under it
486
+ const secondLi = editor.container.querySelectorAll('ul ul li')[1];
487
+ setCursorInListItem(secondLi as HTMLLIElement, 0);
488
+
489
+ lists.outdentListItem();
490
+
491
+ // After outdent, "Second" should be at parent level with "Third" nested under it
492
+ const secondNowTop = editor.container.querySelectorAll(':scope > ul > li')[1];
493
+ expect(secondNowTop?.textContent).toContain('Second');
494
+ expect(secondNowTop?.textContent).toContain('Third');
495
+ });
496
+
497
+ it('cleans up empty parent list after outdent', () => {
498
+ editor = createTestEditor('<ul><li>Parent<ul><li>Only child</li></ul></li></ul>');
499
+ const lists = createLists();
500
+ const nestedLi = editor.container.querySelector('ul ul li')!;
501
+ setCursorInListItem(nestedLi as HTMLLIElement, 0);
502
+
503
+ lists.outdentListItem();
504
+
505
+ // The nested ul should be removed since it's empty
506
+ const nestedLists = editor.container.querySelectorAll('ul ul');
507
+ expect(nestedLists.length).toBe(0);
508
+ });
509
+
510
+ it('returns false when not in a list', () => {
511
+ editor = createTestEditor('<p>Paragraph</p>');
512
+ const lists = createLists();
513
+ editor.setCursorInBlock(0, 0);
514
+
515
+ const handled = lists.outdentListItem();
516
+
517
+ expect(handled).toBe(false);
518
+ });
519
+ });
520
+
521
+ describe('getCurrentListType', () => {
522
+ it('returns "ul" when in unordered list', () => {
523
+ editor = createTestEditor('<ul><li>Bullet item</li></ul>');
524
+ const lists = createLists();
525
+ const li = editor.container.querySelector('li')!;
526
+ setCursorInListItem(li, 0);
527
+
528
+ expect(lists.getCurrentListType()).toBe('ul');
529
+ });
530
+
531
+ it('returns "ol" when in ordered list', () => {
532
+ editor = createTestEditor('<ol><li>Numbered item</li></ol>');
533
+ const lists = createLists();
534
+ const li = editor.container.querySelector('li')!;
535
+ setCursorInListItem(li, 0);
536
+
537
+ expect(lists.getCurrentListType()).toBe('ol');
538
+ });
539
+
540
+ it('returns null when not in list', () => {
541
+ editor = createTestEditor('<p>Paragraph</p>');
542
+ const lists = createLists();
543
+ editor.setCursorInBlock(0, 0);
544
+
545
+ expect(lists.getCurrentListType()).toBeNull();
546
+ });
547
+
548
+ it('returns null when in heading', () => {
549
+ editor = createTestEditor('<h1>Heading</h1>');
550
+ const lists = createLists();
551
+ editor.setCursorInBlock(0, 0);
552
+
553
+ expect(lists.getCurrentListType()).toBeNull();
554
+ });
555
+
556
+ it('returns correct type for nested list', () => {
557
+ editor = createTestEditor('<ul><li>Parent<ol><li>Nested ordered</li></ol></li></ul>');
558
+ const lists = createLists();
559
+ const nestedLi = editor.container.querySelector('ol li')!;
560
+ setCursorInListItem(nestedLi as HTMLLIElement, 0);
561
+
562
+ // Should return the immediate parent list type
563
+ expect(lists.getCurrentListType()).toBe('ol');
564
+ });
565
+ });
566
+
567
+ describe('list splitting behavior', () => {
568
+ it('splits list when converting middle item to paragraph', () => {
569
+ editor = createTestEditor('<ul><li>First</li><li>Second</li><li>Third</li></ul>');
570
+ const lists = createLists();
571
+ const secondLi = editor.container.querySelectorAll('li')[1];
572
+ setCursorInListItem(secondLi as HTMLLIElement, 0);
573
+
574
+ lists.toggleUnorderedList();
575
+
576
+ // Should have: ul with First, p with Second, ul with Third
577
+ const paragraphs = editor.container.querySelectorAll('p');
578
+ expect(paragraphs.length).toBe(1);
579
+ expect(paragraphs[0].textContent).toBe('Second');
580
+
581
+ const lists2 = editor.container.querySelectorAll('ul');
582
+ expect(lists2.length).toBe(2);
583
+ });
584
+
585
+ it('handles converting first item of multi-item list', () => {
586
+ editor = createTestEditor('<ul><li>First</li><li>Second</li></ul>');
587
+ const lists = createLists();
588
+ const firstLi = editor.container.querySelector('li')!;
589
+ setCursorInListItem(firstLi, 0);
590
+
591
+ lists.toggleUnorderedList();
592
+
593
+ // Should have: p with First, ul with Second
594
+ expect(editor.container.querySelector('p')?.textContent).toBe('First');
595
+ expect(editor.container.querySelectorAll('li').length).toBe(1);
596
+ });
597
+
598
+ it('handles converting last item of multi-item list', () => {
599
+ editor = createTestEditor('<ul><li>First</li><li>Last</li></ul>');
600
+ const lists = createLists();
601
+ const lastLi = editor.container.querySelectorAll('li')[1];
602
+ setCursorInListItem(lastLi as HTMLLIElement, 0);
603
+
604
+ lists.toggleUnorderedList();
605
+
606
+ // Should have: ul with First, p with Last
607
+ expect(editor.container.querySelectorAll('li').length).toBe(1);
608
+ expect(editor.container.querySelector('li')?.textContent).toBe('First');
609
+ expect(editor.container.querySelector('p')?.textContent).toBe('Last');
610
+ });
611
+ });
612
+
613
+ describe('heading to list conversion', () => {
614
+ it('converts H1 to unordered list', () => {
615
+ editor = createTestEditor('<h1>Heading One</h1>');
616
+ const lists = createLists();
617
+ editor.setCursorInBlock(0, 0);
618
+
619
+ lists.toggleUnorderedList();
620
+
621
+ expect(editor.getHtml()).toBe('<ul><li>Heading One</li></ul>');
622
+ expect(onContentChange).toHaveBeenCalled();
623
+ });
624
+
625
+ it('converts H2 to ordered list', () => {
626
+ editor = createTestEditor('<h2>Heading Two</h2>');
627
+ const lists = createLists();
628
+ editor.setCursorInBlock(0, 0);
629
+
630
+ lists.toggleOrderedList();
631
+
632
+ expect(editor.getHtml()).toBe('<ol><li>Heading Two</li></ol>');
633
+ expect(onContentChange).toHaveBeenCalled();
634
+ });
635
+
636
+ it('converts H3 to unordered list via hotkey', () => {
637
+ editor = createTestEditor('<h3>Heading Three</h3>');
638
+ const lists = createLists();
639
+ editor.setCursorInBlock(0, 0);
640
+
641
+ lists.toggleUnorderedList();
642
+
643
+ expect(editor.container.querySelector('ul')).not.toBeNull();
644
+ expect(editor.container.querySelector('li')?.textContent).toBe('Heading Three');
645
+ });
646
+
647
+ it('converts H4 to ordered list via hotkey', () => {
648
+ editor = createTestEditor('<h4>Heading Four</h4>');
649
+ const lists = createLists();
650
+ editor.setCursorInBlock(0, 0);
651
+
652
+ lists.toggleOrderedList();
653
+
654
+ expect(editor.container.querySelector('ol')).not.toBeNull();
655
+ expect(editor.container.querySelector('li')?.textContent).toBe('Heading Four');
656
+ });
657
+
658
+ it('converts H5 heading to list via "- " pattern', () => {
659
+ editor = createTestEditor('<h5>- item from heading</h5>');
660
+ const lists = createLists();
661
+ editor.setCursorInBlock(0, 20);
662
+
663
+ const converted = lists.checkAndConvertListPattern();
664
+
665
+ expect(converted).toBe(true);
666
+ expect(editor.container.querySelector('ul')).not.toBeNull();
667
+ expect(editor.container.querySelector('li')?.textContent).toBe('item from heading');
668
+ });
669
+
670
+ it('converts H6 heading to list via "1. " pattern', () => {
671
+ editor = createTestEditor('<h6>1. numbered from heading</h6>');
672
+ const lists = createLists();
673
+ editor.setCursorInBlock(0, 25);
674
+
675
+ const converted = lists.checkAndConvertListPattern();
676
+
677
+ expect(converted).toBe(true);
678
+ expect(editor.container.querySelector('ol')).not.toBeNull();
679
+ expect(editor.container.querySelector('li')?.textContent).toBe('numbered from heading');
680
+ });
681
+
682
+ it('converts list back to paragraph, not heading', () => {
683
+ // Start with a heading, convert to list, then back
684
+ editor = createTestEditor('<h1>Original Heading</h1>');
685
+ const lists = createLists();
686
+ editor.setCursorInBlock(0, 0);
687
+
688
+ // Convert heading to list
689
+ lists.toggleUnorderedList();
690
+ expect(editor.container.querySelector('ul')).not.toBeNull();
691
+
692
+ // Now convert back - should become a paragraph, not a heading
693
+ const li = editor.container.querySelector('li')!;
694
+ setCursorInListItem(li, 0);
695
+ lists.toggleUnorderedList();
696
+
697
+ // Should be a paragraph now
698
+ expect(editor.container.querySelector('p')).not.toBeNull();
699
+ expect(editor.container.querySelector('h1')).toBeNull();
700
+ expect(editor.container.querySelector('p')?.textContent).toBe('Original Heading');
701
+ });
702
+
703
+ it('preserves heading content with formatting when converting to list', () => {
704
+ editor = createTestEditor('<h2>Heading with <strong>bold</strong> text</h2>');
705
+ const lists = createLists();
706
+ editor.setCursorInBlock(0, 0);
707
+
708
+ lists.toggleUnorderedList();
709
+
710
+ expect(editor.container.querySelector('li strong')).not.toBeNull();
711
+ expect(editor.container.textContent).toContain('Heading with bold text');
712
+ });
713
+ });
714
+
715
+ describe('convertCurrentListItemToParagraph', () => {
716
+ it('converts unordered list item to paragraph', () => {
717
+ editor = createTestEditor('<ul><li>List item</li></ul>');
718
+ const lists = createLists();
719
+ const li = editor.container.querySelector('li')!;
720
+ setCursorInListItem(li, 0);
721
+
722
+ const result = lists.convertCurrentListItemToParagraph();
723
+
724
+ expect(result).not.toBeNull();
725
+ expect(editor.getHtml()).toBe('<p>List item</p>');
726
+ expect(onContentChange).toHaveBeenCalled();
727
+ });
728
+
729
+ it('converts ordered list item to paragraph', () => {
730
+ editor = createTestEditor('<ol><li>Numbered item</li></ol>');
731
+ const lists = createLists();
732
+ const li = editor.container.querySelector('li')!;
733
+ setCursorInListItem(li, 0);
734
+
735
+ const result = lists.convertCurrentListItemToParagraph();
736
+
737
+ expect(result).not.toBeNull();
738
+ expect(editor.getHtml()).toBe('<p>Numbered item</p>');
739
+ expect(onContentChange).toHaveBeenCalled();
740
+ });
741
+
742
+ it('returns null when not in a list', () => {
743
+ editor = createTestEditor('<p>Paragraph</p>');
744
+ const lists = createLists();
745
+ editor.setCursorInBlock(0, 0);
746
+
747
+ const result = lists.convertCurrentListItemToParagraph();
748
+
749
+ expect(result).toBeNull();
750
+ expect(onContentChange).not.toHaveBeenCalled();
751
+ });
752
+
753
+ it('preserves content with formatting', () => {
754
+ editor = createTestEditor('<ul><li>Item with <strong>bold</strong> text</li></ul>');
755
+ const lists = createLists();
756
+ const li = editor.container.querySelector('li')!;
757
+ setCursorInListItem(li, 0);
758
+
759
+ lists.convertCurrentListItemToParagraph();
760
+
761
+ expect(editor.container.querySelector('p strong')).not.toBeNull();
762
+ expect(editor.container.textContent).toContain('Item with bold text');
763
+ });
764
+
765
+ it('handles multi-item list - converts only current item', () => {
766
+ editor = createTestEditor('<ul><li>First</li><li>Second</li><li>Third</li></ul>');
767
+ const lists = createLists();
768
+ const secondLi = editor.container.querySelectorAll('li')[1];
769
+ setCursorInListItem(secondLi as HTMLLIElement, 0);
770
+
771
+ lists.convertCurrentListItemToParagraph();
772
+
773
+ // Should have two lists with paragraph in between
774
+ const paragraphs = editor.container.querySelectorAll('p');
775
+ expect(paragraphs.length).toBe(1);
776
+ expect(paragraphs[0].textContent).toBe('Second');
777
+ });
778
+ });
779
+
780
+ describe('edge cases', () => {
781
+ it('handles empty contentRef gracefully', () => {
782
+ const lists = createLists();
783
+ editor.contentRef.value = null;
784
+
785
+ // Should not throw
786
+ expect(() => lists.toggleUnorderedList()).not.toThrow();
787
+ expect(() => lists.toggleOrderedList()).not.toThrow();
788
+ expect(() => lists.checkAndConvertListPattern()).not.toThrow();
789
+ expect(() => lists.handleListEnter()).not.toThrow();
790
+ expect(() => lists.indentListItem()).not.toThrow();
791
+ expect(() => lists.outdentListItem()).not.toThrow();
792
+ expect(lists.getCurrentListType()).toBeNull();
793
+ });
794
+
795
+ it('handles list item with only whitespace as empty', () => {
796
+ editor = createTestEditor('<ul><li> </li></ul>');
797
+ const lists = createLists();
798
+ const li = editor.container.querySelector('li')!;
799
+ setCursorInListItem(li, 0);
800
+
801
+ // Whitespace-only item should be treated as empty for Enter behavior
802
+ lists.handleListEnter();
803
+
804
+ // Should exit the list
805
+ expect(editor.container.querySelector('p')).not.toBeNull();
806
+ });
807
+
808
+ it('handles deeply nested lists', () => {
809
+ editor = createTestEditor('<ul><li>L1<ul><li>L2<ul><li>L3</li></ul></li></ul></li></ul>');
810
+ const lists = createLists();
811
+ const deepLi = editor.container.querySelector('ul ul ul li')!;
812
+ setCursorInListItem(deepLi as HTMLLIElement, 0);
813
+
814
+ // Should be able to outdent from deep nesting
815
+ lists.outdentListItem();
816
+
817
+ // L3 should now be at L2 level
818
+ const l2Items = editor.container.querySelectorAll('ul ul li');
819
+ expect(l2Items.length).toBe(2);
820
+ });
821
+
822
+ it('preserves nested list content when converting parent to paragraph', () => {
823
+ editor = createTestEditor('<ul><li>Parent<ul><li>Nested</li></ul></li></ul>');
824
+ const lists = createLists();
825
+ const parentLi = editor.container.querySelector('li')!;
826
+ setCursorInListItem(parentLi, 0);
827
+
828
+ lists.toggleUnorderedList();
829
+
830
+ // Parent should become paragraph, nested list is removed (per implementation)
831
+ expect(editor.container.querySelector('p')?.textContent).toBe('Parent');
832
+ });
833
+ });
834
+ });