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