quasar-ui-danx 0.4.99 → 0.5.1
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.
- package/dist/danx.es.js +17884 -12732
- package/dist/danx.es.js.map +1 -1
- package/dist/danx.umd.js +192 -118
- package/dist/danx.umd.js.map +1 -1
- package/dist/style.css +1 -1
- package/package.json +11 -2
- package/scripts/publish.sh +76 -0
- package/src/components/Utility/Code/CodeViewer.vue +31 -14
- package/src/components/Utility/Code/CodeViewerCollapsed.vue +2 -0
- package/src/components/Utility/Code/CodeViewerFooter.vue +1 -1
- package/src/components/Utility/Code/LanguageBadge.vue +278 -5
- package/src/components/Utility/Code/MarkdownContent.vue +160 -6
- package/src/components/Utility/Code/index.ts +3 -0
- package/src/components/Utility/Markdown/ContextMenu.vue +314 -0
- package/src/components/Utility/Markdown/HotkeyHelpPopover.vue +259 -0
- package/src/components/Utility/Markdown/LineTypeMenu.vue +226 -0
- package/src/components/Utility/Markdown/LinkPopover.vue +331 -0
- package/src/components/Utility/Markdown/MarkdownEditor.vue +228 -0
- package/src/components/Utility/Markdown/MarkdownEditorContent.vue +235 -0
- package/src/components/Utility/Markdown/MarkdownEditorFooter.vue +50 -0
- package/src/components/Utility/Markdown/TablePopover.vue +420 -0
- package/src/components/Utility/Markdown/index.ts +11 -0
- package/src/components/Utility/Markdown/types.ts +27 -0
- package/src/components/Utility/index.ts +1 -0
- package/src/composables/index.ts +1 -0
- package/src/composables/markdown/features/useBlockquotes.spec.ts +428 -0
- package/src/composables/markdown/features/useBlockquotes.ts +248 -0
- package/src/composables/markdown/features/useCodeBlockManager.ts +369 -0
- package/src/composables/markdown/features/useCodeBlocks.spec.ts +779 -0
- package/src/composables/markdown/features/useCodeBlocks.ts +774 -0
- package/src/composables/markdown/features/useContextMenu.ts +444 -0
- package/src/composables/markdown/features/useFocusTracking.ts +116 -0
- package/src/composables/markdown/features/useHeadings.spec.ts +834 -0
- package/src/composables/markdown/features/useHeadings.ts +290 -0
- package/src/composables/markdown/features/useInlineFormatting.spec.ts +705 -0
- package/src/composables/markdown/features/useInlineFormatting.ts +402 -0
- package/src/composables/markdown/features/useLineTypeMenu.ts +285 -0
- package/src/composables/markdown/features/useLinks.spec.ts +369 -0
- package/src/composables/markdown/features/useLinks.ts +374 -0
- package/src/composables/markdown/features/useLists.spec.ts +834 -0
- package/src/composables/markdown/features/useLists.ts +747 -0
- package/src/composables/markdown/features/usePopoverManager.ts +181 -0
- package/src/composables/markdown/features/useTables.spec.ts +1601 -0
- package/src/composables/markdown/features/useTables.ts +1107 -0
- package/src/composables/markdown/index.ts +16 -0
- package/src/composables/markdown/useMarkdownEditor.spec.ts +332 -0
- package/src/composables/markdown/useMarkdownEditor.ts +1068 -0
- package/src/composables/markdown/useMarkdownHotkeys.spec.ts +791 -0
- package/src/composables/markdown/useMarkdownHotkeys.ts +266 -0
- package/src/composables/markdown/useMarkdownSelection.ts +219 -0
- package/src/composables/markdown/useMarkdownSync.ts +549 -0
- package/src/composables/useCodeViewerEditor.spec.ts +655 -0
- package/src/composables/useCodeViewerEditor.ts +174 -20
- package/src/helpers/formats/index.ts +1 -1
- package/src/helpers/formats/markdown/escapeHtml.ts +15 -0
- package/src/helpers/formats/markdown/escapeSequences.ts +60 -0
- package/src/helpers/formats/markdown/htmlToMarkdown/convertHeadings.ts +41 -0
- package/src/helpers/formats/markdown/htmlToMarkdown/index.spec.ts +489 -0
- package/src/helpers/formats/markdown/htmlToMarkdown/index.ts +412 -0
- package/src/helpers/formats/markdown/index.ts +92 -0
- package/src/helpers/formats/markdown/linePatterns.spec.ts +495 -0
- package/src/helpers/formats/markdown/linePatterns.ts +172 -0
- package/src/helpers/formats/markdown/parseInline.ts +124 -0
- package/src/helpers/formats/markdown/render/index.ts +92 -0
- package/src/helpers/formats/markdown/render/renderFootnotes.ts +30 -0
- package/src/helpers/formats/markdown/render/renderList.ts +69 -0
- package/src/helpers/formats/markdown/render/renderTable.ts +38 -0
- package/src/helpers/formats/markdown/state.ts +58 -0
- package/src/helpers/formats/markdown/tokenize/extractDefinitions.ts +39 -0
- package/src/helpers/formats/markdown/tokenize/index.ts +139 -0
- package/src/helpers/formats/markdown/tokenize/parseBlockquote.ts +34 -0
- package/src/helpers/formats/markdown/tokenize/parseCodeBlock.ts +85 -0
- package/src/helpers/formats/markdown/tokenize/parseDefinitionList.ts +88 -0
- package/src/helpers/formats/markdown/tokenize/parseHeading.ts +65 -0
- package/src/helpers/formats/markdown/tokenize/parseHorizontalRule.ts +22 -0
- package/src/helpers/formats/markdown/tokenize/parseList.ts +119 -0
- package/src/helpers/formats/markdown/tokenize/parseParagraph.ts +59 -0
- package/src/helpers/formats/markdown/tokenize/parseTable.ts +70 -0
- package/src/helpers/formats/markdown/tokenize/parseTaskList.ts +47 -0
- package/src/helpers/formats/markdown/tokenize/utils.ts +25 -0
- package/src/helpers/formats/markdown/types.ts +63 -0
- package/src/styles/danx.scss +1 -0
- package/src/styles/themes/danx/markdown.scss +96 -0
- package/src/test/helpers/editorTestUtils.spec.ts +296 -0
- package/src/test/helpers/editorTestUtils.ts +253 -0
- package/src/test/helpers/index.ts +1 -0
- package/src/test/setup.test.ts +12 -0
- package/src/test/setup.ts +12 -0
- package/vitest.config.ts +19 -0
- package/src/helpers/formats/renderMarkdown.ts +0 -338
|
@@ -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
|
+
}
|