@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.
- package/LICENSE +15 -0
- package/README.md +146 -142
- package/core.js +77 -0
- package/dist/core.esm.js +2 -0
- package/dist/core.esm.js.map +1 -0
- package/dist/rich-editor.esm.js +1 -1
- package/dist/rich-editor.esm.js.map +1 -1
- package/dist/rich-editor.min.js +1 -1
- package/dist/rich-editor.min.js.map +1 -1
- package/index.d.ts +134 -103
- package/index.js +227 -0
- package/lib/core/editor.js +1806 -0
- package/lib/core/format.js +540 -0
- package/lib/core/module.js +81 -0
- package/lib/core/registry.js +158 -0
- package/lib/formats/background.js +213 -0
- package/lib/formats/bold.js +49 -0
- package/lib/formats/capitalization.js +579 -0
- package/lib/formats/color.js +183 -0
- package/lib/formats/emoji.js +282 -0
- package/lib/formats/font-family.js +548 -0
- package/lib/formats/heading.js +502 -0
- package/lib/formats/image.js +347 -0
- package/lib/formats/import.js +385 -0
- package/lib/formats/indent.js +297 -0
- package/lib/formats/italic.js +27 -0
- package/lib/formats/line-height.js +562 -0
- package/lib/formats/link.js +251 -0
- package/lib/formats/list.js +635 -0
- package/lib/formats/strike.js +31 -0
- package/lib/formats/subscript.js +40 -0
- package/lib/formats/superscript.js +39 -0
- package/lib/formats/table.js +293 -0
- package/lib/formats/tag.js +304 -0
- package/lib/formats/text-align.js +422 -0
- package/lib/formats/text-size.js +498 -0
- package/lib/formats/underline.js +30 -0
- package/lib/formats/video.js +381 -0
- package/lib/modules/block-toolbar.js +639 -0
- package/lib/modules/code-view.js +447 -0
- package/lib/modules/find-replace.js +273 -0
- package/lib/modules/history.js +425 -0
- package/lib/modules/resize-handles.js +701 -0
- package/lib/modules/slash-menu.js +183 -0
- package/lib/modules/table-toolbar.js +635 -0
- package/lib/modules/toolbar.js +607 -0
- package/lib/styles-loader.js +142 -0
- package/{dist → lib}/styles.css +1285 -35
- package/lib/styles.css.js +2 -0
- package/lib/styles.min.css +1 -0
- package/lib/ui/color-picker.js +296 -0
- package/lib/ui/customselect.js +351 -0
- package/lib/ui/emoji-picker.js +196 -0
- package/lib/ui/icons.js +145 -0
- package/lib/ui/image-popup.js +435 -0
- package/lib/ui/import-popup.js +288 -0
- package/lib/ui/link-popup.js +139 -0
- package/lib/ui/list-picker.js +307 -0
- package/lib/ui/select-button.js +68 -0
- package/lib/ui/table-popup.js +171 -0
- package/lib/ui/tag-popup.js +249 -0
- package/lib/ui/text-align-picker.js +278 -0
- package/lib/ui/video-popup.js +413 -0
- package/lib/utils/exec-command.js +72 -0
- package/lib/utils/history-helper.js +50 -0
- package/lib/utils/popup-helper.js +219 -0
- package/lib/utils/popup-positioning.js +234 -0
- package/lib/utils/sanitize.js +164 -0
- package/package.json +51 -32
- package/umd-entry.js +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
|
+
}
|