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,428 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { useBlockquotes } from "./useBlockquotes";
3
+ import { createTestEditor, TestEditorResult } from "../../../test/helpers/editorTestUtils";
4
+
5
+ describe("useBlockquotes", () => {
6
+ let editor: TestEditorResult;
7
+ let onContentChange: ReturnType<typeof vi.fn>;
8
+
9
+ afterEach(() => {
10
+ if (editor) {
11
+ editor.destroy();
12
+ }
13
+ });
14
+
15
+ function createBlockquotes() {
16
+ return useBlockquotes({
17
+ contentRef: editor.contentRef,
18
+ onContentChange
19
+ });
20
+ }
21
+
22
+ describe("toggleBlockquote", () => {
23
+ describe("wrapping in blockquote", () => {
24
+ beforeEach(() => {
25
+ onContentChange = vi.fn();
26
+ });
27
+
28
+ it("wraps paragraph in blockquote", () => {
29
+ editor = createTestEditor("<p>Hello world</p>");
30
+ const blockquotes = createBlockquotes();
31
+ editor.setCursorInBlock(0, 5);
32
+
33
+ blockquotes.toggleBlockquote();
34
+
35
+ expect(editor.getHtml()).toBe("<blockquote><p>Hello world</p></blockquote>");
36
+ expect(onContentChange).toHaveBeenCalled();
37
+ });
38
+
39
+ it("wraps h1 in blockquote", () => {
40
+ editor = createTestEditor("<h1>Hello world</h1>");
41
+ const blockquotes = createBlockquotes();
42
+ editor.setCursorInBlock(0, 0);
43
+
44
+ blockquotes.toggleBlockquote();
45
+
46
+ expect(editor.getHtml()).toBe("<blockquote><h1>Hello world</h1></blockquote>");
47
+ expect(onContentChange).toHaveBeenCalled();
48
+ });
49
+
50
+ it("wraps h2 in blockquote", () => {
51
+ editor = createTestEditor("<h2>Test heading</h2>");
52
+ const blockquotes = createBlockquotes();
53
+ editor.setCursorInBlock(0, 0);
54
+
55
+ blockquotes.toggleBlockquote();
56
+
57
+ expect(editor.getHtml()).toBe("<blockquote><h2>Test heading</h2></blockquote>");
58
+ });
59
+
60
+ it("wraps div in blockquote", () => {
61
+ editor = createTestEditor("<div>Div content</div>");
62
+ const blockquotes = createBlockquotes();
63
+ editor.setCursorInBlock(0, 0);
64
+
65
+ blockquotes.toggleBlockquote();
66
+
67
+ expect(editor.getHtml()).toBe("<blockquote><div>Div content</div></blockquote>");
68
+ });
69
+
70
+ it("preserves cursor position after wrapping", () => {
71
+ editor = createTestEditor("<p>Hello world</p>");
72
+ const blockquotes = createBlockquotes();
73
+ editor.setCursorInBlock(0, 5);
74
+
75
+ blockquotes.toggleBlockquote();
76
+
77
+ // Verify cursor is still at offset 5
78
+ const sel = window.getSelection();
79
+ expect(sel).not.toBeNull();
80
+ expect(sel!.rangeCount).toBeGreaterThan(0);
81
+ });
82
+
83
+ it("only wraps current block, not siblings", () => {
84
+ editor = createTestEditor("<p>First</p><p>Second</p><p>Third</p>");
85
+ const blockquotes = createBlockquotes();
86
+ editor.setCursorInBlock(1, 0); // Cursor in second paragraph
87
+
88
+ blockquotes.toggleBlockquote();
89
+
90
+ expect(editor.getHtml()).toBe("<p>First</p><blockquote><p>Second</p></blockquote><p>Third</p>");
91
+ });
92
+ });
93
+
94
+ describe("unwrapping from blockquote", () => {
95
+ beforeEach(() => {
96
+ onContentChange = vi.fn();
97
+ });
98
+
99
+ it("unwraps paragraph from blockquote", () => {
100
+ editor = createTestEditor("<blockquote><p>Hello world</p></blockquote>");
101
+ const blockquotes = createBlockquotes();
102
+ // Set cursor inside the paragraph within blockquote
103
+ const p = editor.container.querySelector("blockquote p");
104
+ if (p?.firstChild) {
105
+ editor.setCursor(p.firstChild, 5);
106
+ }
107
+
108
+ blockquotes.toggleBlockquote();
109
+
110
+ expect(editor.getHtml()).toBe("<p>Hello world</p>");
111
+ expect(onContentChange).toHaveBeenCalled();
112
+ });
113
+
114
+ it("unwraps heading from blockquote", () => {
115
+ editor = createTestEditor("<blockquote><h1>Heading</h1></blockquote>");
116
+ const blockquotes = createBlockquotes();
117
+ const h1 = editor.container.querySelector("blockquote h1");
118
+ if (h1?.firstChild) {
119
+ editor.setCursor(h1.firstChild, 0);
120
+ }
121
+
122
+ blockquotes.toggleBlockquote();
123
+
124
+ expect(editor.getHtml()).toBe("<h1>Heading</h1>");
125
+ });
126
+
127
+ it("preserves cursor position after unwrapping", () => {
128
+ editor = createTestEditor("<blockquote><p>Hello world</p></blockquote>");
129
+ const blockquotes = createBlockquotes();
130
+ const p = editor.container.querySelector("blockquote p");
131
+ if (p?.firstChild) {
132
+ editor.setCursor(p.firstChild, 5);
133
+ }
134
+
135
+ blockquotes.toggleBlockquote();
136
+
137
+ const sel = window.getSelection();
138
+ expect(sel).not.toBeNull();
139
+ expect(sel!.rangeCount).toBeGreaterThan(0);
140
+ });
141
+
142
+ it("unwraps only the blockquote, keeps other blocks", () => {
143
+ editor = createTestEditor("<p>Before</p><blockquote><p>Quoted</p></blockquote><p>After</p>");
144
+ const blockquotes = createBlockquotes();
145
+ const quotedP = editor.container.querySelector("blockquote p");
146
+ if (quotedP?.firstChild) {
147
+ editor.setCursor(quotedP.firstChild, 0);
148
+ }
149
+
150
+ blockquotes.toggleBlockquote();
151
+
152
+ expect(editor.getHtml()).toBe("<p>Before</p><p>Quoted</p><p>After</p>");
153
+ });
154
+ });
155
+
156
+ describe("with inline formatting preserved", () => {
157
+ beforeEach(() => {
158
+ onContentChange = vi.fn();
159
+ });
160
+
161
+ it("preserves bold formatting when wrapping", () => {
162
+ editor = createTestEditor("<p>Hello <strong>bold</strong> world</p>");
163
+ const blockquotes = createBlockquotes();
164
+ editor.setCursorInBlock(0, 0);
165
+
166
+ blockquotes.toggleBlockquote();
167
+
168
+ expect(editor.getHtml()).toBe("<blockquote><p>Hello <strong>bold</strong> world</p></blockquote>");
169
+ });
170
+
171
+ it("preserves italic formatting when wrapping", () => {
172
+ editor = createTestEditor("<p>Hello <em>italic</em> world</p>");
173
+ const blockquotes = createBlockquotes();
174
+ editor.setCursorInBlock(0, 0);
175
+
176
+ blockquotes.toggleBlockquote();
177
+
178
+ expect(editor.getHtml()).toBe("<blockquote><p>Hello <em>italic</em> world</p></blockquote>");
179
+ });
180
+
181
+ it("preserves nested formatting when wrapping", () => {
182
+ editor = createTestEditor("<p>Hello <strong><em>bold italic</em></strong> world</p>");
183
+ const blockquotes = createBlockquotes();
184
+ editor.setCursorInBlock(0, 0);
185
+
186
+ blockquotes.toggleBlockquote();
187
+
188
+ expect(editor.getHtml()).toBe("<blockquote><p>Hello <strong><em>bold italic</em></strong> world</p></blockquote>");
189
+ });
190
+
191
+ it("preserves bold formatting when unwrapping", () => {
192
+ editor = createTestEditor("<blockquote><p>Hello <strong>bold</strong> world</p></blockquote>");
193
+ const blockquotes = createBlockquotes();
194
+ const p = editor.container.querySelector("blockquote p");
195
+ if (p?.firstChild) {
196
+ editor.setCursor(p.firstChild, 0);
197
+ }
198
+
199
+ blockquotes.toggleBlockquote();
200
+
201
+ expect(editor.getHtml()).toBe("<p>Hello <strong>bold</strong> world</p>");
202
+ });
203
+
204
+ it("preserves link formatting when wrapping", () => {
205
+ editor = createTestEditor("<p>Hello <a href=\"https://example.com\">link</a> world</p>");
206
+ const blockquotes = createBlockquotes();
207
+ editor.setCursorInBlock(0, 0);
208
+
209
+ blockquotes.toggleBlockquote();
210
+
211
+ expect(editor.getHtml()).toBe("<blockquote><p>Hello <a href=\"https://example.com\">link</a> world</p></blockquote>");
212
+ });
213
+
214
+ it("preserves code formatting when wrapping", () => {
215
+ editor = createTestEditor("<p>Hello <code>code</code> world</p>");
216
+ const blockquotes = createBlockquotes();
217
+ editor.setCursorInBlock(0, 0);
218
+
219
+ blockquotes.toggleBlockquote();
220
+
221
+ expect(editor.getHtml()).toBe("<blockquote><p>Hello <code>code</code> world</p></blockquote>");
222
+ });
223
+ });
224
+ });
225
+
226
+ describe("isInBlockquote", () => {
227
+ beforeEach(() => {
228
+ onContentChange = vi.fn();
229
+ });
230
+
231
+ it("returns false when cursor is in plain paragraph", () => {
232
+ editor = createTestEditor("<p>Hello world</p>");
233
+ const blockquotes = createBlockquotes();
234
+ editor.setCursorInBlock(0, 5);
235
+
236
+ expect(blockquotes.isInBlockquote()).toBe(false);
237
+ });
238
+
239
+ it("returns true when cursor is inside blockquote", () => {
240
+ editor = createTestEditor("<blockquote><p>Hello world</p></blockquote>");
241
+ const blockquotes = createBlockquotes();
242
+ const p = editor.container.querySelector("blockquote p");
243
+ if (p?.firstChild) {
244
+ editor.setCursor(p.firstChild, 5);
245
+ }
246
+
247
+ expect(blockquotes.isInBlockquote()).toBe(true);
248
+ });
249
+
250
+ it("returns false when cursor is outside blockquote", () => {
251
+ editor = createTestEditor("<p>Outside</p><blockquote><p>Inside</p></blockquote>");
252
+ const blockquotes = createBlockquotes();
253
+ editor.setCursorInBlock(0, 0); // Cursor in first paragraph
254
+
255
+ expect(blockquotes.isInBlockquote()).toBe(false);
256
+ });
257
+
258
+ it("returns true for nested content inside blockquote", () => {
259
+ editor = createTestEditor("<blockquote><p><strong>Bold text</strong></p></blockquote>");
260
+ const blockquotes = createBlockquotes();
261
+ const strong = editor.container.querySelector("blockquote strong");
262
+ if (strong?.firstChild) {
263
+ editor.setCursor(strong.firstChild, 2);
264
+ }
265
+
266
+ expect(blockquotes.isInBlockquote()).toBe(true);
267
+ });
268
+
269
+ it("returns false when contentRef is null", () => {
270
+ editor = createTestEditor("<p>Hello world</p>");
271
+ const { isInBlockquote } = useBlockquotes({
272
+ contentRef: { value: null },
273
+ onContentChange
274
+ });
275
+
276
+ expect(isInBlockquote()).toBe(false);
277
+ });
278
+
279
+ it("returns false when no selection exists", () => {
280
+ editor = createTestEditor("<blockquote><p>Hello world</p></blockquote>");
281
+ const blockquotes = createBlockquotes();
282
+ window.getSelection()?.removeAllRanges();
283
+
284
+ expect(blockquotes.isInBlockquote()).toBe(false);
285
+ });
286
+ });
287
+
288
+ describe("edge cases", () => {
289
+ beforeEach(() => {
290
+ onContentChange = vi.fn();
291
+ });
292
+
293
+ it("does nothing when contentRef is null", () => {
294
+ editor = createTestEditor("<p>Hello world</p>");
295
+ const { toggleBlockquote } = useBlockquotes({
296
+ contentRef: { value: null },
297
+ onContentChange
298
+ });
299
+
300
+ toggleBlockquote();
301
+
302
+ expect(editor.getHtml()).toBe("<p>Hello world</p>");
303
+ expect(onContentChange).not.toHaveBeenCalled();
304
+ });
305
+
306
+ it("does nothing when selection is outside content area", () => {
307
+ editor = createTestEditor("<p>Hello world</p>");
308
+ const blockquotes = createBlockquotes();
309
+
310
+ // Create a selection outside the editor
311
+ const externalDiv = document.createElement("div");
312
+ externalDiv.textContent = "External text";
313
+ document.body.appendChild(externalDiv);
314
+
315
+ const range = document.createRange();
316
+ range.selectNodeContents(externalDiv);
317
+ const sel = window.getSelection();
318
+ sel?.removeAllRanges();
319
+ sel?.addRange(range);
320
+
321
+ blockquotes.toggleBlockquote();
322
+
323
+ expect(editor.getHtml()).toBe("<p>Hello world</p>");
324
+ expect(onContentChange).not.toHaveBeenCalled();
325
+
326
+ externalDiv.remove();
327
+ });
328
+
329
+ it("does nothing when no selection exists", () => {
330
+ editor = createTestEditor("<p>Hello world</p>");
331
+ const blockquotes = createBlockquotes();
332
+ window.getSelection()?.removeAllRanges();
333
+
334
+ blockquotes.toggleBlockquote();
335
+
336
+ expect(editor.getHtml()).toBe("<p>Hello world</p>");
337
+ expect(onContentChange).not.toHaveBeenCalled();
338
+ });
339
+
340
+ it("handles empty paragraph", () => {
341
+ editor = createTestEditor("<p></p>");
342
+ const blockquotes = createBlockquotes();
343
+ const p = editor.getBlock(0);
344
+ if (p) {
345
+ const range = document.createRange();
346
+ range.selectNodeContents(p);
347
+ range.collapse(true);
348
+ const sel = window.getSelection();
349
+ sel?.removeAllRanges();
350
+ sel?.addRange(range);
351
+ }
352
+
353
+ blockquotes.toggleBlockquote();
354
+
355
+ expect(editor.getHtml()).toBe("<blockquote><p></p></blockquote>");
356
+ });
357
+
358
+ it("handles whitespace-only content", () => {
359
+ editor = createTestEditor("<p> </p>");
360
+ const blockquotes = createBlockquotes();
361
+ editor.setCursorInBlock(0, 1);
362
+
363
+ blockquotes.toggleBlockquote();
364
+
365
+ expect(editor.getHtml()).toBe("<blockquote><p> </p></blockquote>");
366
+ });
367
+ });
368
+
369
+ describe("round-trip toggle", () => {
370
+ beforeEach(() => {
371
+ onContentChange = vi.fn();
372
+ });
373
+
374
+ it("toggle wrap then unwrap returns to original", () => {
375
+ editor = createTestEditor("<p>Hello world</p>");
376
+ const blockquotes = createBlockquotes();
377
+ editor.setCursorInBlock(0, 5);
378
+
379
+ // First toggle - wrap
380
+ blockquotes.toggleBlockquote();
381
+ expect(editor.getHtml()).toBe("<blockquote><p>Hello world</p></blockquote>");
382
+
383
+ // Set cursor again inside the blockquote
384
+ const p = editor.container.querySelector("blockquote p");
385
+ if (p?.firstChild) {
386
+ editor.setCursor(p.firstChild, 5);
387
+ }
388
+
389
+ // Second toggle - unwrap
390
+ blockquotes.toggleBlockquote();
391
+ expect(editor.getHtml()).toBe("<p>Hello world</p>");
392
+ });
393
+
394
+ it("toggle unwrap then wrap returns to original", () => {
395
+ editor = createTestEditor("<blockquote><p>Hello world</p></blockquote>");
396
+ const blockquotes = createBlockquotes();
397
+ const p = editor.container.querySelector("blockquote p");
398
+ if (p?.firstChild) {
399
+ editor.setCursor(p.firstChild, 5);
400
+ }
401
+
402
+ // First toggle - unwrap
403
+ blockquotes.toggleBlockquote();
404
+ expect(editor.getHtml()).toBe("<p>Hello world</p>");
405
+
406
+ // Set cursor in the unwrapped paragraph
407
+ editor.setCursorInBlock(0, 5);
408
+
409
+ // Second toggle - wrap
410
+ blockquotes.toggleBlockquote();
411
+ expect(editor.getHtml()).toBe("<blockquote><p>Hello world</p></blockquote>");
412
+ });
413
+ });
414
+
415
+ describe("return type", () => {
416
+ beforeEach(() => {
417
+ onContentChange = vi.fn();
418
+ editor = createTestEditor("<p>test</p>");
419
+ });
420
+
421
+ it("returns both functions", () => {
422
+ const blockquotes = createBlockquotes();
423
+
424
+ expect(typeof blockquotes.toggleBlockquote).toBe("function");
425
+ expect(typeof blockquotes.isInBlockquote).toBe("function");
426
+ });
427
+ });
428
+ });
@@ -0,0 +1,248 @@
1
+ import { Ref } from "vue";
2
+
3
+ /**
4
+ * Options for useBlockquotes composable
5
+ */
6
+ export interface UseBlockquotesOptions {
7
+ contentRef: Ref<HTMLElement | null>;
8
+ onContentChange: () => void;
9
+ }
10
+
11
+ /**
12
+ * Return type for useBlockquotes composable
13
+ */
14
+ export interface UseBlockquotesReturn {
15
+ /** Toggle blockquote on the current block */
16
+ toggleBlockquote: () => void;
17
+ /** Check if cursor is inside a blockquote */
18
+ isInBlockquote: () => boolean;
19
+ }
20
+
21
+ /**
22
+ * Block-level tags that can be wrapped in or unwrapped from blockquotes
23
+ */
24
+ const BLOCK_TAGS = ["P", "H1", "H2", "H3", "H4", "H5", "H6", "DIV"];
25
+
26
+ /**
27
+ * Find the nearest block-level element containing the cursor
28
+ */
29
+ function findCurrentBlock(node: Node | null, contentRef: HTMLElement): Element | null {
30
+ if (!node) return null;
31
+
32
+ let current: Node | null = node;
33
+ while (current && current !== contentRef) {
34
+ if (current.nodeType === Node.ELEMENT_NODE) {
35
+ const element = current as Element;
36
+ if (BLOCK_TAGS.includes(element.tagName) || element.tagName === "BLOCKQUOTE") {
37
+ return element;
38
+ }
39
+ }
40
+ current = current.parentNode;
41
+ }
42
+
43
+ return null;
44
+ }
45
+
46
+ /**
47
+ * Find the blockquote ancestor if one exists
48
+ */
49
+ function findBlockquoteAncestor(node: Node | null, contentRef: HTMLElement): HTMLQuoteElement | null {
50
+ if (!node) return null;
51
+
52
+ let current: Node | null = node;
53
+ while (current && current !== contentRef) {
54
+ if (current.nodeType === Node.ELEMENT_NODE && (current as Element).tagName === "BLOCKQUOTE") {
55
+ return current as HTMLQuoteElement;
56
+ }
57
+ current = current.parentNode;
58
+ }
59
+
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * Get the cursor offset within a block element's text content
65
+ */
66
+ function getCursorOffset(element: HTMLElement): number {
67
+ const selection = window.getSelection();
68
+ if (!selection || !selection.rangeCount) return 0;
69
+
70
+ const range = selection.getRangeAt(0);
71
+ const preCaretRange = document.createRange();
72
+ preCaretRange.selectNodeContents(element);
73
+ preCaretRange.setEnd(range.startContainer, range.startOffset);
74
+
75
+ return preCaretRange.toString().length;
76
+ }
77
+
78
+ /**
79
+ * Set cursor to a specific offset within an element's text content
80
+ */
81
+ function setCursorOffset(element: HTMLElement, targetOffset: number): void {
82
+ const selection = window.getSelection();
83
+ if (!selection) return;
84
+
85
+ let currentOffset = 0;
86
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
87
+ let node = walker.nextNode();
88
+
89
+ while (node) {
90
+ const nodeLength = node.textContent?.length || 0;
91
+ if (currentOffset + nodeLength >= targetOffset) {
92
+ const range = document.createRange();
93
+ range.setStart(node, targetOffset - currentOffset);
94
+ range.collapse(true);
95
+ selection.removeAllRanges();
96
+ selection.addRange(range);
97
+ return;
98
+ }
99
+ currentOffset += nodeLength;
100
+ node = walker.nextNode();
101
+ }
102
+
103
+ // If offset not found, place cursor at end
104
+ const range = document.createRange();
105
+ range.selectNodeContents(element);
106
+ range.collapse(false);
107
+ selection.removeAllRanges();
108
+ selection.addRange(range);
109
+ }
110
+
111
+ /**
112
+ * Composable for blockquote operations in markdown editor
113
+ */
114
+ export function useBlockquotes(options: UseBlockquotesOptions): UseBlockquotesReturn {
115
+ const { contentRef, onContentChange } = options;
116
+
117
+ /**
118
+ * Check if the cursor is currently inside a blockquote
119
+ */
120
+ function isInBlockquote(): boolean {
121
+ if (!contentRef.value) return false;
122
+
123
+ const selection = window.getSelection();
124
+ if (!selection || !selection.rangeCount) return false;
125
+
126
+ const range = selection.getRangeAt(0);
127
+ return findBlockquoteAncestor(range.startContainer, contentRef.value) !== null;
128
+ }
129
+
130
+ /**
131
+ * Toggle blockquote on the current block
132
+ *
133
+ * Behavior:
134
+ * - If cursor is inside a blockquote: unwrap the block from the blockquote
135
+ * - If cursor is not in a blockquote: wrap the current block in a blockquote
136
+ * - Preserves cursor position after transformation
137
+ */
138
+ function toggleBlockquote(): void {
139
+ if (!contentRef.value) return;
140
+
141
+ const selection = window.getSelection();
142
+ if (!selection || !selection.rangeCount) return;
143
+
144
+ const range = selection.getRangeAt(0);
145
+
146
+ // Check if selection is within our content area
147
+ if (!contentRef.value.contains(range.startContainer)) return;
148
+
149
+ const blockquote = findBlockquoteAncestor(range.startContainer, contentRef.value);
150
+
151
+ if (blockquote) {
152
+ unwrapBlockquote(blockquote);
153
+ } else {
154
+ wrapInBlockquote();
155
+ }
156
+
157
+ onContentChange();
158
+ }
159
+
160
+ /**
161
+ * Unwrap content from a blockquote
162
+ */
163
+ function unwrapBlockquote(blockquote: HTMLQuoteElement): void {
164
+ const parent = blockquote.parentNode;
165
+ if (!parent) return;
166
+
167
+ // Save cursor position
168
+ const selection = window.getSelection();
169
+ let cursorOffset = 0;
170
+ let targetBlock: Element | null = null;
171
+
172
+ if (selection && selection.rangeCount > 0) {
173
+ const range = selection.getRangeAt(0);
174
+ const currentBlock = findCurrentBlock(range.startContainer, contentRef.value!);
175
+
176
+ // If the current block is inside the blockquote, get its offset
177
+ if (currentBlock && blockquote.contains(currentBlock)) {
178
+ targetBlock = currentBlock;
179
+ cursorOffset = getCursorOffset(currentBlock as HTMLElement);
180
+ } else if (currentBlock === blockquote) {
181
+ // Cursor is directly in blockquote text node
182
+ cursorOffset = getCursorOffset(blockquote);
183
+ }
184
+ }
185
+
186
+ // Move all children out of the blockquote
187
+ const children = Array.from(blockquote.childNodes);
188
+ let firstMovedElement: Element | null = null;
189
+
190
+ for (const child of children) {
191
+ const insertedNode = parent.insertBefore(child, blockquote);
192
+ if (!firstMovedElement && insertedNode.nodeType === Node.ELEMENT_NODE) {
193
+ firstMovedElement = insertedNode as Element;
194
+ }
195
+ }
196
+
197
+ // Remove the empty blockquote
198
+ parent.removeChild(blockquote);
199
+
200
+ // Restore cursor position
201
+ if (targetBlock && parent.contains(targetBlock)) {
202
+ setCursorOffset(targetBlock as HTMLElement, cursorOffset);
203
+ } else if (firstMovedElement) {
204
+ setCursorOffset(firstMovedElement as HTMLElement, cursorOffset);
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Wrap the current block in a blockquote
210
+ */
211
+ function wrapInBlockquote(): void {
212
+ if (!contentRef.value) return;
213
+
214
+ const selection = window.getSelection();
215
+ if (!selection || !selection.rangeCount) return;
216
+
217
+ const range = selection.getRangeAt(0);
218
+ const currentBlock = findCurrentBlock(range.startContainer, contentRef.value);
219
+
220
+ if (!currentBlock) return;
221
+
222
+ // Don't wrap if already in a blockquote
223
+ if (currentBlock.tagName === "BLOCKQUOTE") return;
224
+
225
+ // Save cursor position
226
+ const cursorOffset = getCursorOffset(currentBlock as HTMLElement);
227
+
228
+ // Create blockquote and wrap the block
229
+ const blockquote = document.createElement("blockquote");
230
+
231
+ // Insert blockquote before the current block
232
+ const parent = currentBlock.parentNode;
233
+ if (!parent) return;
234
+
235
+ parent.insertBefore(blockquote, currentBlock);
236
+
237
+ // Move the block into the blockquote
238
+ blockquote.appendChild(currentBlock);
239
+
240
+ // Restore cursor position
241
+ setCursorOffset(currentBlock as HTMLElement, cursorOffset);
242
+ }
243
+
244
+ return {
245
+ toggleBlockquote,
246
+ isInBlockquote
247
+ };
248
+ }