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
|
@@ -94,6 +94,24 @@
|
|
|
94
94
|
list-style-type: decimal;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// Task lists
|
|
98
|
+
.task-list {
|
|
99
|
+
list-style: none;
|
|
100
|
+
padding-left: 0;
|
|
101
|
+
|
|
102
|
+
.task-list-item {
|
|
103
|
+
display: flex;
|
|
104
|
+
align-items: flex-start;
|
|
105
|
+
gap: 0.5em;
|
|
106
|
+
margin: 0.25em 0;
|
|
107
|
+
|
|
108
|
+
input[type="checkbox"] {
|
|
109
|
+
margin-top: 0.3em;
|
|
110
|
+
cursor: default;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
97
115
|
// Links
|
|
98
116
|
a {
|
|
99
117
|
color: #60a5fa; // blue-400
|
|
@@ -121,6 +139,32 @@
|
|
|
121
139
|
strong { font-weight: 600; }
|
|
122
140
|
em { font-style: italic; }
|
|
123
141
|
|
|
142
|
+
// Strikethrough
|
|
143
|
+
del {
|
|
144
|
+
text-decoration: line-through;
|
|
145
|
+
opacity: 0.7;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Highlight
|
|
149
|
+
mark {
|
|
150
|
+
background: rgba(250, 204, 21, 0.4); // yellow-400 with opacity
|
|
151
|
+
padding: 0.1em 0.2em;
|
|
152
|
+
border-radius: 2px;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Subscript/Superscript
|
|
156
|
+
sub, sup {
|
|
157
|
+
font-size: 0.75em;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
sub {
|
|
161
|
+
vertical-align: sub;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
sup {
|
|
165
|
+
vertical-align: super;
|
|
166
|
+
}
|
|
167
|
+
|
|
124
168
|
// Tables
|
|
125
169
|
table {
|
|
126
170
|
border-collapse: collapse;
|
|
@@ -142,4 +186,56 @@
|
|
|
142
186
|
background: rgba(255, 255, 255, 0.05);
|
|
143
187
|
}
|
|
144
188
|
}
|
|
189
|
+
|
|
190
|
+
// Definition lists
|
|
191
|
+
dl {
|
|
192
|
+
margin: 1em 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
dt {
|
|
196
|
+
font-weight: 600;
|
|
197
|
+
margin-top: 1em;
|
|
198
|
+
|
|
199
|
+
&:first-child {
|
|
200
|
+
margin-top: 0;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
dd {
|
|
205
|
+
margin-left: 2em;
|
|
206
|
+
margin-top: 0.25em;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Footnotes
|
|
210
|
+
.footnote-ref {
|
|
211
|
+
font-size: 0.75em;
|
|
212
|
+
vertical-align: super;
|
|
213
|
+
line-height: 0;
|
|
214
|
+
|
|
215
|
+
a {
|
|
216
|
+
text-decoration: none;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.footnotes {
|
|
221
|
+
margin-top: 2em;
|
|
222
|
+
font-size: 0.875em;
|
|
223
|
+
|
|
224
|
+
hr {
|
|
225
|
+
margin-bottom: 1em;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.footnote-list {
|
|
229
|
+
padding-left: 1.5em;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.footnote-item {
|
|
233
|
+
margin: 0.5em 0;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.footnote-backref {
|
|
237
|
+
margin-left: 0.5em;
|
|
238
|
+
text-decoration: none;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
145
241
|
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { createTestEditor, TestEditorResult } from './editorTestUtils';
|
|
3
|
+
|
|
4
|
+
describe('editorTestUtils', () => {
|
|
5
|
+
let editor: TestEditorResult;
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
if (editor) {
|
|
9
|
+
editor.destroy();
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('createTestEditor', () => {
|
|
14
|
+
it('should create a contenteditable container', () => {
|
|
15
|
+
editor = createTestEditor('<p>Hello World</p>');
|
|
16
|
+
|
|
17
|
+
expect(editor.container).toBeInstanceOf(HTMLElement);
|
|
18
|
+
expect(editor.container.getAttribute('contenteditable')).toBe('true');
|
|
19
|
+
expect(editor.container.innerHTML).toBe('<p>Hello World</p>');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should trim whitespace from initial HTML', () => {
|
|
23
|
+
editor = createTestEditor(' <p>Test</p> ');
|
|
24
|
+
|
|
25
|
+
expect(editor.container.innerHTML).toBe('<p>Test</p>');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('getHtml', () => {
|
|
30
|
+
it('should return current HTML content', () => {
|
|
31
|
+
editor = createTestEditor('<p>Initial</p>');
|
|
32
|
+
|
|
33
|
+
expect(editor.getHtml()).toBe('<p>Initial</p>');
|
|
34
|
+
|
|
35
|
+
// Modify content
|
|
36
|
+
editor.container.innerHTML = '<p>Modified</p>';
|
|
37
|
+
expect(editor.getHtml()).toBe('<p>Modified</p>');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('getMarkdown', () => {
|
|
42
|
+
it('should convert HTML to markdown', () => {
|
|
43
|
+
editor = createTestEditor('<p>Hello <strong>bold</strong> text</p>');
|
|
44
|
+
|
|
45
|
+
const markdown = editor.getMarkdown();
|
|
46
|
+
expect(markdown).toBe('Hello **bold** text');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should handle headings', () => {
|
|
50
|
+
editor = createTestEditor('<h2>Heading Two</h2>');
|
|
51
|
+
|
|
52
|
+
expect(editor.getMarkdown()).toBe('## Heading Two');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should handle lists', () => {
|
|
56
|
+
editor = createTestEditor('<ul><li>Item 1</li><li>Item 2</li></ul>');
|
|
57
|
+
|
|
58
|
+
const markdown = editor.getMarkdown();
|
|
59
|
+
expect(markdown).toContain('- Item 1');
|
|
60
|
+
expect(markdown).toContain('- Item 2');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('getBlock and getBlocks', () => {
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
editor = createTestEditor('<p>First</p><p>Second</p><p>Third</p>');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should get a block by index', () => {
|
|
70
|
+
const block0 = editor.getBlock(0);
|
|
71
|
+
const block1 = editor.getBlock(1);
|
|
72
|
+
const block2 = editor.getBlock(2);
|
|
73
|
+
|
|
74
|
+
expect(block0?.textContent).toBe('First');
|
|
75
|
+
expect(block1?.textContent).toBe('Second');
|
|
76
|
+
expect(block2?.textContent).toBe('Third');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should return null for invalid index', () => {
|
|
80
|
+
expect(editor.getBlock(10)).toBeNull();
|
|
81
|
+
expect(editor.getBlock(-1)).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should get all blocks', () => {
|
|
85
|
+
const blocks = editor.getBlocks();
|
|
86
|
+
|
|
87
|
+
expect(blocks).toHaveLength(3);
|
|
88
|
+
expect(blocks[0].textContent).toBe('First');
|
|
89
|
+
expect(blocks[2].textContent).toBe('Third');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('setCursor and getCursorPosition', () => {
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
editor = createTestEditor('<p>Hello World</p>');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should set cursor position in a text node', () => {
|
|
99
|
+
const textNode = editor.getBlock(0)?.firstChild;
|
|
100
|
+
if (!textNode) throw new Error('Text node not found');
|
|
101
|
+
|
|
102
|
+
editor.setCursor(textNode, 5);
|
|
103
|
+
|
|
104
|
+
const pos = editor.getCursorPosition();
|
|
105
|
+
expect(pos.node).toBe(textNode);
|
|
106
|
+
expect(pos.offset).toBe(5);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should return null node when no selection', () => {
|
|
110
|
+
// Clear any existing selection
|
|
111
|
+
window.getSelection()?.removeAllRanges();
|
|
112
|
+
|
|
113
|
+
const pos = editor.getCursorPosition();
|
|
114
|
+
expect(pos.node).toBeNull();
|
|
115
|
+
expect(pos.offset).toBe(0);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('setCursorInBlock and getCursorOffsetInBlock', () => {
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
editor = createTestEditor('<p>First paragraph</p><p>Second paragraph</p>');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should set cursor at offset within a block', () => {
|
|
125
|
+
editor.setCursorInBlock(0, 6);
|
|
126
|
+
|
|
127
|
+
const offset = editor.getCursorOffsetInBlock(0);
|
|
128
|
+
expect(offset).toBe(6); // After "First "
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should set cursor in second block', () => {
|
|
132
|
+
editor.setCursorInBlock(1, 7);
|
|
133
|
+
|
|
134
|
+
const offset = editor.getCursorOffsetInBlock(1);
|
|
135
|
+
expect(offset).toBe(7); // After "Second "
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should return -1 for invalid block index', () => {
|
|
139
|
+
editor.setCursorInBlock(0, 0);
|
|
140
|
+
|
|
141
|
+
expect(editor.getCursorOffsetInBlock(10)).toBe(-1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should return -1 when cursor is not in the specified block', () => {
|
|
145
|
+
editor.setCursorInBlock(0, 0);
|
|
146
|
+
|
|
147
|
+
expect(editor.getCursorOffsetInBlock(1)).toBe(-1);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('selectRange and selectInBlock', () => {
|
|
152
|
+
beforeEach(() => {
|
|
153
|
+
editor = createTestEditor('<p>Hello World</p>');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should select a text range', () => {
|
|
157
|
+
const textNode = editor.getBlock(0)?.firstChild;
|
|
158
|
+
if (!textNode) throw new Error('Text node not found');
|
|
159
|
+
|
|
160
|
+
editor.selectRange(textNode, 0, textNode, 5);
|
|
161
|
+
|
|
162
|
+
const sel = window.getSelection();
|
|
163
|
+
expect(sel?.toString()).toBe('Hello');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should select text within a block by offsets', () => {
|
|
167
|
+
editor.selectInBlock(0, 6, 11);
|
|
168
|
+
|
|
169
|
+
const sel = window.getSelection();
|
|
170
|
+
expect(sel?.toString()).toBe('World');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('pressKey', () => {
|
|
175
|
+
beforeEach(() => {
|
|
176
|
+
editor = createTestEditor('<p>Test</p>');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should dispatch keydown event', () => {
|
|
180
|
+
let receivedEvent: KeyboardEvent | null = null;
|
|
181
|
+
editor.container.addEventListener('keydown', (e) => {
|
|
182
|
+
receivedEvent = e;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
editor.pressKey('a');
|
|
186
|
+
|
|
187
|
+
expect(receivedEvent).not.toBeNull();
|
|
188
|
+
expect(receivedEvent!.key).toBe('a');
|
|
189
|
+
expect(receivedEvent!.code).toBe('KeyA');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should include modifier keys', () => {
|
|
193
|
+
let receivedEvent: KeyboardEvent | null = null;
|
|
194
|
+
editor.container.addEventListener('keydown', (e) => {
|
|
195
|
+
receivedEvent = e;
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
editor.pressKey('b', { ctrl: true, shift: true });
|
|
199
|
+
|
|
200
|
+
expect(receivedEvent!.ctrlKey).toBe(true);
|
|
201
|
+
expect(receivedEvent!.shiftKey).toBe(true);
|
|
202
|
+
expect(receivedEvent!.altKey).toBe(false);
|
|
203
|
+
expect(receivedEvent!.metaKey).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should handle special keys', () => {
|
|
207
|
+
let receivedEvent: KeyboardEvent | null = null;
|
|
208
|
+
editor.container.addEventListener('keydown', (e) => {
|
|
209
|
+
receivedEvent = e;
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
editor.pressKey('Enter');
|
|
213
|
+
|
|
214
|
+
expect(receivedEvent!.key).toBe('Enter');
|
|
215
|
+
expect(receivedEvent!.code).toBe('Enter');
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('type', () => {
|
|
220
|
+
beforeEach(() => {
|
|
221
|
+
editor = createTestEditor('<p>Hello</p>');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should insert text at cursor position', () => {
|
|
225
|
+
const textNode = editor.getBlock(0)?.firstChild;
|
|
226
|
+
if (!textNode) throw new Error('Text node not found');
|
|
227
|
+
|
|
228
|
+
editor.setCursor(textNode, 5);
|
|
229
|
+
editor.type(' World');
|
|
230
|
+
|
|
231
|
+
expect(editor.container.textContent).toContain('Hello');
|
|
232
|
+
expect(editor.container.textContent).toContain('World');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('should dispatch input event', () => {
|
|
236
|
+
let inputFired = false;
|
|
237
|
+
editor.container.addEventListener('input', () => {
|
|
238
|
+
inputFired = true;
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const textNode = editor.getBlock(0)?.firstChild;
|
|
242
|
+
if (!textNode) throw new Error('Text node not found');
|
|
243
|
+
|
|
244
|
+
editor.setCursor(textNode, 0);
|
|
245
|
+
editor.type('X');
|
|
246
|
+
|
|
247
|
+
expect(inputFired).toBe(true);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe('contentRef', () => {
|
|
252
|
+
it('should provide a Vue ref to the container', () => {
|
|
253
|
+
editor = createTestEditor('<p>Test</p>');
|
|
254
|
+
|
|
255
|
+
expect(editor.contentRef.value).toBe(editor.container);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('destroy', () => {
|
|
260
|
+
it('should remove the container from DOM', () => {
|
|
261
|
+
editor = createTestEditor('<p>Test</p>');
|
|
262
|
+
const container = editor.container;
|
|
263
|
+
|
|
264
|
+
expect(document.body.contains(container)).toBe(true);
|
|
265
|
+
|
|
266
|
+
editor.destroy();
|
|
267
|
+
|
|
268
|
+
expect(document.body.contains(container)).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('complex content handling', () => {
|
|
273
|
+
it('should handle nested inline formatting', () => {
|
|
274
|
+
editor = createTestEditor('<p>This is <strong><em>bold italic</em></strong> text</p>');
|
|
275
|
+
|
|
276
|
+
editor.selectInBlock(0, 8, 19);
|
|
277
|
+
|
|
278
|
+
const sel = window.getSelection();
|
|
279
|
+
expect(sel?.toString()).toBe('bold italic');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('should handle multiple blocks with cursor operations', () => {
|
|
283
|
+
editor = createTestEditor('<p>Line 1</p><p>Line 2</p><p>Line 3</p>');
|
|
284
|
+
|
|
285
|
+
// Set cursor in middle block
|
|
286
|
+
editor.setCursorInBlock(1, 3);
|
|
287
|
+
|
|
288
|
+
const offset = editor.getCursorOffsetInBlock(1);
|
|
289
|
+
expect(offset).toBe(3);
|
|
290
|
+
|
|
291
|
+
// Verify cursor is not in other blocks
|
|
292
|
+
expect(editor.getCursorOffsetInBlock(0)).toBe(-1);
|
|
293
|
+
expect(editor.getCursorOffsetInBlock(2)).toBe(-1);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
});
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { ref } from 'vue';
|
|
2
|
+
import { htmlToMarkdown } from '../../helpers/formats/markdown/htmlToMarkdown';
|
|
3
|
+
|
|
4
|
+
export interface TestEditorResult {
|
|
5
|
+
/** The contenteditable container element */
|
|
6
|
+
container: HTMLElement;
|
|
7
|
+
/** Get the current HTML content */
|
|
8
|
+
getHtml: () => string;
|
|
9
|
+
/** Get the markdown output from current HTML */
|
|
10
|
+
getMarkdown: () => string;
|
|
11
|
+
/** Get cursor position as { node, offset } */
|
|
12
|
+
getCursorPosition: () => { node: Node | null; offset: number };
|
|
13
|
+
/** Get cursor offset within a specific block */
|
|
14
|
+
getCursorOffsetInBlock: (blockIndex: number) => number;
|
|
15
|
+
/** Set cursor at a position in a specific text node */
|
|
16
|
+
setCursor: (node: Node, offset: number) => void;
|
|
17
|
+
/** Set cursor at offset within block's text content */
|
|
18
|
+
setCursorInBlock: (blockIndex: number, offset: number) => void;
|
|
19
|
+
/** Select text range */
|
|
20
|
+
selectRange: (startNode: Node, startOffset: number, endNode: Node, endOffset: number) => void;
|
|
21
|
+
/** Select text within a block by offsets */
|
|
22
|
+
selectInBlock: (blockIndex: number, startOffset: number, endOffset: number) => void;
|
|
23
|
+
/** Simulate a keydown event */
|
|
24
|
+
pressKey: (key: string, modifiers?: { ctrl?: boolean; shift?: boolean; alt?: boolean; meta?: boolean }) => void;
|
|
25
|
+
/** Type text at current cursor position */
|
|
26
|
+
type: (text: string) => void;
|
|
27
|
+
/** Get block element by index */
|
|
28
|
+
getBlock: (index: number) => Element | null;
|
|
29
|
+
/** Get all block elements */
|
|
30
|
+
getBlocks: () => Element[];
|
|
31
|
+
/** Get the contentRef as a Vue ref (for composables) */
|
|
32
|
+
contentRef: ReturnType<typeof ref<HTMLElement | null>>;
|
|
33
|
+
/** Cleanup function */
|
|
34
|
+
destroy: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Find a text node at a given offset within an element's text content.
|
|
39
|
+
* Returns the text node and the offset within that node.
|
|
40
|
+
*/
|
|
41
|
+
function findTextNodeAtOffset(element: Element, targetOffset: number): { node: Text; offset: number } | null {
|
|
42
|
+
let currentOffset = 0;
|
|
43
|
+
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
44
|
+
let node: Text | null;
|
|
45
|
+
let lastNode: Text | null = null;
|
|
46
|
+
|
|
47
|
+
while ((node = walker.nextNode() as Text)) {
|
|
48
|
+
lastNode = node;
|
|
49
|
+
const nodeLength = node.textContent?.length || 0;
|
|
50
|
+
if (currentOffset + nodeLength >= targetOffset) {
|
|
51
|
+
return { node, offset: targetOffset - currentOffset };
|
|
52
|
+
}
|
|
53
|
+
currentOffset += nodeLength;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// If we're past the content, return end of last text node
|
|
57
|
+
if (lastNode) {
|
|
58
|
+
return { node: lastNode, offset: lastNode.textContent?.length || 0 };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get the key code string for common keys
|
|
66
|
+
*/
|
|
67
|
+
function getKeyCode(key: string): string {
|
|
68
|
+
const keyCodes: Record<string, string> = {
|
|
69
|
+
'Enter': 'Enter',
|
|
70
|
+
'Backspace': 'Backspace',
|
|
71
|
+
'Delete': 'Delete',
|
|
72
|
+
'Tab': 'Tab',
|
|
73
|
+
'Escape': 'Escape',
|
|
74
|
+
'ArrowUp': 'ArrowUp',
|
|
75
|
+
'ArrowDown': 'ArrowDown',
|
|
76
|
+
'ArrowLeft': 'ArrowLeft',
|
|
77
|
+
'ArrowRight': 'ArrowRight',
|
|
78
|
+
' ': 'Space',
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if (keyCodes[key]) {
|
|
82
|
+
return keyCodes[key];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// For single letters, use KeyX format
|
|
86
|
+
if (key.length === 1 && /[a-zA-Z]/.test(key)) {
|
|
87
|
+
return `Key${key.toUpperCase()}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// For digits
|
|
91
|
+
if (key.length === 1 && /[0-9]/.test(key)) {
|
|
92
|
+
return `Digit${key}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return key;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Create a test editor with initial HTML content
|
|
100
|
+
*/
|
|
101
|
+
export function createTestEditor(initialHtml: string): TestEditorResult {
|
|
102
|
+
// Create container
|
|
103
|
+
const container = document.createElement('div');
|
|
104
|
+
container.setAttribute('contenteditable', 'true');
|
|
105
|
+
container.innerHTML = initialHtml.trim();
|
|
106
|
+
document.body.appendChild(container);
|
|
107
|
+
|
|
108
|
+
// Create Vue ref for composables
|
|
109
|
+
const contentRef = ref<HTMLElement | null>(container);
|
|
110
|
+
|
|
111
|
+
function getHtml(): string {
|
|
112
|
+
return container.innerHTML;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getMarkdown(): string {
|
|
116
|
+
return htmlToMarkdown(container);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function getCursorPosition(): { node: Node | null; offset: number } {
|
|
120
|
+
const sel = window.getSelection();
|
|
121
|
+
if (!sel || sel.rangeCount === 0) {
|
|
122
|
+
return { node: null, offset: 0 };
|
|
123
|
+
}
|
|
124
|
+
const range = sel.getRangeAt(0);
|
|
125
|
+
return { node: range.startContainer, offset: range.startOffset };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getBlock(index: number): Element | null {
|
|
129
|
+
const blocks = Array.from(container.children);
|
|
130
|
+
return blocks[index] || null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function getBlocks(): Element[] {
|
|
134
|
+
return Array.from(container.children);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function getCursorOffsetInBlock(blockIndex: number): number {
|
|
138
|
+
const block = getBlock(blockIndex);
|
|
139
|
+
if (!block) return -1;
|
|
140
|
+
|
|
141
|
+
const sel = window.getSelection();
|
|
142
|
+
if (!sel || sel.rangeCount === 0) return -1;
|
|
143
|
+
|
|
144
|
+
const range = sel.getRangeAt(0);
|
|
145
|
+
if (!block.contains(range.startContainer)) return -1;
|
|
146
|
+
|
|
147
|
+
// Calculate offset within block's text content
|
|
148
|
+
const preRange = document.createRange();
|
|
149
|
+
preRange.selectNodeContents(block);
|
|
150
|
+
preRange.setEnd(range.startContainer, range.startOffset);
|
|
151
|
+
return preRange.toString().length;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function setCursor(node: Node, offset: number): void {
|
|
155
|
+
const range = document.createRange();
|
|
156
|
+
range.setStart(node, offset);
|
|
157
|
+
range.collapse(true);
|
|
158
|
+
|
|
159
|
+
const sel = window.getSelection();
|
|
160
|
+
sel?.removeAllRanges();
|
|
161
|
+
sel?.addRange(range);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function setCursorInBlock(blockIndex: number, offset: number): void {
|
|
165
|
+
const block = getBlock(blockIndex);
|
|
166
|
+
if (!block) return;
|
|
167
|
+
|
|
168
|
+
const result = findTextNodeAtOffset(block, offset);
|
|
169
|
+
if (result) {
|
|
170
|
+
setCursor(result.node, result.offset);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function selectRange(startNode: Node, startOffset: number, endNode: Node, endOffset: number): void {
|
|
175
|
+
const range = document.createRange();
|
|
176
|
+
range.setStart(startNode, startOffset);
|
|
177
|
+
range.setEnd(endNode, endOffset);
|
|
178
|
+
|
|
179
|
+
const sel = window.getSelection();
|
|
180
|
+
sel?.removeAllRanges();
|
|
181
|
+
sel?.addRange(range);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function selectInBlock(blockIndex: number, startOffset: number, endOffset: number): void {
|
|
185
|
+
const block = getBlock(blockIndex);
|
|
186
|
+
if (!block) return;
|
|
187
|
+
|
|
188
|
+
const start = findTextNodeAtOffset(block, startOffset);
|
|
189
|
+
const end = findTextNodeAtOffset(block, endOffset);
|
|
190
|
+
|
|
191
|
+
if (start && end) {
|
|
192
|
+
selectRange(start.node, start.offset, end.node, end.offset);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function pressKey(key: string, modifiers: { ctrl?: boolean; shift?: boolean; alt?: boolean; meta?: boolean } = {}): void {
|
|
197
|
+
const event = new KeyboardEvent('keydown', {
|
|
198
|
+
key,
|
|
199
|
+
code: getKeyCode(key),
|
|
200
|
+
ctrlKey: modifiers.ctrl || false,
|
|
201
|
+
shiftKey: modifiers.shift || false,
|
|
202
|
+
altKey: modifiers.alt || false,
|
|
203
|
+
metaKey: modifiers.meta || false,
|
|
204
|
+
bubbles: true,
|
|
205
|
+
cancelable: true,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
container.dispatchEvent(event);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function type(text: string): void {
|
|
212
|
+
// Insert text at cursor position
|
|
213
|
+
const sel = window.getSelection();
|
|
214
|
+
if (!sel || sel.rangeCount === 0) return;
|
|
215
|
+
|
|
216
|
+
const range = sel.getRangeAt(0);
|
|
217
|
+
range.deleteContents();
|
|
218
|
+
|
|
219
|
+
const textNode = document.createTextNode(text);
|
|
220
|
+
range.insertNode(textNode);
|
|
221
|
+
|
|
222
|
+
// Move cursor after inserted text
|
|
223
|
+
range.setStartAfter(textNode);
|
|
224
|
+
range.collapse(true);
|
|
225
|
+
sel.removeAllRanges();
|
|
226
|
+
sel.addRange(range);
|
|
227
|
+
|
|
228
|
+
// Dispatch input event
|
|
229
|
+
container.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function destroy(): void {
|
|
233
|
+
container.remove();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
container,
|
|
238
|
+
getHtml,
|
|
239
|
+
getMarkdown,
|
|
240
|
+
getCursorPosition,
|
|
241
|
+
getCursorOffsetInBlock,
|
|
242
|
+
setCursor,
|
|
243
|
+
setCursorInBlock,
|
|
244
|
+
selectRange,
|
|
245
|
+
selectInBlock,
|
|
246
|
+
pressKey,
|
|
247
|
+
type,
|
|
248
|
+
getBlock,
|
|
249
|
+
getBlocks,
|
|
250
|
+
contentRef,
|
|
251
|
+
destroy,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './editorTestUtils';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
describe('Vitest Setup', () => {
|
|
4
|
+
it('should run tests successfully', () => {
|
|
5
|
+
expect(true).toBe(true);
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
it('should have access to jsdom environment', () => {
|
|
9
|
+
expect(typeof window).toBe('object');
|
|
10
|
+
expect(typeof document).toBe('object');
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock window.getSelection for jsdom
|
|
4
|
+
// jsdom's Selection API is limited, so we may need to enhance it
|
|
5
|
+
if (typeof window !== 'undefined') {
|
|
6
|
+
// Ensure getSelection exists
|
|
7
|
+
if (!window.getSelection) {
|
|
8
|
+
window.getSelection = vi.fn(() => null);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Global test utilities can be added here
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { defineConfig } from 'vitest/config';
|
|
3
|
+
import vue from '@vitejs/plugin-vue';
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
plugins: [vue()],
|
|
7
|
+
test: {
|
|
8
|
+
environment: 'jsdom',
|
|
9
|
+
globals: true,
|
|
10
|
+
include: ['src/**/*.{test,spec}.{js,ts}'],
|
|
11
|
+
setupFiles: ['./src/test/setup.ts'],
|
|
12
|
+
root: fileURLToPath(new URL('./', import.meta.url)),
|
|
13
|
+
},
|
|
14
|
+
resolve: {
|
|
15
|
+
alias: {
|
|
16
|
+
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
});
|