@oix1987/yjd 1.0.3 → 2.1.0
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/LICENSE +15 -0
- package/README.md +223 -142
- package/core.js +82 -0
- package/dist/core.esm.js +2 -0
- package/dist/core.esm.js.map +1 -0
- package/dist/rich-editor.esm.js +1 -1
- package/dist/rich-editor.esm.js.map +1 -1
- package/dist/rich-editor.min.js +1 -1
- package/dist/rich-editor.min.js.map +1 -1
- package/index.d.ts +230 -103
- package/index.js +297 -0
- package/lib/core/editor.js +1885 -0
- package/lib/core/format.js +540 -0
- package/lib/core/module.js +81 -0
- package/lib/core/registry.js +158 -0
- package/lib/formats/background.js +213 -0
- package/lib/formats/bold.js +49 -0
- package/lib/formats/capitalization.js +579 -0
- package/lib/formats/color.js +183 -0
- package/lib/formats/emoji.js +282 -0
- package/lib/formats/font-family.js +548 -0
- package/lib/formats/heading.js +502 -0
- package/lib/formats/image.js +341 -0
- package/lib/formats/import.js +385 -0
- package/lib/formats/indent.js +297 -0
- package/lib/formats/italic.js +27 -0
- package/lib/formats/line-height.js +562 -0
- package/lib/formats/link.js +251 -0
- package/lib/formats/list.js +635 -0
- package/lib/formats/strike.js +31 -0
- package/lib/formats/subscript.js +40 -0
- package/lib/formats/superscript.js +39 -0
- package/lib/formats/table.js +293 -0
- package/lib/formats/tag.js +304 -0
- package/lib/formats/text-align.js +422 -0
- package/lib/formats/text-size.js +498 -0
- package/lib/formats/underline.js +30 -0
- package/lib/formats/video.js +381 -0
- package/lib/modules/block-toolbar.js +639 -0
- package/lib/modules/code-view.js +447 -0
- package/lib/modules/find-replace.js +273 -0
- package/lib/modules/history.js +425 -0
- package/lib/modules/mention.js +200 -0
- package/lib/modules/resize-handles.js +701 -0
- package/lib/modules/slash-menu.js +183 -0
- package/lib/modules/table-toolbar.js +635 -0
- package/lib/modules/toolbar.js +607 -0
- package/lib/serialize.js +241 -0
- package/lib/static.js +28 -0
- package/lib/styles-loader.js +142 -0
- package/{dist → lib}/styles.css +1392 -35
- package/lib/styles.css.js +2 -0
- package/lib/styles.min.css +1 -0
- package/lib/ui/color-picker.js +296 -0
- package/lib/ui/customselect.js +351 -0
- package/lib/ui/emoji-picker.js +196 -0
- package/lib/ui/icons.js +145 -0
- package/lib/ui/image-popup.js +435 -0
- package/lib/ui/import-popup.js +288 -0
- package/lib/ui/link-popup.js +139 -0
- package/lib/ui/list-picker.js +307 -0
- package/lib/ui/select-button.js +68 -0
- package/lib/ui/table-popup.js +171 -0
- package/lib/ui/tag-popup.js +249 -0
- package/lib/ui/text-align-picker.js +278 -0
- package/lib/ui/video-popup.js +413 -0
- package/lib/utils/exec-command.js +72 -0
- package/lib/utils/history-helper.js +50 -0
- package/lib/utils/popup-helper.js +219 -0
- package/lib/utils/popup-positioning.js +234 -0
- package/lib/utils/sanitize.js +164 -0
- package/package.json +51 -32
- package/umd-entry.js +19 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { execFormat, queryFormatState } from '../utils/exec-command.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Base Format class - Inspired by Quill's architecture
|
|
5
|
+
* All text formats should extend this class
|
|
6
|
+
*/
|
|
7
|
+
export class Format {
|
|
8
|
+
static formatName = '';
|
|
9
|
+
static tagName = '';
|
|
10
|
+
static className = '';
|
|
11
|
+
|
|
12
|
+
constructor(domNode) {
|
|
13
|
+
this.domNode = domNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a new format node
|
|
18
|
+
* @param {*} value - Format value
|
|
19
|
+
* @returns {HTMLElement}
|
|
20
|
+
*/
|
|
21
|
+
static create(value) {
|
|
22
|
+
const node = document.createElement(this.tagName);
|
|
23
|
+
if (this.className) {
|
|
24
|
+
node.className = this.className;
|
|
25
|
+
}
|
|
26
|
+
return node;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
getOffsetWithin(container, range) {
|
|
30
|
+
let offset = 0;
|
|
31
|
+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
|
|
32
|
+
let currentNode;
|
|
33
|
+
|
|
34
|
+
while ((currentNode = walker.nextNode())) {
|
|
35
|
+
if (currentNode === range.startContainer) {
|
|
36
|
+
return offset + range.startOffset;
|
|
37
|
+
}
|
|
38
|
+
offset += currentNode.textContent.length;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return offset;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check if format is active at current selection
|
|
46
|
+
* @returns {boolean}
|
|
47
|
+
*/
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Inline Format - for formats like bold, italic, underline
|
|
52
|
+
* Handles inline formatting that wraps text within the same line/block
|
|
53
|
+
*/
|
|
54
|
+
export class InlineFormat extends Format {
|
|
55
|
+
/**
|
|
56
|
+
* Create inline format element
|
|
57
|
+
* @param {*} value - Format value
|
|
58
|
+
* @returns {HTMLElement}
|
|
59
|
+
*/
|
|
60
|
+
static create(value) {
|
|
61
|
+
const node = super.create(value);
|
|
62
|
+
return node;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Apply inline format to selection
|
|
67
|
+
* Wraps selected text or inserts format marker at cursor
|
|
68
|
+
* @param {*} value - Format value
|
|
69
|
+
*/
|
|
70
|
+
apply(value) {
|
|
71
|
+
const selection = window.getSelection();
|
|
72
|
+
if (!selection || !selection.rangeCount) return;
|
|
73
|
+
|
|
74
|
+
const range = selection.getRangeAt(0);
|
|
75
|
+
if (range.collapsed) {
|
|
76
|
+
// No selection - insert format marker at cursor
|
|
77
|
+
const formatNode = this.constructor.create(value);
|
|
78
|
+
formatNode.appendChild(document.createTextNode('\u200B')); // Zero-width space
|
|
79
|
+
range.insertNode(formatNode);
|
|
80
|
+
|
|
81
|
+
// Position cursor inside the format node
|
|
82
|
+
const newRange = document.createRange();
|
|
83
|
+
newRange.setStart(formatNode.firstChild, 1);
|
|
84
|
+
newRange.collapse(true);
|
|
85
|
+
selection.removeAllRanges();
|
|
86
|
+
selection.addRange(newRange);
|
|
87
|
+
} else {
|
|
88
|
+
// Has selection - wrap selected content
|
|
89
|
+
const contents = range.extractContents();
|
|
90
|
+
const formatNode = this.constructor.create(value);
|
|
91
|
+
formatNode.appendChild(contents);
|
|
92
|
+
range.insertNode(formatNode);
|
|
93
|
+
|
|
94
|
+
// Select the formatted content
|
|
95
|
+
const newRange = document.createRange();
|
|
96
|
+
newRange.selectNodeContents(formatNode);
|
|
97
|
+
selection.removeAllRanges();
|
|
98
|
+
selection.addRange(newRange);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Remove inline format from selection
|
|
104
|
+
* Unwraps formatted content or removes format at cursor
|
|
105
|
+
*/
|
|
106
|
+
remove() {
|
|
107
|
+
const selection = window.getSelection();
|
|
108
|
+
if (!selection || !selection.rangeCount) return;
|
|
109
|
+
|
|
110
|
+
const range = selection.getRangeAt(0);
|
|
111
|
+
|
|
112
|
+
if (range.collapsed) {
|
|
113
|
+
// Handle cursor position
|
|
114
|
+
this.removeAtCursor(range, selection);
|
|
115
|
+
} else {
|
|
116
|
+
// Handle selection
|
|
117
|
+
this.removeFromSelection(range, selection);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Remove format at cursor position
|
|
123
|
+
* @param {Range} range - Current range
|
|
124
|
+
* @param {Selection} selection - Current selection
|
|
125
|
+
*/
|
|
126
|
+
removeAtCursor(range, selection) {
|
|
127
|
+
const container = range.startContainer;
|
|
128
|
+
const formatNode = this.findFormatNode(container);
|
|
129
|
+
|
|
130
|
+
if (!formatNode || !formatNode.parentNode) return;
|
|
131
|
+
|
|
132
|
+
const text = formatNode.textContent;
|
|
133
|
+
const absoluteOffset = this.getOffsetWithin(formatNode, range);
|
|
134
|
+
|
|
135
|
+
// Split the format node at cursor position
|
|
136
|
+
const beforeText = text.slice(0, absoluteOffset);
|
|
137
|
+
const afterText = text.slice(absoluteOffset);
|
|
138
|
+
|
|
139
|
+
const fragment = document.createDocumentFragment();
|
|
140
|
+
|
|
141
|
+
if (beforeText) {
|
|
142
|
+
const beforeNode = formatNode.cloneNode(false);
|
|
143
|
+
beforeNode.textContent = beforeText;
|
|
144
|
+
fragment.appendChild(beforeNode);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Insert zero-width space as marker
|
|
148
|
+
const zwspNode = document.createTextNode('\u200B');
|
|
149
|
+
fragment.appendChild(zwspNode);
|
|
150
|
+
|
|
151
|
+
if (afterText) {
|
|
152
|
+
const afterNode = formatNode.cloneNode(false);
|
|
153
|
+
afterNode.textContent = afterText;
|
|
154
|
+
fragment.appendChild(afterNode);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
formatNode.replaceWith(fragment);
|
|
158
|
+
|
|
159
|
+
// Position cursor after the marker
|
|
160
|
+
const newRange = document.createRange();
|
|
161
|
+
newRange.setStartAfter(zwspNode);
|
|
162
|
+
newRange.collapse(true);
|
|
163
|
+
selection.removeAllRanges();
|
|
164
|
+
selection.addRange(newRange);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Remove format from selection
|
|
169
|
+
*/
|
|
170
|
+
removeFromSelection(range, selection) {
|
|
171
|
+
const formatName = this.constructor.formatName;
|
|
172
|
+
execFormat(formatName);
|
|
173
|
+
if (formatName === 'strike') {
|
|
174
|
+
execFormat('strikeThrough');
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Find the format node containing the given node
|
|
182
|
+
* @param {Node} node - DOM node
|
|
183
|
+
* @returns {Element|null} Format node
|
|
184
|
+
*/
|
|
185
|
+
findFormatNode(node) {
|
|
186
|
+
while (node && node !== document.body) {
|
|
187
|
+
if (node.nodeType === Node.ELEMENT_NODE &&
|
|
188
|
+
node.tagName === this.constructor.tagName) {
|
|
189
|
+
return node;
|
|
190
|
+
}
|
|
191
|
+
node = node.parentNode;
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Find all format nodes within a range
|
|
198
|
+
* @param {Range} range - Selection range
|
|
199
|
+
* @returns {Element[]} Array of format nodes
|
|
200
|
+
*/
|
|
201
|
+
findFormatNodesInRange(range) {
|
|
202
|
+
const nodes = [];
|
|
203
|
+
const walker = document.createTreeWalker(
|
|
204
|
+
range.commonAncestorContainer,
|
|
205
|
+
NodeFilter.SHOW_ELEMENT,
|
|
206
|
+
{
|
|
207
|
+
acceptNode: (node) => {
|
|
208
|
+
if (node.tagName === this.constructor.tagName &&
|
|
209
|
+
range.intersectsNode(node)) {
|
|
210
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
211
|
+
}
|
|
212
|
+
return NodeFilter.FILTER_SKIP;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
let node;
|
|
218
|
+
while ((node = walker.nextNode())) {
|
|
219
|
+
nodes.push(node);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return nodes;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Check if inline format is active at current selection
|
|
227
|
+
* @returns {boolean}
|
|
228
|
+
*/
|
|
229
|
+
isActive() {
|
|
230
|
+
const selection = window.getSelection();
|
|
231
|
+
if (!selection || !selection.rangeCount) return false;
|
|
232
|
+
|
|
233
|
+
const range = selection.getRangeAt(0);
|
|
234
|
+
let node = range.startContainer;
|
|
235
|
+
|
|
236
|
+
const tagName = this.constructor.tagName;
|
|
237
|
+
const altTags = this.constructor.alternativeTagNames || [];
|
|
238
|
+
const formatName = this.constructor.formatName;
|
|
239
|
+
|
|
240
|
+
// Đặc biệt với một số lệnh hỗ trợ execCommand
|
|
241
|
+
const commandSupported = ['bold', 'italic', 'underline'];
|
|
242
|
+
if (commandSupported.includes(formatName?.toLowerCase())) {
|
|
243
|
+
return queryFormatState(formatName);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Kiểm tra DOM tag
|
|
247
|
+
while (node && node !== document.body) {
|
|
248
|
+
if (
|
|
249
|
+
node.nodeType === Node.ELEMENT_NODE &&
|
|
250
|
+
(node.tagName === tagName ||
|
|
251
|
+
altTags.includes(node.tagName))
|
|
252
|
+
) {
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
node = node.parentNode;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Block Format - for formats like headers, paragraphs, alignment
|
|
265
|
+
* Handles block-level formatting that affects entire blocks/paragraphs
|
|
266
|
+
*/
|
|
267
|
+
export class BlockFormat extends Format {
|
|
268
|
+
/**
|
|
269
|
+
* Create block format element
|
|
270
|
+
* @param {*} value - Format value
|
|
271
|
+
* @returns {HTMLElement}
|
|
272
|
+
*/
|
|
273
|
+
static create(value) {
|
|
274
|
+
const node = super.create(value);
|
|
275
|
+
return node;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Apply block format to selection
|
|
280
|
+
* Converts current block(s) or creates new block
|
|
281
|
+
* @param {*} value - Format value
|
|
282
|
+
*/
|
|
283
|
+
apply(value) {
|
|
284
|
+
const selection = window.getSelection();
|
|
285
|
+
if (!selection || !selection.rangeCount) return;
|
|
286
|
+
|
|
287
|
+
const range = selection.getRangeAt(0);
|
|
288
|
+
const blocks = this.getBlockElements(range);
|
|
289
|
+
|
|
290
|
+
if (blocks.length === 0) {
|
|
291
|
+
// No block found - create new one
|
|
292
|
+
this.createBlockAtCursor(range, value);
|
|
293
|
+
} else {
|
|
294
|
+
// Convert existing blocks
|
|
295
|
+
blocks.forEach(block => {
|
|
296
|
+
this.convertBlock(block, value);
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Remove block format from selection
|
|
303
|
+
* Converts blocks back to default (paragraph) or removes formatting
|
|
304
|
+
*/
|
|
305
|
+
remove() {
|
|
306
|
+
const selection = window.getSelection();
|
|
307
|
+
if (!selection || !selection.rangeCount) return;
|
|
308
|
+
|
|
309
|
+
const range = selection.getRangeAt(0);
|
|
310
|
+
const blocks = this.getBlockElements(range);
|
|
311
|
+
|
|
312
|
+
blocks.forEach(block => {
|
|
313
|
+
this.removeBlockFormat(block);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Create new block at cursor position
|
|
319
|
+
* @param {Range} range - Current range
|
|
320
|
+
* @param {*} value - Format value
|
|
321
|
+
*/
|
|
322
|
+
createBlockAtCursor(range, value) {
|
|
323
|
+
const blockNode = this.constructor.create(value);
|
|
324
|
+
|
|
325
|
+
// Try to preserve style from existing block if cursor is inside one
|
|
326
|
+
const existingBlock = this.getBlockElement(range.startContainer);
|
|
327
|
+
if (existingBlock && existingBlock.style && existingBlock.style.cssText) {
|
|
328
|
+
blockNode.style.cssText = existingBlock.style.cssText;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (range.collapsed) {
|
|
332
|
+
// No selection - create empty block
|
|
333
|
+
blockNode.appendChild(document.createTextNode(''));
|
|
334
|
+
range.insertNode(blockNode);
|
|
335
|
+
|
|
336
|
+
// Position cursor inside the block
|
|
337
|
+
const newRange = document.createRange();
|
|
338
|
+
newRange.setStart(blockNode, 0);
|
|
339
|
+
newRange.collapse(true);
|
|
340
|
+
const selection = window.getSelection();
|
|
341
|
+
selection.removeAllRanges();
|
|
342
|
+
selection.addRange(newRange);
|
|
343
|
+
} else {
|
|
344
|
+
// Has selection - wrap in block
|
|
345
|
+
const contents = range.extractContents();
|
|
346
|
+
blockNode.appendChild(contents);
|
|
347
|
+
range.insertNode(blockNode);
|
|
348
|
+
|
|
349
|
+
// Select the content in the block
|
|
350
|
+
const newRange = document.createRange();
|
|
351
|
+
newRange.selectNodeContents(blockNode);
|
|
352
|
+
const selection = window.getSelection();
|
|
353
|
+
selection.removeAllRanges();
|
|
354
|
+
selection.addRange(newRange);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Convert existing block to new format
|
|
360
|
+
* @param {Element} block - Block element to convert
|
|
361
|
+
* @param {*} value - Format value
|
|
362
|
+
*/
|
|
363
|
+
convertBlock(block, value) {
|
|
364
|
+
const newBlock = this.constructor.create(value);
|
|
365
|
+
|
|
366
|
+
// Copy all child nodes
|
|
367
|
+
while (block.firstChild) {
|
|
368
|
+
newBlock.appendChild(block.firstChild);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Copy relevant attributes
|
|
372
|
+
if (block.className && this.shouldPreserveClass(block.className)) {
|
|
373
|
+
newBlock.className = block.className;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Copy style attributes to preserve formatting like text-align
|
|
377
|
+
if (block.style && block.style.cssText) {
|
|
378
|
+
newBlock.style.cssText = block.style.cssText;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Replace the block
|
|
382
|
+
block.parentNode.replaceChild(newBlock, block);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Remove block format (convert to paragraph)
|
|
387
|
+
* @param {Element} block - Block element
|
|
388
|
+
*/
|
|
389
|
+
removeBlockFormat(block) {
|
|
390
|
+
const paragraph = document.createElement('P');
|
|
391
|
+
|
|
392
|
+
// Move all child nodes to paragraph
|
|
393
|
+
while (block.firstChild) {
|
|
394
|
+
paragraph.appendChild(block.firstChild);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Copy style attributes to preserve formatting like text-align
|
|
398
|
+
if (block.style && block.style.cssText) {
|
|
399
|
+
paragraph.style.cssText = block.style.cssText;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Replace the block
|
|
403
|
+
block.parentNode.replaceChild(paragraph, block);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Get block elements in range
|
|
408
|
+
* @param {Range} range - Selection range
|
|
409
|
+
* @returns {Element[]} Array of block elements
|
|
410
|
+
*/
|
|
411
|
+
getBlockElements(range) {
|
|
412
|
+
const blocks = [];
|
|
413
|
+
let startBlock = this.getBlockElement(range.startContainer);
|
|
414
|
+
let endBlock = this.getBlockElement(range.endContainer);
|
|
415
|
+
|
|
416
|
+
// Nếu endBlock ngay sau startBlock và selection kết thúc ở vị trí 0 của endBlock
|
|
417
|
+
if (startBlock && endBlock && startBlock.nextElementSibling === endBlock) {
|
|
418
|
+
const endAtStartOfEndBlock =
|
|
419
|
+
range.endContainer === endBlock &&
|
|
420
|
+
range.endOffset === 0;
|
|
421
|
+
if (endAtStartOfEndBlock) {
|
|
422
|
+
endBlock = startBlock;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (startBlock === endBlock) {
|
|
427
|
+
if (startBlock) blocks.push(startBlock);
|
|
428
|
+
} else {
|
|
429
|
+
// Multiple blocks
|
|
430
|
+
let current = startBlock;
|
|
431
|
+
while (current && current !== endBlock) {
|
|
432
|
+
if (this.isBlockElement(current)) {
|
|
433
|
+
blocks.push(current);
|
|
434
|
+
}
|
|
435
|
+
current = this.getNextBlockElement(current);
|
|
436
|
+
}
|
|
437
|
+
if (endBlock && this.isBlockElement(endBlock)) {
|
|
438
|
+
blocks.push(endBlock);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return blocks.filter((block, index, self) =>
|
|
443
|
+
self.indexOf(block) === index
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Get block element containing node
|
|
449
|
+
* @param {Node} node - DOM node
|
|
450
|
+
* @returns {Element|null} Block element
|
|
451
|
+
*/
|
|
452
|
+
getBlockElement(node) {
|
|
453
|
+
while (node && node.nodeType !== Node.ELEMENT_NODE) {
|
|
454
|
+
node = node.parentNode;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
while (node && node !== document.body) {
|
|
458
|
+
if (this.isBlockElement(node)) {
|
|
459
|
+
return node;
|
|
460
|
+
}
|
|
461
|
+
node = node.parentNode;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Check if element is a block element
|
|
469
|
+
* @param {Element} element - DOM element
|
|
470
|
+
* @returns {boolean}
|
|
471
|
+
*/
|
|
472
|
+
isBlockElement(element) {
|
|
473
|
+
if (!element || element.nodeType !== Node.ELEMENT_NODE) return false;
|
|
474
|
+
|
|
475
|
+
const blockTags = [
|
|
476
|
+
'P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
|
|
477
|
+
'BLOCKQUOTE', 'PRE', 'UL', 'OL', 'LI', 'SECTION', 'ARTICLE'
|
|
478
|
+
];
|
|
479
|
+
return blockTags.includes(element.tagName.toUpperCase());
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Get next block element
|
|
484
|
+
* @param {Element} element - Current element
|
|
485
|
+
* @returns {Element|null} Next block element
|
|
486
|
+
*/
|
|
487
|
+
getNextBlockElement(element) {
|
|
488
|
+
let next = element.nextElementSibling;
|
|
489
|
+
while (next) {
|
|
490
|
+
if (this.isBlockElement(next)) {
|
|
491
|
+
return next;
|
|
492
|
+
}
|
|
493
|
+
next = next.nextElementSibling;
|
|
494
|
+
}
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Check if block format is active at current selection
|
|
500
|
+
* @param {*} value - Optional specific value to check
|
|
501
|
+
* @returns {boolean}
|
|
502
|
+
*/
|
|
503
|
+
isActive(value = null) {
|
|
504
|
+
const selection = window.getSelection();
|
|
505
|
+
if (!selection || !selection.rangeCount) return false;
|
|
506
|
+
|
|
507
|
+
const range = selection.getRangeAt(0);
|
|
508
|
+
const block = this.getBlockElement(range.startContainer);
|
|
509
|
+
|
|
510
|
+
if (!block) return false;
|
|
511
|
+
|
|
512
|
+
// Check if block matches our format
|
|
513
|
+
if (block.tagName === this.constructor.tagName) {
|
|
514
|
+
return value ? this.hasValue(block, value) : true;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Check if block has specific value
|
|
522
|
+
* Override in subclasses for specific value checking
|
|
523
|
+
* @param {Element} block - Block element
|
|
524
|
+
* @param {*} value - Value to check
|
|
525
|
+
* @returns {boolean}
|
|
526
|
+
*/
|
|
527
|
+
hasValue(block, value) {
|
|
528
|
+
return true; // Default implementation
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Check if class should be preserved during conversion
|
|
533
|
+
* Override in subclasses for specific class handling
|
|
534
|
+
* @param {string} className - Class name
|
|
535
|
+
* @returns {boolean}
|
|
536
|
+
*/
|
|
537
|
+
shouldPreserveClass(className) {
|
|
538
|
+
return false; // Default: don't preserve classes
|
|
539
|
+
}
|
|
540
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base Module class - Inspired by Quill's architecture
|
|
3
|
+
* All editor modules should extend this class
|
|
4
|
+
*/
|
|
5
|
+
export default class Module {
|
|
6
|
+
static DEFAULTS = {};
|
|
7
|
+
|
|
8
|
+
constructor(editor, options = {}) {
|
|
9
|
+
this.editor = editor;
|
|
10
|
+
this.options = { ...this.constructor.DEFAULTS, ...options };
|
|
11
|
+
this.events = new Map();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Add event listener
|
|
16
|
+
* @param {string} event - Event name
|
|
17
|
+
* @param {function} handler - Event handler
|
|
18
|
+
*/
|
|
19
|
+
on(event, handler) {
|
|
20
|
+
if (!this.events.has(event)) {
|
|
21
|
+
this.events.set(event, []);
|
|
22
|
+
}
|
|
23
|
+
this.events.get(event).push(handler);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Remove event listener
|
|
28
|
+
* @param {string} event - Event name
|
|
29
|
+
* @param {function} handler - Event handler
|
|
30
|
+
*/
|
|
31
|
+
off(event, handler) {
|
|
32
|
+
if (this.events.has(event)) {
|
|
33
|
+
const handlers = this.events.get(event);
|
|
34
|
+
const index = handlers.indexOf(handler);
|
|
35
|
+
if (index > -1) {
|
|
36
|
+
handlers.splice(index, 1);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Emit event
|
|
43
|
+
* @param {string} event - Event name
|
|
44
|
+
* @param {*} data - Event data
|
|
45
|
+
*/
|
|
46
|
+
emit(event, data) {
|
|
47
|
+
if (this.events.has(event)) {
|
|
48
|
+
this.events.get(event).forEach(handler => {
|
|
49
|
+
try {
|
|
50
|
+
handler(data);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error(`Error in event handler for ${event}:`, error);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Called when module is being destroyed
|
|
60
|
+
* Override this method to cleanup resources
|
|
61
|
+
*/
|
|
62
|
+
destroy() {
|
|
63
|
+
this.events.clear();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Called when editor content changes
|
|
68
|
+
* Override this method to respond to content changes
|
|
69
|
+
*/
|
|
70
|
+
onContentChange() {
|
|
71
|
+
// Override in subclasses
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Called when selection changes
|
|
76
|
+
* Override this method to respond to selection changes
|
|
77
|
+
*/
|
|
78
|
+
onSelectionChange(range) {
|
|
79
|
+
// Override in subclasses
|
|
80
|
+
}
|
|
81
|
+
}
|