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,747 @@
1
+ import { Ref } from "vue";
2
+ import { UseMarkdownSelectionReturn } from "../useMarkdownSelection";
3
+ import { detectListPattern } from "../../../helpers/formats/markdown/linePatterns";
4
+
5
+ /**
6
+ * Check if an element is a block type that can be converted to/from lists
7
+ * Includes paragraphs, divs, and headings (H1-H6)
8
+ */
9
+ function isConvertibleBlock(element: Element): boolean {
10
+ const tag = element.tagName;
11
+ return tag === "P" || tag === "DIV" || /^H[1-6]$/.test(tag);
12
+ }
13
+
14
+ /**
15
+ * Options for useLists composable
16
+ */
17
+ export interface UseListsOptions {
18
+ contentRef: Ref<HTMLElement | null>;
19
+ selection: UseMarkdownSelectionReturn;
20
+ onContentChange: () => void;
21
+ }
22
+
23
+ /**
24
+ * Return type for useLists composable
25
+ */
26
+ export interface UseListsReturn {
27
+ /** Toggle unordered list (Ctrl+Shift+[) */
28
+ toggleUnorderedList: () => void;
29
+ /** Toggle ordered list (Ctrl+Shift+]) */
30
+ toggleOrderedList: () => void;
31
+ /** Check for list pattern (e.g., "- ", "1. ") and convert if matched */
32
+ checkAndConvertListPattern: () => boolean;
33
+ /** Handle Enter key in list - returns true if handled */
34
+ handleListEnter: () => boolean;
35
+ /** Handle Tab key for list indentation - returns true if handled */
36
+ indentListItem: () => boolean;
37
+ /** Handle Shift+Tab key for list outdentation - returns true if handled */
38
+ outdentListItem: () => boolean;
39
+ /** Get current list type (ul, ol, or null if not in a list) */
40
+ getCurrentListType: () => "ul" | "ol" | null;
41
+ /** Convert current list item to paragraph - returns the new paragraph element or null */
42
+ convertCurrentListItemToParagraph: () => HTMLParagraphElement | null;
43
+ }
44
+
45
+ /**
46
+ * Get the block-level parent element containing the cursor
47
+ */
48
+ function getTargetBlock(contentRef: Ref<HTMLElement | null>, selection: UseMarkdownSelectionReturn): Element | null {
49
+ const currentBlock = selection.getCurrentBlock();
50
+ if (!currentBlock) return null;
51
+
52
+ // For paragraphs, divs, and headings, return directly
53
+ if (isConvertibleBlock(currentBlock)) {
54
+ return currentBlock;
55
+ }
56
+
57
+ // For list items, return the LI
58
+ if (currentBlock.tagName === "LI") {
59
+ return currentBlock;
60
+ }
61
+
62
+ // Walk up to find a convertible block or LI
63
+ if (!contentRef.value) return null;
64
+
65
+ let current: Element | null = currentBlock;
66
+ while (current && current.parentElement !== contentRef.value) {
67
+ if (isConvertibleBlock(current) || current.tagName === "LI") {
68
+ return current;
69
+ }
70
+ current = current.parentElement;
71
+ }
72
+
73
+ // Check if this direct child is a convertible block
74
+ if (current && isConvertibleBlock(current)) {
75
+ return current;
76
+ }
77
+
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Get the list item element containing the cursor
83
+ */
84
+ function getListItem(selection: UseMarkdownSelectionReturn): HTMLLIElement | null {
85
+ const currentBlock = selection.getCurrentBlock();
86
+ if (!currentBlock) return null;
87
+
88
+ // Walk up to find LI
89
+ let current: Element | null = currentBlock;
90
+ while (current) {
91
+ if (current.tagName === "LI") {
92
+ return current as HTMLLIElement;
93
+ }
94
+ current = current.parentElement;
95
+ }
96
+
97
+ return null;
98
+ }
99
+
100
+ /**
101
+ * Get the parent list element (UL or OL) of a list item
102
+ */
103
+ function getParentList(li: HTMLLIElement): HTMLUListElement | HTMLOListElement | null {
104
+ const parent = li.parentElement;
105
+ if (parent && (parent.tagName === "UL" || parent.tagName === "OL")) {
106
+ return parent as HTMLUListElement | HTMLOListElement;
107
+ }
108
+ return null;
109
+ }
110
+
111
+ /**
112
+ * Check what type of list the cursor is in
113
+ */
114
+ function getListType(selection: UseMarkdownSelectionReturn): "ul" | "ol" | null {
115
+ const li = getListItem(selection);
116
+ if (!li) return null;
117
+
118
+ const parentList = getParentList(li);
119
+ if (!parentList) return null;
120
+
121
+ return parentList.tagName.toLowerCase() as "ul" | "ol";
122
+ }
123
+
124
+ /**
125
+ * Convert a paragraph/div to a list item
126
+ */
127
+ function convertToListItem(block: Element, listType: "ul" | "ol"): HTMLLIElement {
128
+ const list = document.createElement(listType);
129
+ const li = document.createElement("li");
130
+
131
+ // Move content from block to list item
132
+ while (block.firstChild) {
133
+ li.appendChild(block.firstChild);
134
+ }
135
+
136
+ list.appendChild(li);
137
+
138
+ // Replace block with list
139
+ block.parentNode?.replaceChild(list, block);
140
+
141
+ return li;
142
+ }
143
+
144
+ /**
145
+ * Convert a list item back to a paragraph
146
+ */
147
+ function convertListItemToParagraph(li: HTMLLIElement, contentRef: HTMLElement): HTMLParagraphElement {
148
+ const p = document.createElement("p");
149
+
150
+ // Move content from list item to paragraph
151
+ while (li.firstChild) {
152
+ // Skip nested lists
153
+ if (li.firstChild.nodeType === Node.ELEMENT_NODE) {
154
+ const el = li.firstChild as Element;
155
+ if (el.tagName === "UL" || el.tagName === "OL") {
156
+ li.removeChild(li.firstChild);
157
+ continue;
158
+ }
159
+ }
160
+ p.appendChild(li.firstChild);
161
+ }
162
+
163
+ const parentList = getParentList(li);
164
+ if (!parentList) {
165
+ // Just replace the li with p
166
+ li.parentNode?.replaceChild(p, li);
167
+ return p;
168
+ }
169
+
170
+ // Check if this is the only item in the list
171
+ if (parentList.children.length === 1) {
172
+ // Replace entire list with paragraph
173
+ parentList.parentNode?.replaceChild(p, parentList);
174
+ } else {
175
+ // Find position of li in list
176
+ const items = Array.from(parentList.children);
177
+ const index = items.indexOf(li);
178
+
179
+ if (index === 0) {
180
+ // First item - insert paragraph before list
181
+ parentList.parentNode?.insertBefore(p, parentList);
182
+ } else if (index === items.length - 1) {
183
+ // Last item - insert paragraph after list
184
+ parentList.parentNode?.insertBefore(p, parentList.nextSibling);
185
+ } else {
186
+ // Middle item - split the list
187
+ const newList = document.createElement(parentList.tagName.toLowerCase()) as HTMLUListElement | HTMLOListElement;
188
+ // Move items after current to new list
189
+ for (let i = index + 1; i < items.length; i++) {
190
+ newList.appendChild(items[i]);
191
+ }
192
+ // Insert paragraph and new list after current list
193
+ parentList.parentNode?.insertBefore(p, parentList.nextSibling);
194
+ if (newList.children.length > 0) {
195
+ p.parentNode?.insertBefore(newList, p.nextSibling);
196
+ }
197
+ }
198
+
199
+ // Remove the original li
200
+ li.remove();
201
+ }
202
+
203
+ return p;
204
+ }
205
+
206
+ /**
207
+ * Position cursor at end of element
208
+ */
209
+ function positionCursorAtEnd(element: Element): void {
210
+ const sel = window.getSelection();
211
+ if (!sel) return;
212
+
213
+ const range = document.createRange();
214
+
215
+ // Find last text node
216
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
217
+ let lastTextNode: Text | null = null;
218
+ let node: Text | null;
219
+ while ((node = walker.nextNode() as Text | null)) {
220
+ lastTextNode = node;
221
+ }
222
+
223
+ if (lastTextNode) {
224
+ range.setStart(lastTextNode, lastTextNode.length);
225
+ range.collapse(true);
226
+ } else {
227
+ range.selectNodeContents(element);
228
+ range.collapse(false);
229
+ }
230
+
231
+ sel.removeAllRanges();
232
+ sel.addRange(range);
233
+ }
234
+
235
+ /**
236
+ * Position cursor at start of element
237
+ */
238
+ function positionCursorAtStart(element: Element): void {
239
+ const sel = window.getSelection();
240
+ if (!sel) return;
241
+
242
+ const range = document.createRange();
243
+
244
+ // Find first text node
245
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
246
+ const firstTextNode = walker.nextNode() as Text | null;
247
+
248
+ if (firstTextNode) {
249
+ range.setStart(firstTextNode, 0);
250
+ range.collapse(true);
251
+ } else {
252
+ range.selectNodeContents(element);
253
+ range.collapse(true);
254
+ }
255
+
256
+ sel.removeAllRanges();
257
+ sel.addRange(range);
258
+ }
259
+
260
+ /**
261
+ * Get cursor offset within an element's text content (excluding nested lists)
262
+ */
263
+ function getCursorOffsetInElement(element: Element): number {
264
+ const sel = window.getSelection();
265
+ if (!sel || sel.rangeCount === 0) return 0;
266
+
267
+ const range = sel.getRangeAt(0);
268
+
269
+ // Create a range from start of element to cursor position
270
+ const preCaretRange = document.createRange();
271
+ preCaretRange.selectNodeContents(element);
272
+ preCaretRange.setEnd(range.startContainer, range.startOffset);
273
+
274
+ // Count characters by walking text nodes, excluding nested lists
275
+ let offset = 0;
276
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
277
+ let node: Text | null;
278
+
279
+ while ((node = walker.nextNode() as Text | null)) {
280
+ // Skip text nodes inside nested lists
281
+ let parent: Node | null = node.parentNode;
282
+ let inNestedList = false;
283
+ while (parent && parent !== element) {
284
+ if (parent.nodeName === "UL" || parent.nodeName === "OL") {
285
+ inNestedList = true;
286
+ break;
287
+ }
288
+ parent = parent.parentNode;
289
+ }
290
+ if (inNestedList) continue;
291
+
292
+ // Check if this text node is before or at the cursor
293
+ const nodeRange = document.createRange();
294
+ nodeRange.selectNodeContents(node);
295
+
296
+ if (preCaretRange.compareBoundaryPoints(Range.END_TO_START, nodeRange) >= 0) {
297
+ // Entire node is before cursor
298
+ offset += node.textContent?.length || 0;
299
+ } else if (preCaretRange.compareBoundaryPoints(Range.END_TO_END, nodeRange) >= 0) {
300
+ // Cursor is inside this node - calculate partial offset
301
+ if (range.startContainer === node) {
302
+ offset += range.startOffset;
303
+ } else {
304
+ offset += node.textContent?.length || 0;
305
+ }
306
+ break;
307
+ } else {
308
+ // Node is after cursor, stop
309
+ break;
310
+ }
311
+ }
312
+
313
+ return offset;
314
+ }
315
+
316
+ /**
317
+ * Restore cursor to a specific text offset within an element (excluding nested lists)
318
+ */
319
+ function restoreCursorToOffset(element: Element, targetOffset: number): void {
320
+ const sel = window.getSelection();
321
+ if (!sel) return;
322
+
323
+ let currentOffset = 0;
324
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
325
+ let node: Text | null;
326
+
327
+ while ((node = walker.nextNode() as Text | null)) {
328
+ // Skip text nodes inside nested lists
329
+ let parent: Node | null = node.parentNode;
330
+ let inNestedList = false;
331
+ while (parent && parent !== element) {
332
+ if (parent.nodeName === "UL" || parent.nodeName === "OL") {
333
+ inNestedList = true;
334
+ break;
335
+ }
336
+ parent = parent.parentNode;
337
+ }
338
+ if (inNestedList) continue;
339
+
340
+ const nodeLength = node.textContent?.length || 0;
341
+ if (currentOffset + nodeLength >= targetOffset) {
342
+ const range = document.createRange();
343
+ range.setStart(node, targetOffset - currentOffset);
344
+ range.collapse(true);
345
+ sel.removeAllRanges();
346
+ sel.addRange(range);
347
+ return;
348
+ }
349
+ currentOffset += nodeLength;
350
+ }
351
+
352
+ // If offset not found (e.g., text is shorter), place cursor at end
353
+ positionCursorAtEnd(element);
354
+ }
355
+
356
+ /**
357
+ * Composable for list operations in markdown editor
358
+ */
359
+ export function useLists(options: UseListsOptions): UseListsReturn {
360
+ const { contentRef, selection, onContentChange } = options;
361
+
362
+ /**
363
+ * Toggle unordered list on the current block
364
+ * - If paragraph/div: convert to <ul><li>
365
+ * - If already in ul: convert back to paragraph
366
+ * - If in ol: convert to ul
367
+ */
368
+ function toggleUnorderedList(): void {
369
+ if (!contentRef.value) return;
370
+
371
+ const currentListType = getListType(selection);
372
+
373
+ if (currentListType === "ul") {
374
+ // Already in ul - convert to paragraph
375
+ const li = getListItem(selection);
376
+ if (li) {
377
+ const p = convertListItemToParagraph(li, contentRef.value);
378
+ positionCursorAtEnd(p);
379
+ }
380
+ } else if (currentListType === "ol") {
381
+ // In ol - convert to ul
382
+ const li = getListItem(selection);
383
+ if (li) {
384
+ const parentList = getParentList(li);
385
+ if (parentList) {
386
+ const ul = document.createElement("ul");
387
+ while (parentList.firstChild) {
388
+ ul.appendChild(parentList.firstChild);
389
+ }
390
+ parentList.parentNode?.replaceChild(ul, parentList);
391
+ // Find the li again (it moved) and position cursor
392
+ const newLi = ul.querySelector("li");
393
+ if (newLi) positionCursorAtEnd(newLi);
394
+ }
395
+ }
396
+ } else {
397
+ // Not in list - convert block to ul
398
+ const block = getTargetBlock(contentRef, selection);
399
+ if (block && isConvertibleBlock(block)) {
400
+ const li = convertToListItem(block, "ul");
401
+ positionCursorAtEnd(li);
402
+ }
403
+ }
404
+
405
+ onContentChange();
406
+ }
407
+
408
+ /**
409
+ * Toggle ordered list on the current block
410
+ * - If paragraph/div: convert to <ol><li>
411
+ * - If already in ol: convert back to paragraph
412
+ * - If in ul: convert to ol
413
+ */
414
+ function toggleOrderedList(): void {
415
+ if (!contentRef.value) return;
416
+
417
+ const currentListType = getListType(selection);
418
+
419
+ if (currentListType === "ol") {
420
+ // Already in ol - convert to paragraph
421
+ const li = getListItem(selection);
422
+ if (li) {
423
+ const p = convertListItemToParagraph(li, contentRef.value);
424
+ positionCursorAtEnd(p);
425
+ }
426
+ } else if (currentListType === "ul") {
427
+ // In ul - convert to ol
428
+ const li = getListItem(selection);
429
+ if (li) {
430
+ const parentList = getParentList(li);
431
+ if (parentList) {
432
+ const ol = document.createElement("ol");
433
+ while (parentList.firstChild) {
434
+ ol.appendChild(parentList.firstChild);
435
+ }
436
+ parentList.parentNode?.replaceChild(ol, parentList);
437
+ // Find the li again (it moved) and position cursor
438
+ const newLi = ol.querySelector("li");
439
+ if (newLi) positionCursorAtEnd(newLi);
440
+ }
441
+ }
442
+ } else {
443
+ // Not in list - convert block to ol
444
+ const block = getTargetBlock(contentRef, selection);
445
+ if (block && isConvertibleBlock(block)) {
446
+ const li = convertToListItem(block, "ol");
447
+ positionCursorAtEnd(li);
448
+ }
449
+ }
450
+
451
+ onContentChange();
452
+ }
453
+
454
+ /**
455
+ * Check if the current block contains a list pattern (e.g., "- ", "1. ")
456
+ * and convert it to the appropriate list if detected.
457
+ * Only converts paragraphs/divs/headings, not existing list items.
458
+ * @returns true if a pattern was detected and converted, false otherwise
459
+ */
460
+ function checkAndConvertListPattern(): boolean {
461
+ if (!contentRef.value) return false;
462
+
463
+ const block = getTargetBlock(contentRef, selection);
464
+ if (!block) return false;
465
+
466
+ // Only convert paragraphs, divs, or headings - don't convert existing list items
467
+ if (!isConvertibleBlock(block)) return false;
468
+
469
+ // Get the text content of the block
470
+ const textContent = block.textContent || "";
471
+
472
+ // Check for list pattern
473
+ const pattern = detectListPattern(textContent);
474
+ if (!pattern) return false;
475
+
476
+ // Pattern detected - convert to list
477
+ const listType = pattern.type === "unordered" ? "ul" : "ol";
478
+ const remainingContent = pattern.content;
479
+
480
+ // Create the new list structure
481
+ const list = document.createElement(listType);
482
+ const li = document.createElement("li");
483
+ li.textContent = remainingContent;
484
+ list.appendChild(li);
485
+
486
+ // Replace block with list
487
+ block.parentNode?.replaceChild(list, block);
488
+
489
+ // Position cursor at the end of the content
490
+ positionCursorAtEnd(li);
491
+
492
+ // Notify of content change
493
+ onContentChange();
494
+
495
+ return true;
496
+ }
497
+
498
+ /**
499
+ * Handle Enter key press when in a list
500
+ * - If list item has content: create new list item after current
501
+ * - If list item is empty AND nested: outdent to parent list level
502
+ * - If list item is empty AND at top level: exit list (convert to paragraph)
503
+ * @returns true if the Enter was handled, false to let browser handle it
504
+ */
505
+ function handleListEnter(): boolean {
506
+ if (!contentRef.value) return false;
507
+
508
+ // Check if we're in a list
509
+ const li = getListItem(selection);
510
+ if (!li) return false;
511
+
512
+ const parentList = getParentList(li);
513
+ if (!parentList) return false;
514
+
515
+ // Check if list item is empty (ignoring nested lists within the li)
516
+ // We need to check only direct text content, not nested list content
517
+ const directContent = getDirectTextContent(li);
518
+
519
+ if (directContent === "") {
520
+ // Empty list item - check if nested or at top level
521
+ const parentLi = parentList.parentElement;
522
+ const isNested = parentLi && parentLi.tagName === "LI";
523
+
524
+ if (isNested) {
525
+ // Nested list - outdent to parent level
526
+ return outdentListItem();
527
+ } else {
528
+ // Top level - exit list (convert to paragraph)
529
+ const p = convertListItemToParagraph(li, contentRef.value);
530
+ positionCursorAtStart(p);
531
+ onContentChange();
532
+ return true;
533
+ }
534
+ }
535
+
536
+ // List item has content - create new list item
537
+ // Get cursor position within the li
538
+ const sel = window.getSelection();
539
+ if (!sel || sel.rangeCount === 0) return false;
540
+
541
+ const range = sel.getRangeAt(0);
542
+
543
+ // Check if cursor is at end of list item
544
+ const cursorAtEnd = isCursorAtEndOfElement(li, range);
545
+
546
+ // Create new list item
547
+ const newLi = document.createElement("li");
548
+
549
+ if (cursorAtEnd) {
550
+ // Cursor at end - create empty new item
551
+ newLi.appendChild(document.createElement("br")); // Ensure empty li is editable
552
+ } else {
553
+ // Cursor in middle - split content
554
+ // Extract content after cursor
555
+ const afterRange = document.createRange();
556
+ afterRange.setStart(range.endContainer, range.endOffset);
557
+ afterRange.setEndAfter(li.lastChild || li);
558
+ const afterContent = afterRange.extractContents();
559
+ newLi.appendChild(afterContent);
560
+ }
561
+
562
+ // Insert new li after current
563
+ li.parentNode?.insertBefore(newLi, li.nextSibling);
564
+
565
+ // Position cursor at start of new li
566
+ positionCursorAtStart(newLi);
567
+
568
+ onContentChange();
569
+ return true;
570
+ }
571
+
572
+ /**
573
+ * Get direct text content of an element, excluding nested lists
574
+ */
575
+ function getDirectTextContent(element: Element): string {
576
+ let text = "";
577
+ const children = Array.from(element.childNodes);
578
+ for (const child of children) {
579
+ if (child.nodeType === Node.TEXT_NODE) {
580
+ text += child.textContent || "";
581
+ } else if (child.nodeType === Node.ELEMENT_NODE) {
582
+ const el = child as Element;
583
+ // Skip nested lists
584
+ if (el.tagName !== "UL" && el.tagName !== "OL") {
585
+ text += getDirectTextContent(el);
586
+ }
587
+ }
588
+ }
589
+ return text.trim();
590
+ }
591
+
592
+ /**
593
+ * Check if cursor is at the end of an element
594
+ */
595
+ function isCursorAtEndOfElement(element: Element, range: Range): boolean {
596
+ // Create a range from cursor to end of element
597
+ const testRange = document.createRange();
598
+ testRange.setStart(range.endContainer, range.endOffset);
599
+ testRange.setEndAfter(element.lastChild || element);
600
+
601
+ // If the range is empty, cursor is at end
602
+ return testRange.toString().trim() === "";
603
+ }
604
+
605
+ /**
606
+ * Indent current list item (Tab key)
607
+ * Creates a nested list inside the previous list item
608
+ * @returns true if handled, false otherwise
609
+ */
610
+ function indentListItem(): boolean {
611
+ if (!contentRef.value) return false;
612
+
613
+ const li = getListItem(selection);
614
+ if (!li) return false;
615
+
616
+ const parentList = getParentList(li);
617
+ if (!parentList) return false;
618
+
619
+ // Get previous sibling li
620
+ const prevLi = li.previousElementSibling;
621
+ if (!prevLi || prevLi.tagName !== "LI") return false; // Can't indent first item
622
+
623
+ // Save cursor offset within this specific list item before DOM manipulation
624
+ const cursorOffset = getCursorOffsetInElement(li);
625
+
626
+ // Check if prev li already has a nested list
627
+ let nestedList = prevLi.querySelector(":scope > ul, :scope > ol") as HTMLUListElement | HTMLOListElement | null;
628
+
629
+ if (!nestedList) {
630
+ // Create nested list of same type
631
+ nestedList = document.createElement(parentList.tagName.toLowerCase()) as HTMLUListElement | HTMLOListElement;
632
+ prevLi.appendChild(nestedList);
633
+ }
634
+
635
+ // Move current li to nested list
636
+ nestedList.appendChild(li);
637
+
638
+ // Restore cursor to same offset within the moved list item
639
+ restoreCursorToOffset(li, cursorOffset);
640
+
641
+ onContentChange();
642
+ return true;
643
+ }
644
+
645
+ /**
646
+ * Outdent current list item (Shift+Tab key)
647
+ * Moves list item up one level
648
+ * @returns true if handled, false otherwise
649
+ */
650
+ function outdentListItem(): boolean {
651
+ if (!contentRef.value) return false;
652
+
653
+ const li = getListItem(selection);
654
+ if (!li) return false;
655
+
656
+ const parentList = getParentList(li);
657
+ if (!parentList) return false;
658
+
659
+ // Check if parent list is nested (has a parent li)
660
+ const parentLi = parentList.parentElement;
661
+ if (!parentLi || parentLi.tagName !== "LI") {
662
+ // Already at top level - convert to paragraph
663
+ const cursorOffset = getCursorOffsetInElement(li);
664
+ const p = convertListItemToParagraph(li, contentRef.value);
665
+ restoreCursorToOffset(p, cursorOffset);
666
+ onContentChange();
667
+ return true;
668
+ }
669
+
670
+ // Save cursor offset within this specific list item before DOM manipulation
671
+ const cursorOffset = getCursorOffsetInElement(li);
672
+
673
+ // Find the grandparent list
674
+ const grandparentList = getParentList(parentLi as HTMLLIElement);
675
+ if (!grandparentList) return false;
676
+
677
+ // Move items after current li to a new nested list in current li
678
+ const itemsAfter = [];
679
+ let sibling = li.nextElementSibling;
680
+ while (sibling) {
681
+ const next = sibling.nextElementSibling;
682
+ itemsAfter.push(sibling);
683
+ sibling = next;
684
+ }
685
+
686
+ if (itemsAfter.length > 0) {
687
+ // Create nested list for items after
688
+ let nestedList = li.querySelector(":scope > ul, :scope > ol") as HTMLUListElement | HTMLOListElement | null;
689
+ if (!nestedList) {
690
+ nestedList = document.createElement(parentList.tagName.toLowerCase()) as HTMLUListElement | HTMLOListElement;
691
+ li.appendChild(nestedList);
692
+ }
693
+ for (const item of itemsAfter) {
694
+ nestedList.appendChild(item);
695
+ }
696
+ }
697
+
698
+ // Move current li after parent li in grandparent list
699
+ grandparentList.insertBefore(li, parentLi.nextSibling);
700
+
701
+ // Clean up empty parent list
702
+ if (parentList.children.length === 0) {
703
+ parentList.remove();
704
+ }
705
+
706
+ // Restore cursor to same offset within the moved list item
707
+ restoreCursorToOffset(li, cursorOffset);
708
+
709
+ onContentChange();
710
+ return true;
711
+ }
712
+
713
+ /**
714
+ * Get current list type (exposed for menu)
715
+ */
716
+ function getCurrentListType(): "ul" | "ol" | null {
717
+ return getListType(selection);
718
+ }
719
+
720
+ /**
721
+ * Convert current list item to paragraph
722
+ * Used when switching from list to heading/paragraph via menu
723
+ * @returns the new paragraph element, or null if not in a list
724
+ */
725
+ function convertCurrentListItemToParagraphFn(): HTMLParagraphElement | null {
726
+ if (!contentRef.value) return null;
727
+
728
+ const li = getListItem(selection);
729
+ if (!li) return null;
730
+
731
+ const p = convertListItemToParagraph(li, contentRef.value);
732
+ positionCursorAtEnd(p);
733
+ onContentChange();
734
+ return p;
735
+ }
736
+
737
+ return {
738
+ toggleUnorderedList,
739
+ toggleOrderedList,
740
+ checkAndConvertListPattern,
741
+ handleListEnter,
742
+ indentListItem,
743
+ outdentListItem,
744
+ getCurrentListType,
745
+ convertCurrentListItemToParagraph: convertCurrentListItemToParagraphFn
746
+ };
747
+ }