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.
Files changed (90) hide show
  1. package/dist/danx.es.js +17884 -12732
  2. package/dist/danx.es.js.map +1 -1
  3. package/dist/danx.umd.js +192 -118
  4. package/dist/danx.umd.js.map +1 -1
  5. package/dist/style.css +1 -1
  6. package/package.json +11 -2
  7. package/scripts/publish.sh +76 -0
  8. package/src/components/Utility/Code/CodeViewer.vue +31 -14
  9. package/src/components/Utility/Code/CodeViewerCollapsed.vue +2 -0
  10. package/src/components/Utility/Code/CodeViewerFooter.vue +1 -1
  11. package/src/components/Utility/Code/LanguageBadge.vue +278 -5
  12. package/src/components/Utility/Code/MarkdownContent.vue +160 -6
  13. package/src/components/Utility/Code/index.ts +3 -0
  14. package/src/components/Utility/Markdown/ContextMenu.vue +314 -0
  15. package/src/components/Utility/Markdown/HotkeyHelpPopover.vue +259 -0
  16. package/src/components/Utility/Markdown/LineTypeMenu.vue +226 -0
  17. package/src/components/Utility/Markdown/LinkPopover.vue +331 -0
  18. package/src/components/Utility/Markdown/MarkdownEditor.vue +228 -0
  19. package/src/components/Utility/Markdown/MarkdownEditorContent.vue +235 -0
  20. package/src/components/Utility/Markdown/MarkdownEditorFooter.vue +50 -0
  21. package/src/components/Utility/Markdown/TablePopover.vue +420 -0
  22. package/src/components/Utility/Markdown/index.ts +11 -0
  23. package/src/components/Utility/Markdown/types.ts +27 -0
  24. package/src/components/Utility/index.ts +1 -0
  25. package/src/composables/index.ts +1 -0
  26. package/src/composables/markdown/features/useBlockquotes.spec.ts +428 -0
  27. package/src/composables/markdown/features/useBlockquotes.ts +248 -0
  28. package/src/composables/markdown/features/useCodeBlockManager.ts +369 -0
  29. package/src/composables/markdown/features/useCodeBlocks.spec.ts +779 -0
  30. package/src/composables/markdown/features/useCodeBlocks.ts +774 -0
  31. package/src/composables/markdown/features/useContextMenu.ts +444 -0
  32. package/src/composables/markdown/features/useFocusTracking.ts +116 -0
  33. package/src/composables/markdown/features/useHeadings.spec.ts +834 -0
  34. package/src/composables/markdown/features/useHeadings.ts +290 -0
  35. package/src/composables/markdown/features/useInlineFormatting.spec.ts +705 -0
  36. package/src/composables/markdown/features/useInlineFormatting.ts +402 -0
  37. package/src/composables/markdown/features/useLineTypeMenu.ts +285 -0
  38. package/src/composables/markdown/features/useLinks.spec.ts +369 -0
  39. package/src/composables/markdown/features/useLinks.ts +374 -0
  40. package/src/composables/markdown/features/useLists.spec.ts +834 -0
  41. package/src/composables/markdown/features/useLists.ts +747 -0
  42. package/src/composables/markdown/features/usePopoverManager.ts +181 -0
  43. package/src/composables/markdown/features/useTables.spec.ts +1601 -0
  44. package/src/composables/markdown/features/useTables.ts +1107 -0
  45. package/src/composables/markdown/index.ts +16 -0
  46. package/src/composables/markdown/useMarkdownEditor.spec.ts +332 -0
  47. package/src/composables/markdown/useMarkdownEditor.ts +1068 -0
  48. package/src/composables/markdown/useMarkdownHotkeys.spec.ts +791 -0
  49. package/src/composables/markdown/useMarkdownHotkeys.ts +266 -0
  50. package/src/composables/markdown/useMarkdownSelection.ts +219 -0
  51. package/src/composables/markdown/useMarkdownSync.ts +549 -0
  52. package/src/composables/useCodeViewerEditor.spec.ts +655 -0
  53. package/src/composables/useCodeViewerEditor.ts +174 -20
  54. package/src/helpers/formats/index.ts +1 -1
  55. package/src/helpers/formats/markdown/escapeHtml.ts +15 -0
  56. package/src/helpers/formats/markdown/escapeSequences.ts +60 -0
  57. package/src/helpers/formats/markdown/htmlToMarkdown/convertHeadings.ts +41 -0
  58. package/src/helpers/formats/markdown/htmlToMarkdown/index.spec.ts +489 -0
  59. package/src/helpers/formats/markdown/htmlToMarkdown/index.ts +412 -0
  60. package/src/helpers/formats/markdown/index.ts +92 -0
  61. package/src/helpers/formats/markdown/linePatterns.spec.ts +495 -0
  62. package/src/helpers/formats/markdown/linePatterns.ts +172 -0
  63. package/src/helpers/formats/markdown/parseInline.ts +124 -0
  64. package/src/helpers/formats/markdown/render/index.ts +92 -0
  65. package/src/helpers/formats/markdown/render/renderFootnotes.ts +30 -0
  66. package/src/helpers/formats/markdown/render/renderList.ts +69 -0
  67. package/src/helpers/formats/markdown/render/renderTable.ts +38 -0
  68. package/src/helpers/formats/markdown/state.ts +58 -0
  69. package/src/helpers/formats/markdown/tokenize/extractDefinitions.ts +39 -0
  70. package/src/helpers/formats/markdown/tokenize/index.ts +139 -0
  71. package/src/helpers/formats/markdown/tokenize/parseBlockquote.ts +34 -0
  72. package/src/helpers/formats/markdown/tokenize/parseCodeBlock.ts +85 -0
  73. package/src/helpers/formats/markdown/tokenize/parseDefinitionList.ts +88 -0
  74. package/src/helpers/formats/markdown/tokenize/parseHeading.ts +65 -0
  75. package/src/helpers/formats/markdown/tokenize/parseHorizontalRule.ts +22 -0
  76. package/src/helpers/formats/markdown/tokenize/parseList.ts +119 -0
  77. package/src/helpers/formats/markdown/tokenize/parseParagraph.ts +59 -0
  78. package/src/helpers/formats/markdown/tokenize/parseTable.ts +70 -0
  79. package/src/helpers/formats/markdown/tokenize/parseTaskList.ts +47 -0
  80. package/src/helpers/formats/markdown/tokenize/utils.ts +25 -0
  81. package/src/helpers/formats/markdown/types.ts +63 -0
  82. package/src/styles/danx.scss +1 -0
  83. package/src/styles/themes/danx/markdown.scss +96 -0
  84. package/src/test/helpers/editorTestUtils.spec.ts +296 -0
  85. package/src/test/helpers/editorTestUtils.ts +253 -0
  86. package/src/test/helpers/index.ts +1 -0
  87. package/src/test/setup.test.ts +12 -0
  88. package/src/test/setup.ts +12 -0
  89. package/vitest.config.ts +19 -0
  90. package/src/helpers/formats/renderMarkdown.ts +0 -338
@@ -0,0 +1,402 @@
1
+ import { Ref } from "vue";
2
+
3
+ /**
4
+ * Options for useInlineFormatting composable
5
+ */
6
+ export interface UseInlineFormattingOptions {
7
+ contentRef: Ref<HTMLElement | null>;
8
+ onContentChange: () => void;
9
+ }
10
+
11
+ /**
12
+ * Return type for useInlineFormatting composable
13
+ */
14
+ export interface UseInlineFormattingReturn {
15
+ /** Toggle bold formatting on selection */
16
+ toggleBold: () => void;
17
+ /** Toggle italic formatting on selection */
18
+ toggleItalic: () => void;
19
+ /** Toggle strikethrough formatting on selection */
20
+ toggleStrikethrough: () => void;
21
+ /** Toggle inline code formatting on selection */
22
+ toggleInlineCode: () => void;
23
+ /** Toggle highlight formatting on selection */
24
+ toggleHighlight: () => void;
25
+ /** Toggle underline formatting on selection */
26
+ toggleUnderline: () => void;
27
+ }
28
+
29
+ /**
30
+ * Inline formatting tag mappings
31
+ */
32
+ const FORMAT_TAGS = {
33
+ bold: { tag: "STRONG", fallback: "B" },
34
+ italic: { tag: "EM", fallback: "I" },
35
+ strikethrough: { tag: "DEL", fallback: "S" },
36
+ code: { tag: "CODE", fallback: null },
37
+ highlight: { tag: "MARK", fallback: null },
38
+ underline: { tag: "U", fallback: null }
39
+ } as const;
40
+
41
+ type FormatType = keyof typeof FORMAT_TAGS;
42
+
43
+ /**
44
+ * Check if a node or its ancestors have a specific formatting tag
45
+ */
46
+ function hasFormatting(node: Node | null, formatType: FormatType): Element | null {
47
+ const { tag, fallback } = FORMAT_TAGS[formatType];
48
+
49
+ let current: Node | null = node;
50
+ while (current && current.nodeType !== Node.DOCUMENT_NODE) {
51
+ if (current.nodeType === Node.ELEMENT_NODE) {
52
+ const element = current as Element;
53
+ const tagName = element.tagName.toUpperCase();
54
+ if (tagName === tag || (fallback && tagName === fallback)) {
55
+ return element;
56
+ }
57
+ }
58
+ current = current.parentNode;
59
+ }
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * Composable for inline formatting operations in markdown editor
65
+ */
66
+ export function useInlineFormatting(options: UseInlineFormattingOptions): UseInlineFormattingReturn {
67
+ const { contentRef, onContentChange } = options;
68
+
69
+ /**
70
+ * Apply or remove inline formatting to the current selection
71
+ *
72
+ * Behavior:
73
+ * - With selection that matches entire formatted element: remove formatting
74
+ * - With selection inside formatted element: remove formatting from selection only
75
+ * - With selection (no existing format): wrap selection with formatting
76
+ * - No selection, cursor inside formatted text: move cursor after formatted element
77
+ * - No selection, cursor outside formatted text: insert formatted placeholder
78
+ */
79
+ function toggleFormat(formatType: FormatType): void {
80
+ if (!contentRef.value) return;
81
+
82
+ const selection = window.getSelection();
83
+ if (!selection || selection.rangeCount === 0) return;
84
+
85
+ const range = selection.getRangeAt(0);
86
+
87
+ // Check if selection is within our content area
88
+ if (!contentRef.value.contains(range.commonAncestorContainer)) return;
89
+
90
+ const { tag } = FORMAT_TAGS[formatType];
91
+
92
+ // Check if the selection/cursor is inside formatted text
93
+ const existingFormat = hasFormatting(range.commonAncestorContainer, formatType);
94
+
95
+ if (!range.collapsed) {
96
+ // There's a selection
97
+ if (existingFormat && isSelectionEntireElement(range, existingFormat)) {
98
+ // Selection matches the entire formatted element - remove formatting
99
+ removeFormatting(existingFormat);
100
+ } else if (existingFormat) {
101
+ // Selection is partially inside formatted element - remove format from selection
102
+ unwrapSelectionFromFormat(range, existingFormat, formatType);
103
+ } else {
104
+ // Selection has no formatting - wrap it
105
+ wrapSelection(range, tag.toLowerCase());
106
+ }
107
+ } else {
108
+ // No selection (cursor only)
109
+ if (existingFormat) {
110
+ // Cursor is inside formatted text - move cursor after the formatted element
111
+ moveCursorAfterElement(existingFormat);
112
+ } else {
113
+ // Cursor is in unformatted area - insert formatted placeholder
114
+ insertFormattedPlaceholder(range, tag.toLowerCase(), formatType);
115
+ }
116
+ }
117
+
118
+ onContentChange();
119
+ }
120
+
121
+ /**
122
+ * Check if selection encompasses the entire element's content
123
+ */
124
+ function isSelectionEntireElement(range: Range, element: Element): boolean {
125
+ return range.toString() === element.textContent;
126
+ }
127
+
128
+ /**
129
+ * Move cursor to position immediately after an element by inserting a
130
+ * zero-width space to break out of the formatting context.
131
+ * The ZWS is cleaned up during HTML→markdown conversion.
132
+ */
133
+ function moveCursorAfterElement(element: Element): void {
134
+ const selection = window.getSelection();
135
+ if (!selection) return;
136
+
137
+ // Insert a zero-width space after the element to break out of formatting
138
+ const zws = document.createTextNode("\u200B");
139
+ element.parentNode?.insertBefore(zws, element.nextSibling);
140
+
141
+ // Position cursor after the zero-width space
142
+ const range = document.createRange();
143
+ range.setStart(zws, 1);
144
+ range.collapse(true);
145
+
146
+ selection.removeAllRanges();
147
+ selection.addRange(range);
148
+ }
149
+
150
+ /**
151
+ * Remove formatting from just the selected portion within a formatted element
152
+ */
153
+ function unwrapSelectionFromFormat(range: Range, formatElement: Element, formatType: FormatType): void {
154
+ const { tag } = FORMAT_TAGS[formatType];
155
+ const tagLower = tag.toLowerCase();
156
+
157
+ // Get the selected text
158
+ const selectedText = range.toString();
159
+ const fullText = formatElement.textContent || "";
160
+
161
+ // Find where the selection is within the formatted element
162
+ const beforeText = fullText.substring(0, fullText.indexOf(selectedText));
163
+ const afterText = fullText.substring(fullText.indexOf(selectedText) + selectedText.length);
164
+
165
+ const parent = formatElement.parentNode;
166
+ if (!parent) return;
167
+
168
+ // Create new structure: [before formatted] [unformatted selection] [after formatted]
169
+ const fragment = document.createDocumentFragment();
170
+
171
+ if (beforeText) {
172
+ const beforeElement = document.createElement(tagLower);
173
+ beforeElement.textContent = beforeText;
174
+ fragment.appendChild(beforeElement);
175
+ }
176
+
177
+ // The unformatted selected text
178
+ const unformattedText = document.createTextNode(selectedText);
179
+ fragment.appendChild(unformattedText);
180
+
181
+ if (afterText) {
182
+ const afterElement = document.createElement(tagLower);
183
+ afterElement.textContent = afterText;
184
+ fragment.appendChild(afterElement);
185
+ }
186
+
187
+ // Replace the original formatted element
188
+ parent.replaceChild(fragment, formatElement);
189
+
190
+ // Select the unformatted text
191
+ const newRange = document.createRange();
192
+ newRange.selectNodeContents(unformattedText);
193
+ const selection = window.getSelection();
194
+ if (selection) {
195
+ selection.removeAllRanges();
196
+ selection.addRange(newRange);
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Remove formatting from an element, keeping its content
202
+ */
203
+ function removeFormatting(element: Element): void {
204
+ const parent = element.parentNode;
205
+ if (!parent) return;
206
+
207
+ // Move all children out of the formatted element
208
+ while (element.firstChild) {
209
+ parent.insertBefore(element.firstChild, element);
210
+ }
211
+
212
+ // Remove the empty formatting element
213
+ parent.removeChild(element);
214
+
215
+ // Normalize to merge adjacent text nodes
216
+ parent.normalize();
217
+ }
218
+
219
+ /**
220
+ * Unwrap all existing formatting elements of the same type within a node tree.
221
+ * This prevents nested formatting like <mark><mark>text</mark></mark>.
222
+ */
223
+ function unwrapExistingFormat(container: Node, tagName: string): void {
224
+ // Handle both Element and DocumentFragment (from extractContents)
225
+ if (container.nodeType === Node.ELEMENT_NODE || container.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
226
+ // Use a temporary div to enable querySelectorAll on DocumentFragment
227
+ const wrapper = document.createElement("div");
228
+ while (container.firstChild) {
229
+ wrapper.appendChild(container.firstChild);
230
+ }
231
+
232
+ // Find all matching elements
233
+ const matchingElements = Array.from(wrapper.querySelectorAll(tagName));
234
+
235
+ // Unwrap each matching element (replace with its children)
236
+ for (const el of matchingElements) {
237
+ const parent = el.parentNode;
238
+ if (parent) {
239
+ while (el.firstChild) {
240
+ parent.insertBefore(el.firstChild, el);
241
+ }
242
+ parent.removeChild(el);
243
+ }
244
+ }
245
+
246
+ // Move content back to original container
247
+ while (wrapper.firstChild) {
248
+ container.appendChild(wrapper.firstChild);
249
+ }
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Remove empty formatting elements from a parent node
255
+ */
256
+ function removeEmptyElements(parent: Node, tagName: string): void {
257
+ if (parent.nodeType === Node.ELEMENT_NODE) {
258
+ const element = parent as Element;
259
+ // Find all matching elements that have no meaningful content
260
+ const emptyElements = Array.from(element.querySelectorAll(tagName)).filter(
261
+ el => !el.textContent?.trim()
262
+ );
263
+ for (const el of emptyElements) {
264
+ el.parentNode?.removeChild(el);
265
+ }
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Wrap the current selection with a formatting tag
271
+ */
272
+ function wrapSelection(range: Range, tagName: string): void {
273
+ // Create the formatting element
274
+ const formatElement = document.createElement(tagName);
275
+
276
+ // Extract the selected content and wrap it
277
+ const contents = range.extractContents();
278
+
279
+ // Unwrap any existing formatting of the same type to prevent nesting
280
+ unwrapExistingFormat(contents, tagName);
281
+
282
+ formatElement.appendChild(contents);
283
+
284
+ // Insert the wrapped content
285
+ range.insertNode(formatElement);
286
+
287
+ // Clean up empty elements left behind by extractContents
288
+ const parentBlock = formatElement.parentElement;
289
+ if (parentBlock) {
290
+ removeEmptyElements(parentBlock, tagName);
291
+ }
292
+
293
+ // Normalize to merge adjacent text nodes
294
+ formatElement.normalize();
295
+
296
+ // Select the newly formatted content
297
+ const newRange = document.createRange();
298
+ newRange.selectNodeContents(formatElement);
299
+
300
+ const selection = window.getSelection();
301
+ if (selection) {
302
+ selection.removeAllRanges();
303
+ selection.addRange(newRange);
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Insert a formatted placeholder when there's no selection
309
+ */
310
+ function insertFormattedPlaceholder(range: Range, tagName: string, formatType: FormatType): void {
311
+ // Create the formatting element with placeholder text
312
+ const formatElement = document.createElement(tagName);
313
+ const placeholderText = getPlaceholderText(formatType);
314
+ formatElement.textContent = placeholderText;
315
+
316
+ // Insert at cursor position
317
+ range.insertNode(formatElement);
318
+
319
+ // Select the placeholder text so user can type over it
320
+ const newRange = document.createRange();
321
+ newRange.selectNodeContents(formatElement);
322
+
323
+ const selection = window.getSelection();
324
+ if (selection) {
325
+ selection.removeAllRanges();
326
+ selection.addRange(newRange);
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Get placeholder text for a format type
332
+ */
333
+ function getPlaceholderText(formatType: FormatType): string {
334
+ switch (formatType) {
335
+ case "bold":
336
+ return "bold text";
337
+ case "italic":
338
+ return "italic text";
339
+ case "strikethrough":
340
+ return "strikethrough text";
341
+ case "code":
342
+ return "code";
343
+ case "highlight":
344
+ return "highlighted text";
345
+ case "underline":
346
+ return "underlined text";
347
+ default:
348
+ return "text";
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Toggle bold formatting (Ctrl+B)
354
+ */
355
+ function toggleBold(): void {
356
+ toggleFormat("bold");
357
+ }
358
+
359
+ /**
360
+ * Toggle italic formatting (Ctrl+I)
361
+ */
362
+ function toggleItalic(): void {
363
+ toggleFormat("italic");
364
+ }
365
+
366
+ /**
367
+ * Toggle strikethrough formatting (Ctrl+Shift+S)
368
+ */
369
+ function toggleStrikethrough(): void {
370
+ toggleFormat("strikethrough");
371
+ }
372
+
373
+ /**
374
+ * Toggle inline code formatting (Ctrl+E)
375
+ */
376
+ function toggleInlineCode(): void {
377
+ toggleFormat("code");
378
+ }
379
+
380
+ /**
381
+ * Toggle highlight formatting (Ctrl+Shift+H)
382
+ */
383
+ function toggleHighlight(): void {
384
+ toggleFormat("highlight");
385
+ }
386
+
387
+ /**
388
+ * Toggle underline formatting (Ctrl+U)
389
+ */
390
+ function toggleUnderline(): void {
391
+ toggleFormat("underline");
392
+ }
393
+
394
+ return {
395
+ toggleBold,
396
+ toggleItalic,
397
+ toggleStrikethrough,
398
+ toggleInlineCode,
399
+ toggleHighlight,
400
+ toggleUnderline
401
+ };
402
+ }
@@ -0,0 +1,285 @@
1
+ import { computed, ComputedRef, onMounted, onUnmounted, Ref, ref, watch } from "vue";
2
+ import { LineType } from "../../../components/Utility/Markdown/types";
3
+ import { UseMarkdownEditorReturn } from "../useMarkdownEditor";
4
+
5
+ /**
6
+ * Options for useLineTypeMenu composable
7
+ */
8
+ export interface UseLineTypeMenuOptions {
9
+ contentRef: Ref<HTMLElement | null>;
10
+ editor: UseMarkdownEditorReturn;
11
+ isEditorFocused: Ref<boolean>;
12
+ }
13
+
14
+ /**
15
+ * Return type for useLineTypeMenu composable
16
+ */
17
+ export interface UseLineTypeMenuReturn {
18
+ currentLineType: ComputedRef<LineType>;
19
+ menuStyle: ComputedRef<{ top: string; opacity: number; pointerEvents: string }>;
20
+ onLineTypeChange: (type: LineType) => void;
21
+ updatePositionAndState: () => void;
22
+ setupListeners: () => void;
23
+ cleanupListeners: () => void;
24
+ }
25
+
26
+ // Map LineType to heading level (single source of truth for bidirectional mapping)
27
+ // Note: ul and ol are handled separately, not as heading levels
28
+ const LINE_TYPE_TO_LEVEL: Record<string, number> = {
29
+ paragraph: 0,
30
+ h1: 1,
31
+ h2: 2,
32
+ h3: 3,
33
+ h4: 4,
34
+ h5: 5,
35
+ h6: 6
36
+ };
37
+
38
+ // Derive the inverse mapping from LINE_TYPE_TO_LEVEL
39
+ const LEVEL_TO_LINE_TYPE = Object.fromEntries(
40
+ Object.entries(LINE_TYPE_TO_LEVEL).map(([type, level]) => [level, type as LineType])
41
+ ) as Record<number, LineType>;
42
+
43
+ /**
44
+ * Composable for managing the floating line type menu in the markdown editor.
45
+ * Handles line type detection, menu positioning, and line type changes.
46
+ */
47
+ export function useLineTypeMenu(options: UseLineTypeMenuOptions): UseLineTypeMenuReturn {
48
+ const { contentRef, editor, isEditorFocused } = options;
49
+
50
+ // Track current heading level for LineTypeMenu
51
+ const currentHeadingLevel = ref(0);
52
+
53
+ // Track the current block element's top position for floating menu
54
+ const currentBlockTop = ref(0);
55
+
56
+ // Track current list type for LineTypeMenu
57
+ const currentListType = ref<"ul" | "ol" | null>(null);
58
+
59
+ // Track if we're in a code block
60
+ const isInCodeBlock = ref(false);
61
+
62
+ // Computed current line type from heading level, list type, or code block
63
+ const currentLineType = computed<LineType>(() => {
64
+ // If we're in a code block, return "code"
65
+ if (isInCodeBlock.value) {
66
+ return "code";
67
+ }
68
+ // If we're in a list, return the list type
69
+ if (currentListType.value) {
70
+ return currentListType.value;
71
+ }
72
+ // Otherwise, return heading type based on level
73
+ const level = currentHeadingLevel.value;
74
+ return LEVEL_TO_LINE_TYPE[level] ?? "paragraph";
75
+ });
76
+
77
+ // Computed style for the floating line type menu
78
+ const menuStyle = computed(() => {
79
+ return {
80
+ top: `${currentBlockTop.value}px`,
81
+ opacity: isEditorFocused.value ? 1 : 0,
82
+ pointerEvents: isEditorFocused.value ? "auto" : "none"
83
+ };
84
+ });
85
+
86
+ /**
87
+ * Find the block element containing the current selection
88
+ */
89
+ function findCurrentBlockElement(): HTMLElement | null {
90
+ const selection = window.getSelection();
91
+ if (!selection || selection.rangeCount === 0) return null;
92
+
93
+ let node: Node | null = selection.anchorNode;
94
+
95
+ // Walk up to find a block element (P, H1-H6, DIV, etc.)
96
+ while (node && node !== contentRef.value) {
97
+ if (node.nodeType === Node.ELEMENT_NODE) {
98
+ const element = node as HTMLElement;
99
+ const tagName = element.tagName.toUpperCase();
100
+ if (["P", "H1", "H2", "H3", "H4", "H5", "H6", "DIV", "BLOCKQUOTE", "PRE", "UL", "OL", "LI"].includes(tagName)) {
101
+ return element;
102
+ }
103
+ }
104
+ node = node.parentNode;
105
+ }
106
+
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Update the floating menu position based on current block
112
+ */
113
+ function updateMenuPosition(): void {
114
+ const contentEl = contentRef.value;
115
+ if (!contentEl) return;
116
+
117
+ const blockElement = findCurrentBlockElement();
118
+ if (!blockElement) {
119
+ // If no block found, position at top
120
+ currentBlockTop.value = 0;
121
+ return;
122
+ }
123
+
124
+ // Get the block's position relative to the content container
125
+ // We need to account for the content's scroll position
126
+ const contentRect = contentEl.getBoundingClientRect();
127
+ const blockRect = blockElement.getBoundingClientRect();
128
+
129
+ // Calculate top position relative to the content container, accounting for scroll
130
+ const relativeTop = blockRect.top - contentRect.top + contentEl.scrollTop;
131
+ currentBlockTop.value = relativeTop;
132
+ }
133
+
134
+ /**
135
+ * Update current heading level, list type, code block state, and menu position when selection changes
136
+ */
137
+ function updatePositionAndState(): void {
138
+ const level = editor.headings.getCurrentHeadingLevel();
139
+ currentHeadingLevel.value = level;
140
+ // Also check if we're in a list
141
+ currentListType.value = editor.lists.getCurrentListType();
142
+ // Also check if we're in a code block
143
+ isInCodeBlock.value = editor.codeBlocks.isInCodeBlock();
144
+ updateMenuPosition();
145
+ }
146
+
147
+ /**
148
+ * Handle line type change from menu
149
+ */
150
+ function onLineTypeChange(type: LineType): void {
151
+ // Handle code block type
152
+ if (type === "code") {
153
+ // If already in a code block, do nothing (or toggle off if that's desired)
154
+ if (editor.codeBlocks.isInCodeBlock()) {
155
+ return;
156
+ }
157
+
158
+ // If currently in a list, first convert the list item to paragraph
159
+ const listType = editor.lists.getCurrentListType();
160
+ if (listType) {
161
+ editor.lists.convertCurrentListItemToParagraph();
162
+ }
163
+
164
+ // Now toggle to code block
165
+ editor.codeBlocks.toggleCodeBlock();
166
+ isInCodeBlock.value = true;
167
+ currentListType.value = null;
168
+ return;
169
+ }
170
+
171
+ // Handle list types
172
+ if (type === "ul") {
173
+ // If in code block, first convert to paragraph
174
+ if (editor.codeBlocks.isInCodeBlock()) {
175
+ editor.codeBlocks.toggleCodeBlock();
176
+ }
177
+ editor.lists.toggleUnorderedList();
178
+ currentListType.value = editor.lists.getCurrentListType();
179
+ isInCodeBlock.value = false;
180
+ return;
181
+ }
182
+ if (type === "ol") {
183
+ // If in code block, first convert to paragraph
184
+ if (editor.codeBlocks.isInCodeBlock()) {
185
+ editor.codeBlocks.toggleCodeBlock();
186
+ }
187
+ editor.lists.toggleOrderedList();
188
+ currentListType.value = editor.lists.getCurrentListType();
189
+ isInCodeBlock.value = false;
190
+ return;
191
+ }
192
+
193
+ // Handle heading/paragraph types
194
+ const level = LINE_TYPE_TO_LEVEL[type];
195
+ if (level !== undefined) {
196
+ // If currently in a code block, first convert to paragraph
197
+ if (editor.codeBlocks.isInCodeBlock()) {
198
+ editor.codeBlocks.toggleCodeBlock();
199
+ }
200
+
201
+ // If currently in a list, first convert the list item to paragraph
202
+ const listType = editor.lists.getCurrentListType();
203
+ if (listType) {
204
+ editor.lists.convertCurrentListItemToParagraph();
205
+ }
206
+
207
+ // Now apply the heading level (0 = paragraph, 1-6 = heading)
208
+ // Only set heading if level > 0 (paragraph is already the result of list conversion)
209
+ if (level > 0) {
210
+ editor.headings.setHeadingLevel(level as 1 | 2 | 3 | 4 | 5 | 6);
211
+ }
212
+
213
+ // Update the tracked level immediately
214
+ currentHeadingLevel.value = level;
215
+ currentListType.value = null;
216
+ isInCodeBlock.value = false;
217
+ }
218
+ }
219
+
220
+ // Track which element has listeners attached
221
+ let boundContentEl: HTMLElement | null = null;
222
+
223
+ /**
224
+ * Handle focus in event
225
+ */
226
+ function handleFocusIn(event: FocusEvent): void {
227
+ const contentEl = contentRef.value;
228
+ if (contentEl && contentEl.contains(event.target as Node)) {
229
+ // Note: isEditorFocused is managed externally, but we update menu position
230
+ updateMenuPosition();
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Setup/cleanup focus and scroll listeners on content element
236
+ */
237
+ function setupContentListeners(el: HTMLElement | null): void {
238
+ // Cleanup previous listeners if element changed
239
+ if (boundContentEl && boundContentEl !== el) {
240
+ boundContentEl.removeEventListener("focusin", handleFocusIn);
241
+ boundContentEl.removeEventListener("scroll", updateMenuPosition);
242
+ boundContentEl = null;
243
+ }
244
+
245
+ // Setup new listeners
246
+ if (el && el !== boundContentEl) {
247
+ el.addEventListener("focusin", handleFocusIn);
248
+ el.addEventListener("scroll", updateMenuPosition);
249
+ boundContentEl = el;
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Setup all listeners (call from component's onMounted)
255
+ */
256
+ function setupListeners(): void {
257
+ // Setup content listeners
258
+ setupContentListeners(contentRef.value);
259
+
260
+ // Watch for content element to become available
261
+ watch(contentRef, (newEl) => {
262
+ setupContentListeners(newEl);
263
+ }, { immediate: true });
264
+
265
+ // Listen for selection changes
266
+ document.addEventListener("selectionchange", updatePositionAndState);
267
+ }
268
+
269
+ /**
270
+ * Cleanup all listeners (call from component's onUnmounted)
271
+ */
272
+ function cleanupListeners(): void {
273
+ document.removeEventListener("selectionchange", updatePositionAndState);
274
+ setupContentListeners(null);
275
+ }
276
+
277
+ return {
278
+ currentLineType,
279
+ menuStyle,
280
+ onLineTypeChange,
281
+ updatePositionAndState,
282
+ setupListeners,
283
+ cleanupListeners
284
+ };
285
+ }