@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.
Files changed (73) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +223 -142
  3. package/core.js +82 -0
  4. package/dist/core.esm.js +2 -0
  5. package/dist/core.esm.js.map +1 -0
  6. package/dist/rich-editor.esm.js +1 -1
  7. package/dist/rich-editor.esm.js.map +1 -1
  8. package/dist/rich-editor.min.js +1 -1
  9. package/dist/rich-editor.min.js.map +1 -1
  10. package/index.d.ts +230 -103
  11. package/index.js +297 -0
  12. package/lib/core/editor.js +1885 -0
  13. package/lib/core/format.js +540 -0
  14. package/lib/core/module.js +81 -0
  15. package/lib/core/registry.js +158 -0
  16. package/lib/formats/background.js +213 -0
  17. package/lib/formats/bold.js +49 -0
  18. package/lib/formats/capitalization.js +579 -0
  19. package/lib/formats/color.js +183 -0
  20. package/lib/formats/emoji.js +282 -0
  21. package/lib/formats/font-family.js +548 -0
  22. package/lib/formats/heading.js +502 -0
  23. package/lib/formats/image.js +341 -0
  24. package/lib/formats/import.js +385 -0
  25. package/lib/formats/indent.js +297 -0
  26. package/lib/formats/italic.js +27 -0
  27. package/lib/formats/line-height.js +562 -0
  28. package/lib/formats/link.js +251 -0
  29. package/lib/formats/list.js +635 -0
  30. package/lib/formats/strike.js +31 -0
  31. package/lib/formats/subscript.js +40 -0
  32. package/lib/formats/superscript.js +39 -0
  33. package/lib/formats/table.js +293 -0
  34. package/lib/formats/tag.js +304 -0
  35. package/lib/formats/text-align.js +422 -0
  36. package/lib/formats/text-size.js +498 -0
  37. package/lib/formats/underline.js +30 -0
  38. package/lib/formats/video.js +381 -0
  39. package/lib/modules/block-toolbar.js +639 -0
  40. package/lib/modules/code-view.js +447 -0
  41. package/lib/modules/find-replace.js +273 -0
  42. package/lib/modules/history.js +425 -0
  43. package/lib/modules/mention.js +200 -0
  44. package/lib/modules/resize-handles.js +701 -0
  45. package/lib/modules/slash-menu.js +183 -0
  46. package/lib/modules/table-toolbar.js +635 -0
  47. package/lib/modules/toolbar.js +607 -0
  48. package/lib/serialize.js +241 -0
  49. package/lib/static.js +28 -0
  50. package/lib/styles-loader.js +142 -0
  51. package/{dist → lib}/styles.css +1392 -35
  52. package/lib/styles.css.js +2 -0
  53. package/lib/styles.min.css +1 -0
  54. package/lib/ui/color-picker.js +296 -0
  55. package/lib/ui/customselect.js +351 -0
  56. package/lib/ui/emoji-picker.js +196 -0
  57. package/lib/ui/icons.js +145 -0
  58. package/lib/ui/image-popup.js +435 -0
  59. package/lib/ui/import-popup.js +288 -0
  60. package/lib/ui/link-popup.js +139 -0
  61. package/lib/ui/list-picker.js +307 -0
  62. package/lib/ui/select-button.js +68 -0
  63. package/lib/ui/table-popup.js +171 -0
  64. package/lib/ui/tag-popup.js +249 -0
  65. package/lib/ui/text-align-picker.js +278 -0
  66. package/lib/ui/video-popup.js +413 -0
  67. package/lib/utils/exec-command.js +72 -0
  68. package/lib/utils/history-helper.js +50 -0
  69. package/lib/utils/popup-helper.js +219 -0
  70. package/lib/utils/popup-positioning.js +234 -0
  71. package/lib/utils/sanitize.js +164 -0
  72. package/package.json +51 -32
  73. package/umd-entry.js +19 -0
@@ -0,0 +1,502 @@
1
+ import { BlockFormat } from '../core/format.js';
2
+ import CustomSelect from '../ui/customselect.js';
3
+ import { saveBeforeFormat } from '../utils/history-helper.js';
4
+ import Editor from '../core/editor.js';
5
+
6
+ /**
7
+ * Heading Format - Handles heading and paragraph formatting
8
+ * Now supports multiple editor instances with separate popup instances
9
+ */
10
+ class Heading extends BlockFormat {
11
+ static formatName = 'heading';
12
+ static tagName = 'H1'; // Default tag, will be overridden
13
+
14
+ constructor() {
15
+ super();
16
+
17
+ // Get current editor instance
18
+ const currentEditor = Editor.getCurrentInstance();
19
+ if (!currentEditor) {
20
+ console.warn('No editor instance found for Heading format');
21
+ return;
22
+ }
23
+
24
+ this.editorId = currentEditor.instanceId;
25
+
26
+ // Check if this editor already has a heading select instance
27
+ let customSelect = currentEditor.getPopupInstance('heading');
28
+
29
+ if (!customSelect) {
30
+ // Create new custom select instance for this editor
31
+ const tagMap = Heading.getTagMap();
32
+ const items = Object.values(tagMap).map(tagData => ({
33
+ value: tagData.tag,
34
+ label: tagData.element,
35
+ title: tagData.title
36
+ }));
37
+
38
+ customSelect = new CustomSelect({
39
+ items: items,
40
+ displayProperty: 'label',
41
+ valueProperty: 'value',
42
+ className: 'heading-select',
43
+ onItemSelect: (value, item) => {
44
+ Heading.applyTagToCurrentSelection(value, this.editorId);
45
+ },
46
+ editor: currentEditor,
47
+ editorId: this.editorId
48
+ });
49
+
50
+ // Store popup instance in editor
51
+ currentEditor.setPopupInstance('heading', customSelect);
52
+ }
53
+
54
+ this.customSelect = customSelect;
55
+ }
56
+
57
+ /**
58
+ * Create a new Heading format instance for a specific editor
59
+ * @param {string} editorId - Editor instance ID
60
+ * @returns {Heading} Heading format instance
61
+ */
62
+ static createForEditor(editorId) {
63
+ const editor = Editor.getInstanceById(editorId);
64
+ if (!editor) {
65
+ console.warn('No editor instance found for ID:', editorId);
66
+ return null;
67
+ }
68
+
69
+ // Temporarily set as current instance
70
+ const originalCurrent = Editor.currentInstance;
71
+ Editor.currentInstance = editor;
72
+
73
+ // Create format instance
74
+ const format = new Heading();
75
+
76
+ // Restore original current instance
77
+ Editor.currentInstance = originalCurrent;
78
+
79
+ return format;
80
+ }
81
+
82
+ /**
83
+ * Get display name for tag
84
+ * @param {string} tag - HTML tag name
85
+ * @returns {string} Display name
86
+ */
87
+ static getTagMap() {
88
+ return {
89
+ 'H1': { tag: 'H1', element: '<h1 style="margin:0">Heading 1</h1>', title: 'Heading 1' },
90
+ 'H2': { tag: 'H2', element: '<h2 style="margin:0">Heading 2</h2>', title: 'Heading 2' },
91
+ 'H3': { tag: 'H3', element: '<h3 style="margin:0">Heading 3</h3>', title: 'Heading 3' },
92
+ 'H4': { tag: 'H4', element: '<h4 style="margin:0">Heading 4</h4>', title: 'Heading 4' },
93
+ 'H5': { tag: 'H5', element: '<h5 style="margin:0">Heading 5</h5>', title: 'Heading 5' },
94
+ 'H6': { tag: 'H6', element: '<h6 style="margin:0">Heading 6</h6>', title: 'Heading 6' },
95
+ 'P': { tag: 'P', element: '<p style="margin:0">Paragraph</p>', title: 'Paragraph' },
96
+ 'PRE': { tag: 'PRE', element: '<pre style="margin:0">Code</pre>', title: 'Preformatted' },
97
+ 'BLOCKQUOTE': { tag: 'BLOCKQUOTE', element: '<blockquote style="margin:0">Quote</blockquote>', title: 'Quote' }
98
+ };
99
+ }
100
+
101
+ static getTagDisplayName(tag) {
102
+ const tagMap = this.getTagMap();
103
+ return tagMap[tag]?.title || 'Paragraph';
104
+ }
105
+
106
+ /**
107
+ * Update custom button text based on current tag
108
+ */
109
+ updateButtonText() {
110
+ const currentTag = this.getCurrentTag();
111
+ const displayName = Heading.getTagDisplayName(currentTag || 'P');
112
+
113
+ // Find heading button in the current editor's toolbar
114
+ const editor = Editor.getInstanceById(this.editorId);
115
+ if (!editor) return;
116
+
117
+ const toolbar = editor.getModule('toolbar');
118
+ let headingButton = null;
119
+
120
+ if (toolbar) {
121
+ headingButton = toolbar.getButton('heading');
122
+ }
123
+
124
+ // Fallback: find button by class in the current editor's toolbar
125
+ if (!headingButton) {
126
+ const toolbarContainer = toolbar?.getContainer();
127
+ if (toolbarContainer) {
128
+ headingButton = toolbarContainer.querySelector('.rich-editor-toolbar-btn.heading-btn');
129
+ }
130
+ }
131
+
132
+ // Final fallback: find any heading button in the current editor's wrapper
133
+ if (!headingButton) {
134
+ headingButton = editor.wrapper.querySelector('.rich-editor-toolbar-btn.heading-btn');
135
+ }
136
+
137
+ if (headingButton && headingButton.updateText) {
138
+ headingButton.updateText(displayName);
139
+ } else if (headingButton) {
140
+ headingButton.textContent = displayName;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Create element with specific tag
146
+ * @param {string} tag - HTML tag name (H1, H2, P, etc.)
147
+ * @returns {HTMLElement}
148
+ */
149
+ static create(tag = 'P') {
150
+ const node = document.createElement(tag.toUpperCase());
151
+ return node;
152
+ }
153
+
154
+ /**
155
+ * Static method to apply tag to current selection
156
+ * @param {string} tag - HTML tag name
157
+ * @param {string} editorId - Editor instance ID
158
+ */
159
+ static applyTagToCurrentSelection(tag, editorId = null) {
160
+ // Get the correct editor instance
161
+ let editor = null;
162
+ if (editorId) {
163
+ editor = Editor.getInstanceById(editorId);
164
+ } else {
165
+ editor = Editor.getCurrentInstance();
166
+ }
167
+
168
+ if (!editor) {
169
+ console.warn('No editor instance found for heading application');
170
+ return;
171
+ }
172
+
173
+ const selection = window.getSelection();
174
+ if (!selection || !selection.rangeCount) return;
175
+
176
+ // Save state before applying format
177
+ saveBeforeFormat();
178
+
179
+ const range = selection.getRangeAt(0);
180
+ const headingFormat = Heading.createForEditor(editorId);
181
+ if (headingFormat) {
182
+ headingFormat.apply(tag);
183
+
184
+ // Update button text after applying
185
+ headingFormat.updateButtonText();
186
+ }
187
+
188
+ // Trigger content change after applying format
189
+ setTimeout(() => {
190
+ if (editor && typeof editor.onContentChange === 'function') {
191
+ editor.onContentChange();
192
+ }
193
+ }, 0);
194
+ }
195
+
196
+ /**
197
+ * Apply heading format with specified tag
198
+ * @param {string} tag - HTML tag name (H1, H2, P, etc.)
199
+ */
200
+ apply(tag = 'P') {
201
+ const selection = window.getSelection();
202
+ if (!selection || !selection.rangeCount) return;
203
+
204
+ // Lưu selection trước khi đổi
205
+ const range = selection.getRangeAt(0);
206
+ const isCollapsed = range.collapsed; // true = không bôi đen gì
207
+
208
+ const blocks = this.getBlockElements(range);
209
+
210
+ if (blocks.length === 0) {
211
+ // Không có block - tạo mới
212
+ const newBlock = this.createBlockAtCursor(range, tag);
213
+
214
+ // Sau khi tạo block mới → đặt con trỏ vào block
215
+ const newRange = document.createRange();
216
+ newRange.setStart(newBlock, 0);
217
+ newRange.collapse(true);
218
+ selection.removeAllRanges();
219
+ selection.addRange(newRange);
220
+ } else {
221
+ // selection hiện tại
222
+ const selection = window.getSelection();
223
+ if (!selection.rangeCount) return;
224
+ const range = selection.getRangeAt(0);
225
+ const isCollapsed = range.collapsed;
226
+
227
+ // đảm bảo blocks là mảng
228
+ const blocksArray = Array.from(blocks);
229
+
230
+
231
+ // tìm block chứa 1 node
232
+ function findBlockIndex(node, blocks) {
233
+ while (node && node.nodeType !== 9 /*document*/) {
234
+ const idx = blocks.indexOf(node);
235
+ if (idx !== -1) return idx;
236
+ node = node.parentNode;
237
+ }
238
+ return -1;
239
+ }
240
+
241
+ // tính số ký tự từ đầu block tới vị trí (sử dụng Range.toString())
242
+ function charOffsetFromBlockStart(block, container, offset) {
243
+ const r = document.createRange();
244
+ r.setStart(block, 0);
245
+ r.setEnd(container, offset);
246
+ return r.toString().length;
247
+ }
248
+
249
+ const startBlockIndex = findBlockIndex(range.startContainer, blocksArray);
250
+ const endBlockIndex = findBlockIndex(range.endContainer, blocksArray);
251
+
252
+ let startCharOffset = 0, endCharOffset = 0;
253
+ if (startBlockIndex !== -1) {
254
+ startCharOffset = charOffsetFromBlockStart(blocksArray[startBlockIndex], range.startContainer, range.startOffset);
255
+ }
256
+ if (!isCollapsed && endBlockIndex !== -1) {
257
+ endCharOffset = charOffsetFromBlockStart(blocksArray[endBlockIndex], range.endContainer, range.endOffset);
258
+ }
259
+
260
+ // --- Thực hiện convert và lấy lại node mới trả về (nếu convertBlock trả về node mới)
261
+ const newBlocks = blocksArray.map(b => {
262
+ const newNode = this.convertBlock(b, tag);
263
+ return newNode || b; // nếu convertBlock trả về undefined thì dùng lại b (convert in-place)
264
+ });
265
+
266
+ // helper: từ charOffset tìm text node + offset bên trong nó; nếu không tìm thì trả về block để set ở cuối
267
+ function resolvePositionByCharOffset(block, charOffset) {
268
+ const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT, null, false);
269
+ let node;
270
+ let remaining = charOffset;
271
+ while ((node = walker.nextNode())) {
272
+ const len = node.nodeValue.length;
273
+ if (remaining <= len) return { node, offset: remaining };
274
+ remaining -= len;
275
+ }
276
+ // không tìm text node phù hợp => đặt ở cuối block
277
+ return { node: block, offset: block.childNodes.length };
278
+ }
279
+
280
+ // tái tạo range
281
+ const newRange = document.createRange();
282
+
283
+ if (isCollapsed) {
284
+ const idx = (startBlockIndex !== -1 ? startBlockIndex : 0);
285
+ const pos = resolvePositionByCharOffset(newBlocks[idx], startCharOffset);
286
+ if (pos.node.nodeType === Node.TEXT_NODE) newRange.setStart(pos.node, pos.offset);
287
+ else newRange.setStart(pos.node, Math.max(0, pos.offset));
288
+ newRange.collapse(true);
289
+ } else {
290
+ if (startBlockIndex === -1 || endBlockIndex === -1) {
291
+ // fallback: nếu không nằm trong blocks thì giữ range cũ (hoặc handle theo logic của bạn)
292
+ selection.removeAllRanges();
293
+ selection.addRange(range);
294
+ return;
295
+ }
296
+ const s = resolvePositionByCharOffset(newBlocks[startBlockIndex], startCharOffset);
297
+ const e = resolvePositionByCharOffset(newBlocks[endBlockIndex], endCharOffset);
298
+
299
+ // setStart/setEnd chấp nhận text node + offset hoặc element + childIndex
300
+ if (s.node.nodeType === Node.TEXT_NODE) newRange.setStart(s.node, s.offset);
301
+ else newRange.setStart(s.node, Math.min(s.offset, s.node.childNodes.length));
302
+
303
+ if (e.node.nodeType === Node.TEXT_NODE) newRange.setEnd(e.node, e.offset);
304
+ else newRange.setEnd(e.node, Math.min(e.offset, e.node.childNodes.length));
305
+ }
306
+
307
+ selection.removeAllRanges();
308
+ selection.addRange(newRange);
309
+ }
310
+ }
311
+
312
+
313
+ /**
314
+ * Create new block at cursor position
315
+ * @param {Range} range - Current range
316
+ * @param {string} tag - HTML tag name
317
+ */
318
+ createBlockAtCursor(range, tag) {
319
+ const blockNode = this.constructor.create(tag);
320
+
321
+ // Try to preserve style from existing block if cursor is inside one
322
+ const existingBlock = this.getBlockElement(range.startContainer);
323
+ if (existingBlock && existingBlock.style && existingBlock.style.cssText) {
324
+ blockNode.style.cssText = existingBlock.style.cssText;
325
+ }
326
+
327
+ if (range.collapsed) {
328
+ // No selection - create empty block
329
+ blockNode.appendChild(document.createTextNode(''));
330
+ range.insertNode(blockNode);
331
+
332
+ // Position cursor inside the block
333
+ const newRange = document.createRange();
334
+ newRange.setStart(blockNode, 0);
335
+ newRange.collapse(true);
336
+ const selection = window.getSelection();
337
+ selection.removeAllRanges();
338
+ selection.addRange(newRange);
339
+ } else {
340
+ // Has selection - wrap in block
341
+ const contents = range.extractContents();
342
+ blockNode.appendChild(contents);
343
+ range.insertNode(blockNode);
344
+
345
+ // Select the content in the block
346
+ const newRange = document.createRange();
347
+ newRange.selectNodeContents(blockNode);
348
+ const selection = window.getSelection();
349
+ selection.removeAllRanges();
350
+ selection.addRange(newRange);
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Convert existing block to new format
356
+ * @param {Element} block - Block element to convert
357
+ * @param {string} tag - HTML tag name
358
+ * @returns {Element} - The new block element
359
+ */
360
+ convertBlock(block, tag) {
361
+ const newBlock = this.constructor.create(tag);
362
+
363
+ // Copy all child nodes
364
+ while (block.firstChild) {
365
+ newBlock.appendChild(block.firstChild);
366
+ }
367
+
368
+ // Copy relevant attributes
369
+ if (block.className && this.shouldPreserveClass(block.className)) {
370
+ newBlock.className = block.className;
371
+ }
372
+
373
+ // Copy style attributes to preserve formatting like text-align
374
+ if (block.style && block.style.cssText) {
375
+ newBlock.style.cssText = block.style.cssText;
376
+ }
377
+
378
+ // Replace the block
379
+ block.parentNode.replaceChild(newBlock, block);
380
+
381
+ return newBlock;
382
+ }
383
+
384
+ /**
385
+ * Set cursor at start of block (fallback method)
386
+ * @param {Element} block - Block element
387
+ */
388
+ setCursorAtStartOfBlock(block) {
389
+ const selection = window.getSelection();
390
+ const range = document.createRange();
391
+
392
+ // Find first text node or position at start of block
393
+ const walker = document.createTreeWalker(
394
+ block,
395
+ NodeFilter.SHOW_TEXT,
396
+ null,
397
+ false
398
+ );
399
+
400
+ const firstTextNode = walker.nextNode();
401
+ if (firstTextNode) {
402
+ range.setStart(firstTextNode, 0);
403
+ range.collapse(true);
404
+ } else {
405
+ range.setStart(block, 0);
406
+ range.collapse(true);
407
+ }
408
+
409
+ selection.removeAllRanges();
410
+ selection.addRange(range);
411
+ }
412
+
413
+ /**
414
+ * Toggle heading format - shows/hides tag picker
415
+ */
416
+ async toggle() {
417
+ if (this.customSelect.isVisible) {
418
+ this.customSelect.hide();
419
+ } else {
420
+ await this.showTagPicker();
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Show custom select positioned relative to heading button on toolbar
426
+ */
427
+ async showTagPicker() {
428
+ // Find heading button in the current editor's toolbar
429
+ const editor = Editor.getInstanceById(this.editorId);
430
+ if (!editor) return;
431
+
432
+ const toolbar = editor.getModule('toolbar');
433
+ let headingButton = null;
434
+
435
+ if (toolbar) {
436
+ headingButton = toolbar.getButton('heading');
437
+ }
438
+
439
+ // Fallback: find button by class in the current editor's toolbar
440
+ if (!headingButton) {
441
+ const toolbarContainer = toolbar?.getContainer();
442
+ if (toolbarContainer) {
443
+ headingButton = toolbarContainer.querySelector('.rich-editor-toolbar-btn.heading-btn');
444
+ }
445
+ }
446
+
447
+ // Final fallback: find any heading button in the current editor's wrapper
448
+ if (!headingButton) {
449
+ headingButton = editor.wrapper.querySelector('.rich-editor-toolbar-btn.heading-btn');
450
+ }
451
+
452
+ if (!headingButton) {
453
+ console.warn('Heading button not found for editor:', this.editorId);
454
+ return;
455
+ }
456
+
457
+ // Update current selection before showing
458
+ const currentTag = this.getCurrentTag();
459
+ if (currentTag) {
460
+ this.customSelect.setCurrentValue(currentTag);
461
+ }
462
+
463
+ await this.customSelect.show(headingButton);
464
+ }
465
+
466
+ /**
467
+ * Check if heading format is active - always return false (no active state)
468
+ * Only update button text to show current tag
469
+ * @param {string} tag - Optional specific tag to check
470
+ * @returns {boolean}
471
+ */
472
+ isActive(tag = null) {
473
+ // Always update button text to show current tag
474
+ this.updateButtonText();
475
+
476
+ // Never show active state for heading button
477
+ return false;
478
+ }
479
+
480
+ /**
481
+ * Get current tag of the selection
482
+ * @returns {string|null} Current tag name or null
483
+ */
484
+ getCurrentTag() {
485
+ const selection = window.getSelection();
486
+ if (!selection || !selection.rangeCount) return null;
487
+
488
+ const range = selection.getRangeAt(0);
489
+ const block = this.getBlockElement(range.startContainer);
490
+
491
+ if (!block) return null;
492
+
493
+ const headingTags = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'P', 'PRE', 'BLOCKQUOTE'];
494
+ if (headingTags.includes(block.tagName)) {
495
+ return block.tagName;
496
+ }
497
+
498
+ return null;
499
+ }
500
+ }
501
+
502
+ export default Heading;