@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,1885 @@
1
+ import registry from './registry.js';
2
+ import Module from './module.js';
3
+ import { execFormat, queryFormatState } from '../utils/exec-command.js';
4
+ import { sanitizeHtml } from '../utils/sanitize.js';
5
+ import { htmlToMarkdown, markdownToHtml, domToJson, jsonToHtml } from '../serialize.js';
6
+
7
+ /**
8
+ * Main Editor class - Inspired by Quill's architecture
9
+ * This replaces the monolithic EditorCore class
10
+ */
11
+ export default class Editor {
12
+ static DEFAULTS = {
13
+ placeholder: 'Start typing...',
14
+ theme: 'light',
15
+ height: 400,
16
+ width: 800,
17
+ maxWidth: 1200,
18
+ maxHeight: 800,
19
+ content: null, // Default content for the editor
20
+ features: {
21
+ emoji: true,
22
+ image: true,
23
+ table: true,
24
+ wordCount: true,
25
+ breadcrumb: true
26
+ }
27
+ };
28
+
29
+ // Static reference to current editor instance
30
+ static currentInstance = null;
31
+ // Static map to track all editor instances
32
+ static instances = new Map();
33
+
34
+ constructor(selector, options = {}) {
35
+ this.options = {
36
+ ...Editor.DEFAULTS,
37
+ ...options,
38
+ // Deep-merge `features` so a partial override (e.g. { wordCount: false }
39
+ // to hide the bottom bar) keeps the other defaults instead of wiping them.
40
+ features: { ...Editor.DEFAULTS.features, ...(options.features || {}) }
41
+ };
42
+ this.root = typeof selector === 'string' ? document.querySelector(selector) : selector;
43
+ this.modules = new Map();
44
+ this.formats = new Map();
45
+ this.registry = registry;
46
+ this.events = new Map(); // Add event system
47
+
48
+ // State management
49
+ this.toolbarBtns = {};
50
+ this.statusbarEls = {};
51
+ this.dropdownMenus = {};
52
+
53
+ // Popup management - each editor has its own popup instances
54
+ this.popupInstances = new Map();
55
+
56
+ // Set as current instance
57
+ Editor.currentInstance = this;
58
+
59
+ // Register this instance
60
+ const instanceId = this.generateInstanceId();
61
+ this.instanceId = instanceId;
62
+ Editor.instances.set(instanceId, this);
63
+
64
+ this.init();
65
+ }
66
+
67
+ /**
68
+ * Generate unique instance ID
69
+ */
70
+ generateInstanceId() {
71
+ return 'editor_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
72
+ }
73
+
74
+ /**
75
+ * Initialize editor
76
+ */
77
+ init() {
78
+ this.createStructure();
79
+ this.loadModules();
80
+ this.loadFormats();
81
+ this.setupEventListeners();
82
+ this.updateStatusbar();
83
+ }
84
+
85
+ /**
86
+ * Create basic DOM structure - extracted from EditorCore.init()
87
+ * TODO: Copy implementation from EditorCore.init()
88
+ */
89
+ createStructure() {
90
+ // Create wrapper
91
+ this.wrapper = document.createElement('div');
92
+ this.wrapper.className = 'yjd-rich-editor';
93
+
94
+ // Apply dynamic sizing. A number is treated as pixels; a string (e.g.
95
+ // '100%') is applied verbatim so the editor can size responsively to its
96
+ // container instead of a fixed width.
97
+ const cssSize = (v) => (typeof v === 'number' ? v + 'px' : v);
98
+ this.wrapper.style.width = cssSize(this.options.width);
99
+ this.wrapper.style.maxWidth = cssSize(this.options.maxWidth);
100
+ this.wrapper.style.minHeight = cssSize(this.options.height);
101
+ this.wrapper.style.maxHeight = cssSize(this.options.maxHeight);
102
+
103
+ // Set position relative for popup positioning
104
+ this.wrapper.style.position = 'relative';
105
+
106
+ // Create editor area
107
+ this.editor = document.createElement('div');
108
+ this.editor.className = 'rich-editor-area';
109
+ this.editor.contentEditable = true;
110
+ this.editor.setAttribute('data-placeholder', this.options.placeholder);
111
+
112
+ // Accessibility: expose the editable region to assistive technology
113
+ this.editor.setAttribute('role', 'textbox');
114
+ this.editor.setAttribute('aria-multiline', 'true');
115
+ this.editor.setAttribute('aria-label', this.options.ariaLabel || this.options.placeholder || 'Rich text editor');
116
+
117
+ // Text direction (RTL support)
118
+ if (this.options.direction) {
119
+ this.editor.setAttribute('dir', this.options.direction === 'rtl' ? 'rtl' : 'ltr');
120
+ }
121
+
122
+ // Force browser to create <p> tags instead of <div> when pressing Enter
123
+ execFormat('defaultParagraphSeparator', 'p');
124
+
125
+ // Add default content
126
+ this.editor.innerHTML = this.getDefaultContent();
127
+
128
+ this.wrapper.appendChild(this.editor);
129
+
130
+ // Create popup container
131
+ this.popupContainer = document.createElement('div');
132
+ this.popupContainer.className = 'rich-editor-popup-container';
133
+ this.popupContainer.style.position = 'absolute';
134
+ this.popupContainer.style.top = '0';
135
+ this.popupContainer.style.left = '0';
136
+ this.popupContainer.style.width = '100%';
137
+ this.popupContainer.style.height = '100%';
138
+ this.popupContainer.style.pointerEvents = 'none';
139
+ this.popupContainer.style.zIndex = '1000';
140
+ this.wrapper.appendChild(this.popupContainer);
141
+
142
+ // Create statusbar if needed
143
+ if (this.options.features.wordCount || this.options.features.breadcrumb) {
144
+ this.createStatusbar();
145
+ }
146
+
147
+ // Add wrapper to root
148
+ this.root.appendChild(this.wrapper);
149
+
150
+ // Initialize placeholder visibility
151
+ this.updatePlaceholderVisibility();
152
+ }
153
+
154
+ /**
155
+ * Check if content is HTML or plain text
156
+ * @param {string} content - Content to check
157
+ * @returns {boolean} True if content appears to be HTML
158
+ */
159
+ isHtmlContent(content) {
160
+ if (!content || typeof content !== 'string') {
161
+ return false;
162
+ }
163
+
164
+ // Trim whitespace for checking
165
+ const trimmed = content.trim();
166
+
167
+ // Check for common HTML patterns
168
+ const htmlPatterns = [
169
+ /<[^>]+>/, // Contains HTML tags
170
+ /&[a-zA-Z]+;/, // Contains HTML entities
171
+ /&#\d+;/, // Contains numeric HTML entities
172
+ ];
173
+
174
+ return htmlPatterns.some(pattern => pattern.test(trimmed));
175
+ }
176
+
177
+ /**
178
+ * Wrap plain text content in a paragraph tag
179
+ * @param {string} content - Content to wrap
180
+ * @returns {string} Wrapped content
181
+ */
182
+ wrapTextInParagraph(content) {
183
+ if (!content || typeof content !== 'string') {
184
+ return '<p><br></p>';
185
+ }
186
+
187
+ const trimmed = content.trim();
188
+
189
+ // If content is already HTML, return as is
190
+ if (this.isHtmlContent(trimmed)) {
191
+ return trimmed;
192
+ }
193
+
194
+ // If content is empty, return empty paragraph
195
+ if (trimmed === '') {
196
+ return '<p><br></p>';
197
+ }
198
+
199
+ // Wrap plain text in paragraph tag
200
+ return `<p>${trimmed}</p>`;
201
+ }
202
+
203
+ /**
204
+ * Get default content for editor
205
+ */
206
+ getDefaultContent() {
207
+ // If custom content is provided in options, use it
208
+ if (this.options.content) {
209
+ return this.wrapTextInParagraph(this.options.content);
210
+ }
211
+
212
+ // Restore an autosaved draft if available
213
+ const saved = this._getAutosaved();
214
+ if (saved != null && saved !== '') {
215
+ return saved;
216
+ }
217
+
218
+ // Return completely empty content to show placeholder
219
+ return '';
220
+ }
221
+
222
+ /**
223
+ * Create statusbar - extracted from EditorCore
224
+ * TODO: Copy implementation from EditorCore.init()
225
+ */
226
+ createStatusbar() {
227
+ this.statusbar = document.createElement('div');
228
+ this.statusbar.className = 'rich-editor-statusbar';
229
+
230
+ // Create breadcrumb and word count elements
231
+ this.statusbarEls.breadcrumb = document.createElement('span');
232
+ this.statusbarEls.breadcrumb.className = 'rich-editor-breadcrumb';
233
+
234
+ this.statusbarEls.wordcount = document.createElement('span');
235
+ this.statusbarEls.wordcount.className = 'wordcount';
236
+
237
+ this.statusbar.appendChild(this.statusbarEls.breadcrumb);
238
+ this.statusbar.appendChild(this.statusbarEls.wordcount);
239
+ this.wrapper.appendChild(this.statusbar);
240
+ }
241
+
242
+ /**
243
+ * Load and initialize modules
244
+ */
245
+ loadModules() {
246
+ // Determine which modules to load
247
+ let modulesToLoad;
248
+
249
+ // Check if user provided toolbar configuration
250
+ const hasToolbarConfig = this.options.toolbar || this.options.toolbar1 || this.options.toolbar2;
251
+
252
+ if (hasToolbarConfig) {
253
+ // User wants custom toolbar - load only basic modules
254
+ modulesToLoad = this.options.modules || ['toolbar', 'history'];
255
+ } else {
256
+ // No toolbar config - load full feature set
257
+ modulesToLoad = this.options.modules || ['toolbar', 'history', 'block-toolbar', 'table-toolbar', 'code-view', 'theme-switcher', 'resize-handles', 'find-replace', 'slash-menu', 'mention'];
258
+ }
259
+
260
+ // @mention is inert without a source, so load it whenever configured —
261
+ // even alongside a custom toolbar that otherwise loads only basics.
262
+ if (this.options.mention && !modulesToLoad.includes('mention')) {
263
+ modulesToLoad.push('mention');
264
+ }
265
+
266
+
267
+ modulesToLoad.forEach(moduleName => {
268
+ const ModuleClass = this.registry.get(`modules/${moduleName}`);
269
+ if (ModuleClass) {
270
+ // For toolbar module, pass all options so it can detect toolbar config
271
+ const moduleOptions = moduleName === 'toolbar' ? this.options : (this.options[moduleName] || this.options);
272
+ const moduleInstance = new ModuleClass(this, moduleOptions);
273
+ this.modules.set(moduleName, moduleInstance);
274
+
275
+ // Insert toolbar before editor
276
+ if (moduleName === 'toolbar' && moduleInstance.getContainer) {
277
+ const toolbarContainer = moduleInstance.getContainer();
278
+ this.wrapper.insertBefore(toolbarContainer, this.editor);
279
+
280
+ // Listen for toolbar events
281
+ moduleInstance.on('toolbar-click', (data) => {
282
+ this.handleToolbarClick(data);
283
+ });
284
+ }
285
+
286
+ } else {
287
+ }
288
+ });
289
+ }
290
+
291
+ /**
292
+ * Load and initialize formats
293
+ */
294
+ loadFormats() {
295
+ // Determine which formats to load
296
+ let formatsToLoad;
297
+
298
+ // Check if user provided toolbar configuration
299
+ const hasToolbarConfig = this.options.toolbar || this.options.toolbar1 || this.options.toolbar2;
300
+
301
+ if (hasToolbarConfig) {
302
+ // User wants custom toolbar - load only basic formats
303
+ formatsToLoad = this.options.formats || ['bold', 'italic', 'underline', 'strike'];
304
+ } else {
305
+ // No toolbar config - load full feature set
306
+ formatsToLoad = this.options.formats || [
307
+ 'bold', 'italic', 'underline', 'strike', 'subscript', 'superscript',
308
+ 'color', 'background', 'text-align', 'text-size', 'link',
309
+ 'code', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
310
+ 'paragraph', 'pre'
311
+ ];
312
+ }
313
+
314
+
315
+ formatsToLoad.forEach(formatName => {
316
+ const FormatClass = this.registry.get(`formats/${formatName}`);
317
+ if (FormatClass) {
318
+ this.formats.set(formatName, FormatClass);
319
+ } else {
320
+ }
321
+ });
322
+ }
323
+
324
+ /**
325
+ * Setup event listeners - extracted from EditorCore
326
+ * TODO: Copy implementation from EditorCore.bindEvents()
327
+ */
328
+ setupEventListeners() {
329
+ // Track the active editor: whenever the user interacts with THIS editor
330
+ // (pointer or focus anywhere inside its wrapper — editor area, toolbar,
331
+ // popups), make it the current instance. This fixes multi-instance bugs
332
+ // where helpers resolved to the last-CREATED editor instead of the
333
+ // last-INTERACTED one.
334
+ this._markActive = () => { Editor.currentInstance = this; };
335
+ this.wrapper.addEventListener('pointerdown', this._markActive, true);
336
+ this.wrapper.addEventListener('focusin', this._markActive, true);
337
+
338
+ // Basic input event. onContentChange() already runs ensureEditorHasContent()
339
+ // and updateStatusbar() (via _emitChange), so we don't duplicate them here.
340
+ this.editor.addEventListener('input', () => {
341
+ this.updatePlaceholderVisibility();
342
+ this.onContentChange();
343
+ });
344
+
345
+ // Selection changes (caret move, selection) — coalesced to one run per
346
+ // animation frame so rapid events don't each trigger a full toolbar pass.
347
+ this._onDocSelectionChange = () => {
348
+ if (document.activeElement === this.editor || this.editor.contains(document.activeElement)) {
349
+ this._scheduleSelectionUpdate();
350
+ }
351
+ };
352
+ document.addEventListener('selectionchange', this._onDocSelectionChange);
353
+
354
+ // Mouse up: selectionchange already fires for this; just ensure a refresh
355
+ // (still rAF-throttled, no setTimeout).
356
+ this.editor.addEventListener('mouseup', () => this._scheduleSelectionUpdate());
357
+
358
+ // Click inside the editor: keep a valid editable block.
359
+ this.editor.addEventListener('click', () => {
360
+ this.ensureEditorHasContent();
361
+ });
362
+
363
+ // Image context menu (right-click)
364
+ this.editor.addEventListener('contextmenu', (e) => {
365
+ // Image context menu functionality removed - methods don't exist
366
+ });
367
+
368
+ // Formatting keyboard shortcuts (Ctrl/Cmd + B/I/U, Ctrl/Cmd + K for link)
369
+ this.editor.addEventListener('keydown', (e) => {
370
+ if (!(e.ctrlKey || e.metaKey) || e.altKey) return;
371
+ const shortcuts = { b: 'bold', i: 'italic', u: 'underline', k: 'link' };
372
+ const command = shortcuts[e.key.toLowerCase()];
373
+ if (!command) return;
374
+ // Don't hijack shift-modified combos (e.g. Ctrl+Shift+...) except plain ones
375
+ if (e.shiftKey) return;
376
+ e.preventDefault();
377
+ this.toggleFormat(command);
378
+ });
379
+
380
+ // Handle keydown events to ensure content structure
381
+ this.editor.addEventListener('keydown', (e) => {
382
+ // Check for delete/backspace operations that might empty the editor
383
+ if (e.key === 'Delete' || e.key === 'Backspace') {
384
+ // Use setTimeout to check after the deletion occurs
385
+ setTimeout(() => {
386
+ this.ensureEditorHasContent();
387
+ this.updatePlaceholderVisibility();
388
+ }, 0);
389
+ }
390
+ });
391
+
392
+ // Handle paste events — sanitize pasted HTML (prevents XSS and strips
393
+ // messy markup from Word/Google Docs). Set options.pasteAsPlainText to
394
+ // always paste as plain text instead.
395
+ this.editor.addEventListener('paste', (e) => {
396
+ this.handlePaste(e);
397
+ });
398
+
399
+ // Allow dropping (needed for the drop event to fire with files)
400
+ this.editor.addEventListener('dragover', (e) => {
401
+ if (e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files')) {
402
+ e.preventDefault();
403
+ }
404
+ });
405
+
406
+ // Handle drop events (drag and drop) — insert dropped image files
407
+ this.editor.addEventListener('drop', (e) => {
408
+ const dt = e.dataTransfer;
409
+ const files = dt && dt.files ? Array.from(dt.files) : [];
410
+ const imageFile = files.find(f => f.type && f.type.startsWith('image/'));
411
+ if (imageFile) {
412
+ e.preventDefault();
413
+ this.placeCaretAtPoint(e.clientX, e.clientY);
414
+ this.insertImageFile(imageFile);
415
+ return;
416
+ }
417
+ // Check content after a normal drop operation
418
+ setTimeout(() => {
419
+ this.ensureEditorHasContent();
420
+ this.updatePlaceholderVisibility();
421
+ }, 0);
422
+ });
423
+
424
+ // Enforce character limit (maxLength) on insertion-type input
425
+ if (this.options.maxLength) {
426
+ this.editor.addEventListener('beforeinput', (e) => {
427
+ if (!e.inputType || !e.inputType.startsWith('insert')) return;
428
+ if (e.inputType === 'insertFromPaste') return; // handled in handlePaste
429
+ const incoming = e.data ? e.data.length : 1;
430
+ if (this._remainingChars() < incoming) {
431
+ e.preventDefault();
432
+ }
433
+ });
434
+ }
435
+
436
+ // Markdown shortcuts (# heading, - bullet, 1. ordered, > quote) on space
437
+ if (this.options.markdown !== false) {
438
+ this.editor.addEventListener('keydown', (e) => {
439
+ if (e.key === ' ' && !e.ctrlKey && !e.metaKey && !e.altKey) {
440
+ this.handleMarkdownShortcut(e);
441
+ }
442
+ });
443
+ }
444
+
445
+ // Handle cut events
446
+ this.editor.addEventListener('cut', () => {
447
+ // Check content after cut operation
448
+ setTimeout(() => {
449
+ this.ensureEditorHasContent();
450
+ this.updatePlaceholderVisibility();
451
+ }, 0);
452
+ });
453
+
454
+ // Focus editor on load
455
+ setTimeout(() => {
456
+ // Ensure editor has proper content structure on load
457
+ this.ensureEditorHasContent();
458
+ this.updatePlaceholderVisibility();
459
+ // Set the initial undo/redo dimmed state (nothing to undo yet).
460
+ this.updateHistoryButtons();
461
+ this.focus();
462
+ }, 100);
463
+
464
+ // Handle focus events to ensure content structure
465
+ this.editor.addEventListener('focus', () => {
466
+ // Ensure there's always a paragraph element for editing when focusing
467
+ setTimeout(() => {
468
+ this.ensureEditorHasContent();
469
+ this.updatePlaceholderVisibility();
470
+ }, 0);
471
+ });
472
+ }
473
+
474
+ /**
475
+ * Handle content changes
476
+ */
477
+ onContentChange() {
478
+ // Check if editor is empty and create a paragraph element if needed
479
+ this.ensureEditorHasContent();
480
+ this._emitChange();
481
+ }
482
+
483
+ /**
484
+ * Notify listeners of a content change WITHOUT running the empty-content
485
+ * reset (used when an intentionally-empty block — e.g. a fresh heading from
486
+ * a markdown shortcut — must not be wiped by ensureEditorHasContent).
487
+ */
488
+ _emitChange() {
489
+ this.modules.forEach(module => {
490
+ if (typeof module.onContentChange === 'function') {
491
+ module.onContentChange();
492
+ }
493
+ });
494
+
495
+ // Keep the status bar (word/char count + breadcrumb) in sync after every
496
+ // mutation, including programmatic ones (find/replace, undo/redo, toolbar).
497
+ this.updateStatusbar();
498
+
499
+ // Get current content
500
+ const content = this.getContent();
501
+
502
+ // Call onChange callback if provided
503
+ if (this.options.onChange && typeof this.options.onChange === 'function') {
504
+ this.options.onChange(content);
505
+ }
506
+
507
+ // Persist draft if autosave is enabled
508
+ this._scheduleAutosave(content);
509
+
510
+ // Emit change events ('change' is the documented name; 'text-change' kept
511
+ // for backward compatibility).
512
+ this.emit('change', content);
513
+ this.emit('text-change', content);
514
+ }
515
+
516
+ /**
517
+ * Ensure editor always has a paragraph element for editing
518
+ * This prevents users from editing directly in the editor container
519
+ */
520
+ ensureEditorHasContent() {
521
+ if (!this.isEditorEmpty()) return;
522
+
523
+ // Only act when the caret/selection is actually inside this editor — avoids
524
+ // clearing formats or stealing focus based on a selection elsewhere.
525
+ const selInEditor = this.isSelectionInEditableArea(window.getSelection());
526
+
527
+ // Rebuild to a clean paragraph when needed — this strips leftover empty
528
+ // formatting tags (e.g. <b><i><u>) that survive a "delete all".
529
+ if (this.editor.innerHTML !== '<p><br></p>') {
530
+ const paragraph = document.createElement('p');
531
+ paragraph.innerHTML = '<br>';
532
+ this.editor.innerHTML = '';
533
+ this.editor.appendChild(paragraph);
534
+ this.setCursorToElement(paragraph);
535
+ this.editor.focus();
536
+ }
537
+
538
+ // Clearing the DOM is not enough: browsers keep a "pending" inline-format
539
+ // state for the next typed character. Toggle off any active inline format
540
+ // so new text isn't unexpectedly bold/italic/underline/strikethrough.
541
+ if (selInEditor || document.activeElement === this.editor) {
542
+ this._clearStickyInlineFormats();
543
+ }
544
+
545
+ this.updateToolbarButtonStates();
546
+ this.updateStatusbar();
547
+ }
548
+
549
+ /**
550
+ * Turn off any active inline formatting command so the next typed character
551
+ * starts unformatted. Only affects a collapsed caret (no DOM mutation).
552
+ */
553
+ _clearStickyInlineFormats() {
554
+ ['bold', 'italic', 'underline', 'strikeThrough'].forEach((cmd) => {
555
+ if (queryFormatState(cmd)) execFormat(cmd);
556
+ });
557
+ }
558
+
559
+ /**
560
+ * Ensure there's always a paragraph element available for editing
561
+ * This prevents users from editing directly in the editor container
562
+ */
563
+ ensureParagraphForEditing() {
564
+ const children = this.editor.children;
565
+
566
+ // If editor has no children, create a paragraph
567
+ if (children.length === 0) {
568
+ const paragraph = document.createElement('p');
569
+ paragraph.innerHTML = '<br>';
570
+ this.editor.appendChild(paragraph);
571
+ this.setCursorToElement(paragraph);
572
+ return;
573
+ }
574
+
575
+ // Check if the last child is a block element that can contain text
576
+ const lastChild = children[children.length - 1];
577
+ const blockTags = ['P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'PRE', 'ARTICLE', 'SECTION', 'MAIN', 'ASIDE'];
578
+
579
+ // Only add paragraph if the last child is not a block element that can contain text
580
+ if (!blockTags.includes(lastChild.tagName)) {
581
+ // Add a paragraph element at the end for editing
582
+ const paragraph = document.createElement('p');
583
+ paragraph.innerHTML = '<br>';
584
+ this.editor.appendChild(paragraph);
585
+ }
586
+ }
587
+
588
+ /**
589
+ * Check if editor is empty or contains only empty elements
590
+ */
591
+ isEditorEmpty() {
592
+ // Real text content → not empty (ignore zero-width spaces).
593
+ const text = this.editor.textContent;
594
+ if (text && text.replace(/\u200B/g, '').trim() !== '') return false;
595
+
596
+ // Embedded/void media counts as content even with no text.
597
+ if (this.editor.querySelector('img, table, hr, video, iframe, audio, figure')) {
598
+ return false;
599
+ }
600
+
601
+ // Otherwise empty — including the case where only empty formatting tags
602
+ // remain (e.g. <p><b><i><u><br></u></i></b></p> after deleting everything).
603
+ return true;
604
+ }
605
+
606
+ /**
607
+ * Set cursor position to a specific element
608
+ */
609
+ setCursorToElement(element) {
610
+ const range = document.createRange();
611
+ const selection = window.getSelection();
612
+
613
+ // Try to set cursor at the beginning of the element
614
+ if (element.firstChild && element.firstChild.nodeType === Node.TEXT_NODE) {
615
+ range.setStart(element.firstChild, 0);
616
+ } else {
617
+ range.setStart(element, 0);
618
+ }
619
+
620
+ range.collapse(true);
621
+
622
+ selection.removeAllRanges();
623
+ selection.addRange(range);
624
+ }
625
+
626
+ /**
627
+ * Coalesce selection-driven UI updates to one run per animation frame.
628
+ */
629
+ _scheduleSelectionUpdate() {
630
+ if (this._selUpdateQueued) return;
631
+ this._selUpdateQueued = true;
632
+ requestAnimationFrame(() => {
633
+ this._selUpdateQueued = false;
634
+ this.onSelectionChange();
635
+ });
636
+ }
637
+
638
+ /**
639
+ * Handle selection changes
640
+ */
641
+ onSelectionChange() {
642
+ const selection = window.getSelection();
643
+ const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
644
+
645
+ // Check if selection is within rich-editor-area
646
+ const isInEditableArea = this.isSelectionInEditableArea(selection);
647
+
648
+ // If the selection is inside this editor, it is the active instance.
649
+ if (isInEditableArea) {
650
+ Editor.currentInstance = this;
651
+ // Remember the last real (non-collapsed) selection so popups (colour,
652
+ // etc.) can restore it even if a tap clears the live selection on mobile.
653
+ if (range && !range.collapsed) {
654
+ this._lastRange = range.cloneRange();
655
+ }
656
+ }
657
+
658
+ // Update all modules with selection info
659
+ this.modules.forEach(module => {
660
+ if (typeof module.onSelectionChange === 'function') {
661
+ module.onSelectionChange(range, isInEditableArea);
662
+ }
663
+ });
664
+
665
+ // Update toolbar button states
666
+ this.updateToolbarButtonStates();
667
+
668
+ // Update toolbar buttons accessibility
669
+ this.updateToolbarAccessibility(isInEditableArea);
670
+
671
+ // Update statusbar when selection changes
672
+ this.updateStatusbar();
673
+ }
674
+
675
+ /**
676
+ * Check if current selection is within the rich-editor-area
677
+ */
678
+ isSelectionInEditableArea(selection) {
679
+ if (!selection || selection.rangeCount === 0) {
680
+ return false;
681
+ }
682
+
683
+ const range = selection.getRangeAt(0);
684
+ const startContainer = range.startContainer;
685
+ const endContainer = range.endContainer;
686
+
687
+ // Check if both start and end containers are within rich-editor-area
688
+ const startInEditor = this.isNodeInEditableArea(startContainer);
689
+ const endInEditor = this.isNodeInEditableArea(endContainer);
690
+
691
+ return startInEditor && endInEditor;
692
+ }
693
+
694
+ /**
695
+ * Check if a node is within the rich-editor-area
696
+ */
697
+ isNodeInEditableArea(node) {
698
+ if (!node) return false;
699
+
700
+ // Traverse up the DOM tree to find rich-editor-area
701
+ let currentNode = node.nodeType === Node.TEXT_NODE ? node.parentNode : node;
702
+
703
+ while (currentNode && currentNode !== document.body) {
704
+ if (currentNode === this.editor ||
705
+ (currentNode.classList && currentNode.classList.contains('rich-editor-area'))) {
706
+ return true;
707
+ }
708
+ currentNode = currentNode.parentNode;
709
+ }
710
+
711
+ return false;
712
+ }
713
+
714
+ /**
715
+ * Update toolbar accessibility based on selection location
716
+ */
717
+ updateToolbarAccessibility(isInEditableArea) {
718
+ const toolbar = this.getModule('toolbar');
719
+ if (!toolbar) return;
720
+
721
+ // List of commands that should be disabled when outside editable area
722
+ // Note: undo/redo are NOT in this list - they should always work
723
+ const editingCommands = [
724
+ 'bold', 'italic', 'underline', 'strike', 'subscript', 'superscript',
725
+ 'color', 'background', 'link', 'table', 'heading',
726
+ 'font-family', 'line-height', 'capitalization', 'text-align', 'list',
727
+ 'indent-increase', 'indent-decrease', 'text-size'
728
+ ];
729
+
730
+ editingCommands.forEach(command => {
731
+ toolbar.setButtonDisabled(command, !isInEditableArea);
732
+ });
733
+
734
+ // These commands should always be enabled regardless of selection location
735
+ const alwaysEnabledCommands = ['more', 'undo', 'redo', 'code-view', 'theme'];
736
+ alwaysEnabledCommands.forEach(command => {
737
+ toolbar.setButtonDisabled(command, false);
738
+ });
739
+ }
740
+
741
+ /**
742
+ * Update statusbar - extracted from EditorCore
743
+ * TODO: Copy implementation from EditorCore.updateStatusbar()
744
+ */
745
+ updateStatusbar() {
746
+ if (!this.statusbar) return;
747
+
748
+ const sel = window.getSelection();
749
+ if (!sel) return;
750
+
751
+ // Update breadcrumb
752
+ if (this.statusbarEls.breadcrumb && this.options.features.breadcrumb) {
753
+ const currentNode = sel.anchorNode;
754
+ // Only reflect a selection that lives inside THIS editor. On a page with
755
+ // several editors the selection is global, so without this guard each
756
+ // statusbar would walk up to <body> and show another editor's path.
757
+ if (!currentNode || !this.editor.contains(currentNode)) {
758
+ this.statusbarEls.breadcrumb.textContent = 'editor';
759
+ } else {
760
+ const path = [];
761
+ let element = currentNode?.nodeType === 3 ? currentNode.parentElement : currentNode;
762
+
763
+ while (element && element !== this.editor && element !== document.body) {
764
+ if (element.tagName) {
765
+ let tagInfo = element.tagName.toLowerCase();
766
+ if (element.className && typeof element.className === 'string') {
767
+ const classes = element.className.trim();
768
+ if (classes) {
769
+ tagInfo += '.' + classes.split(' ').join('.');
770
+ }
771
+ }
772
+ if (element.id) {
773
+ tagInfo += '#' + element.id;
774
+ }
775
+ path.unshift(tagInfo);
776
+ }
777
+ element = element.parentElement;
778
+ }
779
+
780
+ this.statusbarEls.breadcrumb.textContent = path.length > 0 ? path.join(' > ') : 'editor';
781
+ }
782
+ }
783
+
784
+ // Update word count
785
+ if (this.statusbarEls.wordcount && this.options.features.wordCount) {
786
+ // While in HTML code-view, count the rendered text of the source being
787
+ // edited (not the stale visual content that's hidden behind it).
788
+ let text;
789
+ const codeView = this.getModule('code-view');
790
+ if (codeView && typeof codeView.isInCodeView === 'function' && codeView.isInCodeView()) {
791
+ const tmp = document.createElement('div');
792
+ tmp.innerHTML = codeView.getCurrentContent ? codeView.getCurrentContent() : '';
793
+ text = tmp.textContent || '';
794
+ } else {
795
+ text = this.editor.textContent || '';
796
+ }
797
+ const words = text.trim() ? text.trim().split(/\s+/).length : 0;
798
+ const chars = text.length;
799
+ const charsNoSpaces = text.replace(/\s/g, '').length;
800
+
801
+ let label = `${words} words, ${chars} chars (${charsNoSpaces} no spaces)`;
802
+ if (this.options.maxLength) {
803
+ label += ` • ${Math.max(0, this.options.maxLength - chars)} left`;
804
+ }
805
+ this.statusbarEls.wordcount.textContent = label;
806
+ }
807
+ }
808
+
809
+ /**
810
+ * Focus editor
811
+ */
812
+ focus() {
813
+ if (this.editor) {
814
+ this.editor.focus();
815
+ }
816
+ }
817
+
818
+ /**
819
+ * Get editor content
820
+ */
821
+ getContent() {
822
+ return this.editor.innerHTML;
823
+ }
824
+
825
+ /**
826
+ * Set editor content
827
+ */
828
+ setContent(html) {
829
+ // Wrap plain text content in paragraph tag if needed
830
+ const processedContent = this.wrapTextInParagraph(html);
831
+ this.editor.innerHTML = processedContent;
832
+ this.onContentChange();
833
+ }
834
+
835
+ /* ----- Export / import in common formats (HTML · JSON · Markdown) ----- */
836
+
837
+ /** HTML string (alias of getContent). */
838
+ getHTML() { return this.getContent(); }
839
+ /** Set content from an HTML string (sanitised). */
840
+ setHTML(html) { this.setContent(sanitizeHtml(html || '')); }
841
+
842
+ /** Structured JSON document `{ type:'doc', content:[…] }`. */
843
+ getJSON() { return domToJson(this.getContent()); }
844
+ /** Set content from a JSON document (produced by getJSON). */
845
+ setJSON(json) { this.setContent(sanitizeHtml(jsonToHtml(json))); }
846
+
847
+ /** Markdown string. */
848
+ getMarkdown() { return htmlToMarkdown(this.getContent()); }
849
+ /** Set content from a Markdown string. */
850
+ setMarkdown(md) { this.setContent(sanitizeHtml(markdownToHtml(md || ''))); }
851
+
852
+ /**
853
+ * Get the plain text content of the editor (no markup).
854
+ * @returns {string}
855
+ */
856
+ getText() {
857
+ return this.editor.textContent || '';
858
+ }
859
+
860
+ /**
861
+ * Whether the editor has no meaningful content.
862
+ * @returns {boolean}
863
+ */
864
+ isEmpty() {
865
+ return this.isEditorEmpty();
866
+ }
867
+
868
+ /**
869
+ * Clear all content, leaving an empty paragraph.
870
+ */
871
+ clear() {
872
+ this.editor.innerHTML = '<p><br></p>';
873
+ this.onContentChange();
874
+ this.updatePlaceholderVisibility();
875
+ }
876
+
877
+ /**
878
+ * Insert plain text at the current caret position.
879
+ * @param {string} text
880
+ */
881
+ insertText(text) {
882
+ if (typeof text !== 'string') return;
883
+ this.focus();
884
+ execFormat('insertText', text);
885
+ this.onContentChange();
886
+ }
887
+
888
+ /**
889
+ * Insert HTML at the current caret position (sanitized to prevent XSS).
890
+ * @param {string} html
891
+ */
892
+ insertHTML(html) {
893
+ if (typeof html !== 'string') return;
894
+ this.focus();
895
+ execFormat('insertHTML', sanitizeHtml(html));
896
+ this.onContentChange();
897
+ }
898
+
899
+ /**
900
+ * Handle a paste event: sanitize pasted HTML, or paste as plain text.
901
+ * @param {ClipboardEvent} e
902
+ */
903
+ handlePaste(e) {
904
+ const clipboard = e.clipboardData || window.clipboardData;
905
+ if (!clipboard) return; // let the browser handle it
906
+
907
+ // Pasted image file (screenshot, copied image) → insert as image.
908
+ const items = clipboard.items ? Array.from(clipboard.items) : [];
909
+ const imageItem = items.find(it => it.kind === 'file' && it.type && it.type.startsWith('image/'));
910
+ if (imageItem) {
911
+ const file = imageItem.getAsFile();
912
+ if (file) {
913
+ e.preventDefault();
914
+ this.insertImageFile(file);
915
+ return;
916
+ }
917
+ }
918
+
919
+ let html = clipboard.getData('text/html');
920
+ let text = clipboard.getData('text/plain');
921
+
922
+ // Nothing useful to insert ourselves → fall back to default behavior
923
+ if (!html && !text) return;
924
+
925
+ e.preventDefault();
926
+
927
+ // Enforce character limit on paste (force plain text trimmed to remaining)
928
+ if (this.options.maxLength) {
929
+ const remaining = this._remainingChars();
930
+ if (remaining <= 0) return;
931
+ const plain = (text || '').slice(0, remaining);
932
+ execFormat('insertText', plain);
933
+ } else if (!this.options.pasteAsPlainText && html) {
934
+ execFormat('insertHTML', sanitizeHtml(html));
935
+ } else if (text) {
936
+ execFormat('insertText', text);
937
+ }
938
+
939
+ setTimeout(() => {
940
+ this.ensureEditorHasContent();
941
+ this.updatePlaceholderVisibility();
942
+ this.onContentChange();
943
+ }, 0);
944
+ }
945
+
946
+ /**
947
+ * Number of characters that can still be added before hitting maxLength
948
+ * (accounts for the current selection being replaced). Infinity if no limit.
949
+ */
950
+ _remainingChars() {
951
+ if (!this.options.maxLength) return Infinity;
952
+ const sel = window.getSelection();
953
+ const selLen = sel && !sel.isCollapsed ? sel.toString().length : 0;
954
+ return this.options.maxLength - (this.getText().length - selLen);
955
+ }
956
+
957
+ /**
958
+ * Read an image File and insert it (as a base64 data URL) at the caret.
959
+ * @param {File} file
960
+ */
961
+ insertImageFile(file) {
962
+ if (!file || !file.type || !file.type.startsWith('image/')) return;
963
+
964
+ const cfg = this.options.image || {};
965
+ // Validate against optional accept / maxSize before doing anything.
966
+ if (cfg.accept && cfg.accept !== 'image/*') {
967
+ const ok = cfg.accept.split(',').some(a => {
968
+ a = a.trim();
969
+ return a.endsWith('/*') ? file.type.startsWith(a.slice(0, -1)) : file.type === a;
970
+ });
971
+ if (!ok) { this.emit('image:error', { file, reason: 'type' }); return; }
972
+ }
973
+ if (cfg.maxSize && file.size > cfg.maxSize) {
974
+ this.emit('image:error', { file, reason: 'size' });
975
+ return;
976
+ }
977
+
978
+ // Build a real <img> from a src (validates via the Image format).
979
+ const makeImg = (src, extra = '') => {
980
+ const ImageClass = this.registry.get('formats/image');
981
+ if (ImageClass && typeof ImageClass.create === 'function') {
982
+ const img = ImageClass.create(src);
983
+ if (!img) return null;
984
+ if (extra) img.setAttribute('data-state', extra);
985
+ return img;
986
+ }
987
+ return null;
988
+ };
989
+
990
+ // No upload hook → embed as base64 (backward-compatible default).
991
+ if (typeof cfg.upload !== 'function') {
992
+ const reader = new FileReader();
993
+ reader.onload = (ev) => {
994
+ const html = (makeImg(ev.target.result) || {}).outerHTML
995
+ || `<img src="${ev.target.result}" class="inserted-image" style="max-width:100%" contenteditable="false">`;
996
+ this.focus();
997
+ execFormat('insertHTML', html);
998
+ this.onContentChange();
999
+ };
1000
+ reader.readAsDataURL(file);
1001
+ return;
1002
+ }
1003
+
1004
+ // Upload hook: insert a placeholder, await the URL, then swap the src.
1005
+ const placeholderId = 'rte-up-' + Math.round(performance.now()) + '-' + (this._upCounter = (this._upCounter || 0) + 1);
1006
+ const ph = makeImg('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27120%27 height=%2790%27%3E%3Crect width=%27100%25%27 height=%27100%25%27 fill=%27%23efedff%27/%3E%3C/svg%3E', 'uploading');
1007
+ if (!ph) return;
1008
+ ph.id = placeholderId;
1009
+ ph.style.opacity = '0.6';
1010
+ this.focus();
1011
+ execFormat('insertHTML', ph.outerHTML);
1012
+ this.emit('image:upload', { file });
1013
+
1014
+ Promise.resolve(cfg.upload(file)).then((url) => {
1015
+ const el = this.editor.querySelector('#' + placeholderId);
1016
+ if (!el) return;
1017
+ if (url) {
1018
+ el.src = url;
1019
+ el.style.opacity = '';
1020
+ el.removeAttribute('data-state');
1021
+ el.removeAttribute('id');
1022
+ this.emit('image:uploaded', { file, url });
1023
+ } else {
1024
+ el.remove();
1025
+ }
1026
+ this.onContentChange();
1027
+ }).catch((err) => {
1028
+ const el = this.editor.querySelector('#' + placeholderId);
1029
+ if (el) el.remove();
1030
+ this.emit('image:error', { file, reason: 'upload', error: err });
1031
+ this.onContentChange();
1032
+ });
1033
+ }
1034
+
1035
+ /**
1036
+ * Place the caret at the given viewport coordinates (used for drag-drop).
1037
+ */
1038
+ placeCaretAtPoint(x, y) {
1039
+ let range = null;
1040
+ if (document.caretRangeFromPoint) {
1041
+ range = document.caretRangeFromPoint(x, y);
1042
+ } else if (document.caretPositionFromPoint) {
1043
+ const pos = document.caretPositionFromPoint(x, y);
1044
+ if (pos) {
1045
+ range = document.createRange();
1046
+ range.setStart(pos.offsetNode, pos.offset);
1047
+ }
1048
+ }
1049
+ if (range) {
1050
+ range.collapse(true);
1051
+ const sel = window.getSelection();
1052
+ sel.removeAllRanges();
1053
+ sel.addRange(range);
1054
+ }
1055
+ }
1056
+
1057
+ /**
1058
+ * Transform a markdown marker at the start of the current block when space
1059
+ * is pressed: "# " → H1..H6, "- "/"* " → bullet list, "1. " → ordered list,
1060
+ * "> " → blockquote.
1061
+ * @param {KeyboardEvent} e
1062
+ */
1063
+ handleMarkdownShortcut(e) {
1064
+ const sel = window.getSelection();
1065
+ if (!sel || !sel.isCollapsed || !sel.rangeCount) return;
1066
+ const range = sel.getRangeAt(0);
1067
+
1068
+ // Find the nearest plain block (P/DIV) containing the caret.
1069
+ let block = range.startContainer;
1070
+ block = block.nodeType === Node.TEXT_NODE ? block.parentElement : block;
1071
+ while (block && block !== this.editor && block.tagName !== 'P' && block.tagName !== 'DIV') {
1072
+ block = block.parentElement;
1073
+ }
1074
+ if (!block || block === this.editor) return;
1075
+
1076
+ // Text from block start to the caret = the marker the user typed.
1077
+ const pre = document.createRange();
1078
+ pre.selectNodeContents(block);
1079
+ pre.setEnd(range.startContainer, range.startOffset);
1080
+ const marker = pre.toString();
1081
+
1082
+ const blockMap = { '#': 'h1', '##': 'h2', '###': 'h3', '####': 'h4', '#####': 'h5', '######': 'h6', '>': 'blockquote' };
1083
+ const blockTag = blockMap[marker];
1084
+ const listType = (marker === '-' || marker === '*') ? 'ul'
1085
+ : /^\d+\.$/.test(marker) ? 'ol' : null;
1086
+ if (!blockTag && !listType) return;
1087
+
1088
+ e.preventDefault();
1089
+
1090
+ const history = this.getModule('history');
1091
+ if (history && typeof history.saveBeforeFormat === 'function') history.saveBeforeFormat();
1092
+
1093
+ // Remove the marker text the user typed.
1094
+ pre.deleteContents();
1095
+
1096
+ if (blockTag) {
1097
+ // Replace the block element directly (execCommand formatBlock is
1098
+ // unreliable on a now-empty block).
1099
+ const el = document.createElement(blockTag);
1100
+ while (block.firstChild) el.appendChild(block.firstChild);
1101
+ // Ensure a <br> placeholder so the empty block stays focusable/visible
1102
+ if (el.textContent === '' && !el.querySelector('*')) {
1103
+ el.innerHTML = '<br>';
1104
+ }
1105
+ block.replaceWith(el);
1106
+ const caret = document.createRange();
1107
+ caret.selectNodeContents(el);
1108
+ caret.collapse(true);
1109
+ sel.removeAllRanges();
1110
+ sel.addRange(caret);
1111
+ } else {
1112
+ const caret = document.createRange();
1113
+ caret.selectNodeContents(block);
1114
+ caret.collapse(true);
1115
+ sel.removeAllRanges();
1116
+ sel.addRange(caret);
1117
+ execFormat(listType === 'ul' ? 'insertUnorderedList' : 'insertOrderedList');
1118
+ }
1119
+ // Use _emitChange (not onContentChange) so a fresh empty heading/quote
1120
+ // isn't wiped by the empty-content reset.
1121
+ this._emitChange();
1122
+ this.updatePlaceholderVisibility();
1123
+ }
1124
+
1125
+ /**
1126
+ * Set text direction ('ltr' | 'rtl').
1127
+ */
1128
+ setDirection(dir) {
1129
+ const d = dir === 'rtl' ? 'rtl' : 'ltr';
1130
+ this.editor.setAttribute('dir', d);
1131
+ const toolbar = this.getModule('toolbar');
1132
+ if (toolbar) toolbar.setButtonActive('text-direction', d === 'rtl');
1133
+ }
1134
+
1135
+ /**
1136
+ * @returns {'ltr'|'rtl'} Current text direction.
1137
+ */
1138
+ getDirection() {
1139
+ return this.editor.getAttribute('dir') === 'rtl' ? 'rtl' : 'ltr';
1140
+ }
1141
+
1142
+ /**
1143
+ * Toggle between LTR and RTL.
1144
+ */
1145
+ toggleDirection() {
1146
+ this.setDirection(this.getDirection() === 'rtl' ? 'ltr' : 'rtl');
1147
+ }
1148
+
1149
+ /**
1150
+ * Normalized autosave config ({ key, debounce }) or null when disabled.
1151
+ */
1152
+ _autosaveCfg() {
1153
+ if (this._autosaveMemo !== undefined) return this._autosaveMemo;
1154
+ const a = this.options.autosave;
1155
+ if (!a) { this._autosaveMemo = null; return null; }
1156
+ this._autosaveMemo = {
1157
+ key: (typeof a === 'object' && a.key) ? a.key : 'yjd-autosave',
1158
+ debounce: (typeof a === 'object' && a.debounce) ? a.debounce : 1000
1159
+ };
1160
+ return this._autosaveMemo;
1161
+ }
1162
+
1163
+ /** Read previously autosaved content (or null). */
1164
+ _getAutosaved() {
1165
+ const cfg = this._autosaveCfg();
1166
+ if (!cfg) return null;
1167
+ try { return localStorage.getItem(cfg.key); } catch (e) { return null; }
1168
+ }
1169
+
1170
+ /** Debounced write of content to localStorage. */
1171
+ _scheduleAutosave(content) {
1172
+ const cfg = this._autosaveCfg();
1173
+ if (!cfg) return;
1174
+ clearTimeout(this._autosaveTimer);
1175
+ this._autosaveTimer = setTimeout(() => {
1176
+ try { localStorage.setItem(cfg.key, content); } catch (e) { /* storage unavailable */ }
1177
+ }, cfg.debounce);
1178
+ }
1179
+
1180
+ /** Remove the autosaved draft from storage. */
1181
+ clearAutosave() {
1182
+ const cfg = this._autosaveCfg();
1183
+ if (!cfg) return;
1184
+ try { localStorage.removeItem(cfg.key); } catch (e) { /* ignore */ }
1185
+ }
1186
+
1187
+ /**
1188
+ * Remove inline formatting (and links) from the current selection.
1189
+ */
1190
+ clearFormatting() {
1191
+ const historyModule = this.getModule('history');
1192
+ if (historyModule && typeof historyModule.saveBeforeFormat === 'function') {
1193
+ historyModule.saveBeforeFormat();
1194
+ }
1195
+ this.focus();
1196
+ execFormat('removeFormat');
1197
+ execFormat('unlink');
1198
+ this.onContentChange();
1199
+ this.updateToolbarButtonStates();
1200
+ }
1201
+
1202
+ /**
1203
+ * Convert the current block to a given type.
1204
+ * @param {('p'|'h1'|'h2'|'h3'|'h4'|'h5'|'h6'|'blockquote'|'pre'|'ul'|'ol')} type
1205
+ */
1206
+ setBlockType(type) {
1207
+ const sel = window.getSelection();
1208
+ if (!sel || !sel.rangeCount) return;
1209
+ let block = sel.getRangeAt(0).startContainer;
1210
+ block = block.nodeType === Node.TEXT_NODE ? block.parentElement : block;
1211
+ const BLOCK = /^(P|DIV|H[1-6]|BLOCKQUOTE|PRE|LI)$/;
1212
+ while (block && block !== this.editor && !BLOCK.test(block.tagName)) {
1213
+ block = block.parentElement;
1214
+ }
1215
+ if (!block || block === this.editor) return;
1216
+
1217
+ const history = this.getModule('history');
1218
+ if (history && typeof history.saveBeforeFormat === 'function') history.saveBeforeFormat();
1219
+
1220
+ if (type === 'ul' || type === 'ol') {
1221
+ const r = document.createRange();
1222
+ r.selectNodeContents(block);
1223
+ r.collapse(true);
1224
+ sel.removeAllRanges();
1225
+ sel.addRange(r);
1226
+ execFormat(type === 'ul' ? 'insertUnorderedList' : 'insertOrderedList');
1227
+ } else {
1228
+ const el = document.createElement(type);
1229
+ while (block.firstChild) el.appendChild(block.firstChild);
1230
+ if (el.textContent === '' && !el.querySelector('*')) el.innerHTML = '<br>';
1231
+ block.replaceWith(el);
1232
+ const r = document.createRange();
1233
+ r.selectNodeContents(el);
1234
+ r.collapse(false);
1235
+ sel.removeAllRanges();
1236
+ sel.addRange(r);
1237
+ }
1238
+ this._emitChange();
1239
+ this.updatePlaceholderVisibility();
1240
+ }
1241
+
1242
+ /**
1243
+ * Insert a horizontal rule at the current caret position.
1244
+ */
1245
+ insertHorizontalRule() {
1246
+ const historyModule = this.getModule('history');
1247
+ if (historyModule && typeof historyModule.saveBeforeFormat === 'function') {
1248
+ historyModule.saveBeforeFormat();
1249
+ }
1250
+ this.focus();
1251
+ execFormat('insertHorizontalRule');
1252
+ this.onContentChange();
1253
+ }
1254
+
1255
+ /**
1256
+ * Whether a block element has no real (text/media) content.
1257
+ */
1258
+ _isBlockEmpty(el) {
1259
+ if (!el) return true;
1260
+ if (el.querySelector && el.querySelector('img, table, hr, video, iframe, audio, figure')) {
1261
+ return false;
1262
+ }
1263
+ return (el.textContent || '').replace(/\u200B/g, '').trim() === '';
1264
+ }
1265
+
1266
+ /**
1267
+ * Insert a block-level element at the editor's top level, next to the block
1268
+ * containing the caret — never nested inside a heading or inline formatting
1269
+ * tag (which would be invalid HTML). Removes the source block if it became
1270
+ * empty, and guarantees an editable paragraph after the inserted block.
1271
+ * @param {HTMLElement} blockEl
1272
+ */
1273
+ insertBlock(blockEl) {
1274
+ const sel = window.getSelection();
1275
+ let topBlock = null;
1276
+ if (sel && sel.rangeCount) {
1277
+ const range = sel.getRangeAt(0);
1278
+ if (!range.collapsed) range.deleteContents();
1279
+ let node = range.startContainer;
1280
+ node = node.nodeType === Node.TEXT_NODE ? node.parentNode : node;
1281
+ while (node && node !== this.editor && node.parentNode !== this.editor) {
1282
+ node = node.parentNode;
1283
+ }
1284
+ if (node && node.parentNode === this.editor) topBlock = node;
1285
+ }
1286
+
1287
+ if (topBlock) {
1288
+ const wasEmpty = this._isBlockEmpty(topBlock);
1289
+ if (topBlock.nextSibling) {
1290
+ this.editor.insertBefore(blockEl, topBlock.nextSibling);
1291
+ } else {
1292
+ this.editor.appendChild(blockEl);
1293
+ }
1294
+ // Remove the originating block if it held only the caret / empty format tags
1295
+ if (wasEmpty) topBlock.remove();
1296
+ } else {
1297
+ this.editor.appendChild(blockEl);
1298
+ }
1299
+
1300
+ // Guarantee an editable paragraph after the inserted block
1301
+ if (!blockEl.nextSibling) {
1302
+ const p = document.createElement('p');
1303
+ p.innerHTML = '<br>';
1304
+ this.editor.appendChild(p);
1305
+ }
1306
+ }
1307
+
1308
+ /**
1309
+ * Enable/disable read-only mode.
1310
+ * @param {boolean} readOnly
1311
+ */
1312
+ setReadOnly(readOnly) {
1313
+ this._readOnly = !!readOnly;
1314
+ this.editor.contentEditable = this._readOnly ? 'false' : 'true';
1315
+ this.editor.setAttribute('aria-readonly', this._readOnly ? 'true' : 'false');
1316
+ this.wrapper.classList.toggle('read-only', this._readOnly);
1317
+
1318
+ // Disable/enable toolbar interaction
1319
+ const toolbar = this.getModule('toolbar');
1320
+ if (toolbar && toolbar.buttons) {
1321
+ toolbar.buttons.forEach((_, command) => {
1322
+ toolbar.setButtonDisabled(command, this._readOnly);
1323
+ });
1324
+ }
1325
+ }
1326
+
1327
+ /**
1328
+ * @returns {boolean} Whether the editor is in read-only mode.
1329
+ */
1330
+ isReadOnly() {
1331
+ return !!this._readOnly;
1332
+ }
1333
+
1334
+ /**
1335
+ * Get module instance
1336
+ */
1337
+ getModule(name) {
1338
+ return this.modules.get(name);
1339
+ }
1340
+
1341
+ /**
1342
+ * Get format class
1343
+ */
1344
+ getFormat(name) {
1345
+ return this.formats.get(name);
1346
+ }
1347
+
1348
+ /**
1349
+ * Register new items
1350
+ */
1351
+ register(path, definition, suppressWarning = false) {
1352
+ this.registry.register(path, definition, suppressWarning);
1353
+ }
1354
+
1355
+ /**
1356
+ * Handle toolbar button clicks
1357
+ */
1358
+ handleToolbarClick(data) {
1359
+ const { command, button, value } = data;
1360
+
1361
+ // Set this editor as current instance for the duration of this command
1362
+ const originalCurrent = Editor.currentInstance;
1363
+ Editor.currentInstance = this;
1364
+
1365
+ // Emit toolbar-click event for modules to listen
1366
+ this.emit('toolbar-click', data);
1367
+
1368
+ // Commands that should always work regardless of selection location
1369
+ const alwaysAllowedCommands = ['more', 'undo', 'redo', 'code-view', 'theme', 'text-direction', 'find'];
1370
+
1371
+ if (alwaysAllowedCommands.includes(command)) {
1372
+ // These commands can execute regardless of selection location
1373
+ switch (command) {
1374
+ case 'more':
1375
+ // More command is handled by toolbar module itself
1376
+ return;
1377
+ case 'undo':
1378
+ this.undo();
1379
+ return;
1380
+ case 'redo':
1381
+ this.redo();
1382
+ return;
1383
+ case 'code-view':
1384
+ // Code view command is handled by CodeView module itself
1385
+ // The module listens to 'toolbar-click' events and handles it internally
1386
+ return;
1387
+ case 'text-direction':
1388
+ this.toggleDirection();
1389
+ return;
1390
+ case 'find':
1391
+ // Find/replace module listens to 'toolbar-click' and opens its panel
1392
+ return;
1393
+ }
1394
+ }
1395
+
1396
+ // For all other commands, check if current selection is in editable area
1397
+ const selection = window.getSelection();
1398
+ const isInEditableArea = this.isSelectionInEditableArea(selection);
1399
+
1400
+ if (!isInEditableArea) {
1401
+ console.warn(`Command '${command}' blocked: Selection outside editable area`);
1402
+ return;
1403
+ }
1404
+
1405
+ // Handle formatting commands (only when selection is in editable area)
1406
+ switch (command) {
1407
+ case 'bold':
1408
+ case 'italic':
1409
+ case 'underline':
1410
+ case 'strike':
1411
+ case 'subscript':
1412
+ case 'superscript':
1413
+ case 'color':
1414
+ case 'background':
1415
+ case 'link':
1416
+ case 'table':
1417
+ case 'heading':
1418
+ case 'font-family':
1419
+ case 'line-height':
1420
+ case 'capitalization':
1421
+ case 'text-align':
1422
+ case 'text-size':
1423
+ case 'list':
1424
+ case 'indent-increase':
1425
+ case 'indent-decrease':
1426
+ case 'emoji':
1427
+ case 'image':
1428
+ case 'video':
1429
+ case 'tag':
1430
+
1431
+ case 'import':
1432
+ this.toggleFormat(command);
1433
+ break;
1434
+ case 'clear-format':
1435
+ this.clearFormatting();
1436
+ break;
1437
+ case 'horizontal-rule':
1438
+ this.insertHorizontalRule();
1439
+ break;
1440
+ default:
1441
+ console.warn(`Unknown command: ${command}`);
1442
+ }
1443
+ }
1444
+
1445
+ /**
1446
+ * Toggle format on current selection
1447
+ */
1448
+ toggleFormat(formatName) {
1449
+ // Save state before applying format
1450
+ const historyModule = this.getModule('history');
1451
+ if (historyModule && typeof historyModule.saveBeforeFormat === 'function') {
1452
+ historyModule.saveBeforeFormat();
1453
+ }
1454
+
1455
+ // Map format names to registry keys
1456
+ const formatMap = {
1457
+ 'bold': 'bold',
1458
+ 'italic': 'italic',
1459
+ 'underline': 'underline',
1460
+ 'strike': 'strike',
1461
+ 'subscript': 'subscript',
1462
+ 'superscript': 'superscript',
1463
+ 'color': 'color',
1464
+ 'background': 'background',
1465
+ 'link': 'link',
1466
+ 'table': 'table',
1467
+ 'heading': 'heading',
1468
+ 'font-family': 'font-family',
1469
+ 'line-height': 'line-height',
1470
+ 'capitalization': 'capitalization',
1471
+ 'text-align': 'text-align',
1472
+ 'text-size': 'text-size',
1473
+ 'list': 'list',
1474
+ 'indent-increase': 'indent-increase',
1475
+ 'indent-decrease': 'indent-decrease',
1476
+ 'emoji': 'emoji',
1477
+ 'image': 'image',
1478
+ 'video': 'video',
1479
+ 'tag': 'tag',
1480
+
1481
+ 'import': 'import'
1482
+ };
1483
+
1484
+ const registryKey = formatMap[formatName];
1485
+ if (!registryKey) {
1486
+ console.warn(`Unknown format: ${formatName}`);
1487
+ return;
1488
+ }
1489
+
1490
+ const FormatClass = this.registry.get(`formats/${registryKey}`);
1491
+ if (!FormatClass) {
1492
+ return;
1493
+ }
1494
+
1495
+ // Create format instance and toggle
1496
+ const formatInstance = new FormatClass();
1497
+ formatInstance.toggle();
1498
+
1499
+ // Update button state
1500
+ this.updateToolbarButtonStates();
1501
+
1502
+ // Trigger content change for formats that modify content immediately
1503
+ // (like bold, italic, underline, etc. that use execCommand)
1504
+ const immediateFormats = ['bold', 'italic', 'underline', 'strike', 'subscript', 'superscript'];
1505
+ if (immediateFormats.includes(formatName)) {
1506
+ // Use setTimeout to ensure DOM changes are complete
1507
+ setTimeout(() => {
1508
+ this.onContentChange();
1509
+ }, 0);
1510
+ }
1511
+ }
1512
+
1513
+ /**
1514
+ * Get a cached format instance for state checks (created once per editor).
1515
+ */
1516
+ _getFormatInstance(formatName) {
1517
+ if (!this._fmtCache) this._fmtCache = new Map();
1518
+ if (this._fmtCache.has(formatName)) return this._fmtCache.get(formatName);
1519
+ const FormatClass = this.registry.get(`formats/${formatName}`);
1520
+ if (!FormatClass) return null;
1521
+ let inst;
1522
+ if (FormatClass.createForEditor) {
1523
+ inst = FormatClass.createForEditor(this.instanceId);
1524
+ } else {
1525
+ const original = Editor.currentInstance;
1526
+ Editor.currentInstance = this;
1527
+ inst = new FormatClass();
1528
+ Editor.currentInstance = original;
1529
+ }
1530
+ this._fmtCache.set(formatName, inst);
1531
+ return inst;
1532
+ }
1533
+
1534
+ /**
1535
+ * Update toolbar button states based on current selection
1536
+ */
1537
+ updateToolbarButtonStates() {
1538
+ const toolbar = this.getModule('toolbar');
1539
+ if (!toolbar) return;
1540
+
1541
+ const selection = window.getSelection();
1542
+ if (!selection || !selection.rangeCount) return;
1543
+
1544
+ // Check if selection is in editable area
1545
+ const isInEditableArea = this.isSelectionInEditableArea(selection);
1546
+
1547
+ const formats = ['heading', 'font-family', 'line-height', 'capitalization', 'text-align', 'list', 'indent-increase', 'indent-decrease', 'bold', 'italic', 'underline', 'strike', 'subscript', 'superscript', 'color', 'background', 'link', 'table', 'text-size'];
1548
+
1549
+ formats.forEach(formatName => {
1550
+ // Only check format state if selection is in editable area
1551
+ if (isInEditableArea) {
1552
+ // Reuse a cached instance per format (was creating 19 instances on
1553
+ // every caret move — wasteful garbage). isActive() reads live
1554
+ // selection/DOM, so a cached instance is safe.
1555
+ const formatInstance = this._getFormatInstance(formatName);
1556
+ if (formatInstance) {
1557
+ toolbar.setButtonActive(formatName, formatInstance.isActive());
1558
+ if (formatName === 'line-height' && typeof formatInstance.updateButtonText === 'function') {
1559
+ formatInstance.updateButtonText();
1560
+ }
1561
+ }
1562
+ } else {
1563
+ // Clear active state for buttons when outside editable area
1564
+ toolbar.setButtonActive(formatName, false);
1565
+ }
1566
+ });
1567
+
1568
+ // Special handling for text-size: always update button text to show current size
1569
+ if (isInEditableArea) {
1570
+ const TextSizeClass = this.registry.get('formats/text-size');
1571
+ if (TextSizeClass && typeof TextSizeClass.updateButtonTextStatic === 'function') {
1572
+ TextSizeClass.updateButtonTextStatic(this.instanceId);
1573
+ }
1574
+ }
1575
+
1576
+ this.updateColorSwatches();
1577
+ this.updateHistoryButtons();
1578
+ }
1579
+
1580
+ /**
1581
+ * Undo/redo are always visible (no show/hide flicker); they're just dimmed
1582
+ * and disabled when there's nothing to act on.
1583
+ */
1584
+ updateHistoryButtons() {
1585
+ const toolbar = this.getModule('toolbar');
1586
+ if (!toolbar || typeof toolbar.getButton !== 'function') return;
1587
+ const history = this.getModule('history');
1588
+ const canUndo = !!(history && typeof history.canUndo === 'function' && history.canUndo());
1589
+ const canRedo = !!(history && typeof history.canRedo === 'function' && history.canRedo());
1590
+ const setState = (btn, enabled) => {
1591
+ if (!btn) return;
1592
+ btn.classList.remove('rte-hidden'); // always shown now
1593
+ btn.disabled = !enabled;
1594
+ btn.classList.toggle('is-disabled', !enabled);
1595
+ };
1596
+ setState(toolbar.getButton('undo'), canUndo);
1597
+ setState(toolbar.getButton('redo'), canRedo);
1598
+ }
1599
+
1600
+ /**
1601
+ * Reflect the colour at the caret on the toolbar's colour/background swatch
1602
+ * bars. Falls back to the CSS default (via clearing the inline style) when no
1603
+ * explicit colour is applied.
1604
+ */
1605
+ updateColorSwatches() {
1606
+ const toolbar = this.getModule('toolbar');
1607
+ if (!toolbar || typeof toolbar.getButton !== 'function') return;
1608
+
1609
+ const apply = (name, color) => {
1610
+ const btn = toolbar.getButton(name);
1611
+ if (!btn) return;
1612
+ const swatch = btn.querySelector('.rte-swatch');
1613
+ if (!swatch) return;
1614
+ if (color) {
1615
+ swatch.style.background = color;
1616
+ btn.classList.add('has-color');
1617
+ } else {
1618
+ swatch.style.removeProperty('background');
1619
+ btn.classList.remove('has-color');
1620
+ }
1621
+ };
1622
+
1623
+ const ColorClass = this.registry.get('formats/color');
1624
+ if (ColorClass && typeof ColorClass.getCurrentColor === 'function') {
1625
+ apply('color', ColorClass.getCurrentColor());
1626
+ }
1627
+ const BgClass = this.registry.get('formats/background');
1628
+ if (BgClass && typeof BgClass.getCurrentColor === 'function') {
1629
+ apply('background', BgClass.getCurrentColor());
1630
+ }
1631
+ }
1632
+
1633
+ /**
1634
+ * Undo last action
1635
+ */
1636
+ undo() {
1637
+ const history = this.getModule('history');
1638
+ if (history && typeof history.undo === 'function') {
1639
+ history.undo();
1640
+ } else {
1641
+ execFormat('undo');
1642
+ }
1643
+ }
1644
+
1645
+ /**
1646
+ * Redo last undone action
1647
+ */
1648
+ redo() {
1649
+ const history = this.getModule('history');
1650
+ if (history && typeof history.redo === 'function') {
1651
+ history.redo();
1652
+ } else {
1653
+ execFormat('redo');
1654
+ }
1655
+ }
1656
+
1657
+ /**
1658
+ * Add event listener
1659
+ * @param {string} event - Event name
1660
+ * @param {function} handler - Event handler
1661
+ */
1662
+ on(event, handler) {
1663
+ if (!this.events.has(event)) {
1664
+ this.events.set(event, []);
1665
+ }
1666
+ this.events.get(event).push(handler);
1667
+ }
1668
+
1669
+ /**
1670
+ * Remove event listener
1671
+ * @param {string} event - Event name
1672
+ * @param {function} handler - Event handler
1673
+ */
1674
+ off(event, handler) {
1675
+ if (this.events.has(event)) {
1676
+ const handlers = this.events.get(event);
1677
+ const index = handlers.indexOf(handler);
1678
+ if (index > -1) {
1679
+ handlers.splice(index, 1);
1680
+ }
1681
+ }
1682
+ }
1683
+
1684
+ /**
1685
+ * Emit event
1686
+ * @param {string} event - Event name
1687
+ * @param {*} data - Event data
1688
+ */
1689
+ emit(event, data) {
1690
+ if (this.events.has(event)) {
1691
+ this.events.get(event).forEach(handler => {
1692
+ try {
1693
+ handler(data);
1694
+ } catch (error) {
1695
+ console.error(`Error in event handler for ${event}:`, error);
1696
+ }
1697
+ });
1698
+ }
1699
+ }
1700
+
1701
+ /**
1702
+ * Prevent focus loss when clicking on UI elements
1703
+ * @param {HTMLElement} element - Element to attach listener to
1704
+ * @param {string} allowedSelector - CSS selector for elements that should allow normal click behavior
1705
+ */
1706
+ preventFocusLoss(element, allowedSelector = 'button, input, select, textarea, [contenteditable]') {
1707
+ if (!element) return;
1708
+
1709
+ element.addEventListener('mousedown', (e) => {
1710
+ // Allow normal behavior for interactive elements
1711
+ if (e.target.closest(allowedSelector)) {
1712
+ return;
1713
+ }
1714
+
1715
+ // Prevent default behavior for non-interactive areas
1716
+ e.preventDefault();
1717
+
1718
+ // Restore focus to editor after event processing
1719
+ setTimeout(() => {
1720
+ this.focus();
1721
+ }, 0);
1722
+ });
1723
+ }
1724
+
1725
+ /**
1726
+ * Get current editor instance
1727
+ * @returns {Editor|null} Current editor instance
1728
+ */
1729
+ static getCurrentInstance() {
1730
+ return Editor.currentInstance;
1731
+ }
1732
+
1733
+ /**
1734
+ * Utility function to maintain editor focus after UI interactions
1735
+ * @param {Function} callback - Function to execute before maintaining focus
1736
+ * @param {Editor} editor - Editor instance to maintain focus on
1737
+ */
1738
+ static maintainFocus(callback, editor = null) {
1739
+ if (typeof callback === 'function') {
1740
+ callback();
1741
+ }
1742
+ const editorInstance = editor || Editor.getCurrentInstance();
1743
+ if (editorInstance) {
1744
+ setTimeout(() => editorInstance.focus(), 0);
1745
+ }
1746
+ }
1747
+
1748
+ /**
1749
+ * Get popup container for this editor instance
1750
+ * @returns {HTMLElement} Popup container element
1751
+ */
1752
+ getPopupContainer() {
1753
+ return this.popupContainer;
1754
+ }
1755
+
1756
+ /**
1757
+ * Get popup container from current editor instance
1758
+ * @returns {HTMLElement|null} Popup container element or null if no current instance
1759
+ */
1760
+ static getPopupContainer() {
1761
+ const currentInstance = Editor.getCurrentInstance();
1762
+ return currentInstance ? currentInstance.getPopupContainer() : null;
1763
+ }
1764
+
1765
+ /**
1766
+ * Get popup instance for this editor
1767
+ * @param {string} popupType - Type of popup (e.g., 'link', 'image', 'table')
1768
+ * @returns {Object|null} Popup instance or null if not found
1769
+ */
1770
+ getPopupInstance(popupType) {
1771
+ return this.popupInstances.get(popupType);
1772
+ }
1773
+
1774
+ /**
1775
+ * Set popup instance for this editor
1776
+ * @param {string} popupType - Type of popup
1777
+ * @param {Object} popupInstance - Popup instance
1778
+ */
1779
+ setPopupInstance(popupType, popupInstance) {
1780
+ this.popupInstances.set(popupType, popupInstance);
1781
+ }
1782
+
1783
+ /**
1784
+ * Get popup instance by editor ID and popup type
1785
+ * @param {string} editorId - Editor instance ID
1786
+ * @param {string} popupType - Type of popup
1787
+ * @returns {Object|null} Popup instance or null if not found
1788
+ */
1789
+ static getPopupInstanceById(editorId, popupType) {
1790
+ const editor = Editor.instances.get(editorId);
1791
+ return editor ? editor.getPopupInstance(popupType) : null;
1792
+ }
1793
+
1794
+ /**
1795
+ * Get editor instance by ID
1796
+ * @param {string} editorId - Editor instance ID
1797
+ * @returns {Editor|null} Editor instance or null if not found
1798
+ */
1799
+ static getInstanceById(editorId) {
1800
+ return Editor.instances.get(editorId);
1801
+ }
1802
+
1803
+ /**
1804
+ * Get all editor instances
1805
+ * @returns {Map} Map of all editor instances
1806
+ */
1807
+ static getAllInstances() {
1808
+ return Editor.instances;
1809
+ }
1810
+
1811
+ /**
1812
+ * Destroy popup instances for this editor
1813
+ */
1814
+ destroyPopupInstances() {
1815
+ this.popupInstances.forEach((popupInstance, popupType) => {
1816
+ if (popupInstance && typeof popupInstance.destroy === 'function') {
1817
+ popupInstance.destroy();
1818
+ }
1819
+ });
1820
+ this.popupInstances.clear();
1821
+ }
1822
+
1823
+ /**
1824
+ * Update placeholder visibility based on editor content
1825
+ */
1826
+ updatePlaceholderVisibility() {
1827
+ // Use isEditorEmpty() (text AND media) rather than textContent alone, so an
1828
+ // image/table-only editor doesn't keep showing the placeholder.
1829
+ if (this.isEditorEmpty()) {
1830
+ this.editor.classList.add('placeholder-visible');
1831
+ } else {
1832
+ this.editor.classList.remove('placeholder-visible');
1833
+ }
1834
+ }
1835
+
1836
+ /**
1837
+ * Destroy editor
1838
+ */
1839
+ destroy() {
1840
+ // Remove active-tracking listeners
1841
+ if (this._markActive) {
1842
+ this.wrapper.removeEventListener('pointerdown', this._markActive, true);
1843
+ this.wrapper.removeEventListener('focusin', this._markActive, true);
1844
+ this._markActive = null;
1845
+ }
1846
+
1847
+ // Remove the document-level selection listener
1848
+ if (this._onDocSelectionChange) {
1849
+ document.removeEventListener('selectionchange', this._onDocSelectionChange);
1850
+ this._onDocSelectionChange = null;
1851
+ }
1852
+ if (this._fmtCache) this._fmtCache.clear();
1853
+
1854
+ // Cancel any pending autosave write
1855
+ clearTimeout(this._autosaveTimer);
1856
+
1857
+ // Destroy all modules
1858
+ this.modules.forEach(module => {
1859
+ if (typeof module.destroy === 'function') {
1860
+ module.destroy();
1861
+ }
1862
+ });
1863
+
1864
+ // Destroy popup instances
1865
+ this.destroyPopupInstances();
1866
+
1867
+ // Remove DOM elements
1868
+ if (this.wrapper && this.wrapper.parentNode) {
1869
+ this.wrapper.parentNode.removeChild(this.wrapper);
1870
+ }
1871
+
1872
+ // Clear references
1873
+ this.modules.clear();
1874
+ this.formats.clear();
1875
+ this.events.clear(); // Clear events
1876
+
1877
+ // Remove from instances map
1878
+ Editor.instances.delete(this.instanceId);
1879
+
1880
+ // Clear current instance if this was the current one
1881
+ if (Editor.currentInstance === this) {
1882
+ Editor.currentInstance = null;
1883
+ }
1884
+ }
1885
+ }