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.
- package/.claude/settings.local.json +8 -0
- package/dist/danx.es.js +16119 -10641
- package/dist/danx.es.js.map +1 -1
- package/dist/danx.umd.js +202 -123
- package/dist/danx.umd.js.map +1 -1
- package/dist/style.css +1 -1
- package/package.json +8 -1
- package/src/components/Utility/Buttons/ActionButton.vue +15 -5
- package/src/components/Utility/Code/CodeViewer.vue +41 -16
- package/src/components/Utility/Code/CodeViewerCollapsed.vue +2 -0
- package/src/components/Utility/Code/CodeViewerFooter.vue +3 -1
- package/src/components/Utility/Code/LanguageBadge.vue +278 -5
- package/src/components/Utility/Code/MarkdownContent.vue +31 -163
- 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 +233 -0
- package/src/components/Utility/Markdown/MarkdownEditorContent.vue +296 -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/Widgets/LabelPillWidget.vue +20 -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 +805 -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 +388 -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 +1077 -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/useCodeFormat.ts +17 -10
- package/src/composables/useCodeViewerEditor.spec.ts +655 -0
- package/src/composables/useCodeViewerEditor.ts +174 -20
- package/src/helpers/formats/highlightCSS.ts +236 -0
- package/src/helpers/formats/highlightHTML.ts +483 -0
- package/src/helpers/formats/highlightJavaScript.ts +346 -0
- package/src/helpers/formats/highlightSyntax.ts +15 -4
- package/src/helpers/formats/index.ts +3 -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 +425 -0
- package/src/helpers/formats/markdown/index.ts +7 -0
- package/src/helpers/formats/markdown/linePatterns.spec.ts +498 -0
- package/src/helpers/formats/markdown/linePatterns.ts +172 -0
- package/src/styles/danx.scss +3 -3
- package/src/styles/index.scss +5 -5
- package/src/styles/themes/danx/code.scss +257 -1
- package/src/styles/themes/danx/index.scss +10 -10
- package/src/styles/themes/danx/markdown.scss +59 -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/highlighters.test.ts +153 -0
- package/src/test/setup.test.ts +12 -0
- package/src/test/setup.ts +12 -0
- package/src/types/widgets.d.ts +2 -2
- package/vite.config.js +5 -1
- 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
|
+
}
|