@oix1987/yjd 1.0.3 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +223 -142
- package/core.js +82 -0
- package/dist/core.esm.js +2 -0
- package/dist/core.esm.js.map +1 -0
- package/dist/rich-editor.esm.js +1 -1
- package/dist/rich-editor.esm.js.map +1 -1
- package/dist/rich-editor.min.js +1 -1
- package/dist/rich-editor.min.js.map +1 -1
- package/index.d.ts +230 -103
- package/index.js +297 -0
- package/lib/core/editor.js +1885 -0
- package/lib/core/format.js +540 -0
- package/lib/core/module.js +81 -0
- package/lib/core/registry.js +158 -0
- package/lib/formats/background.js +213 -0
- package/lib/formats/bold.js +49 -0
- package/lib/formats/capitalization.js +579 -0
- package/lib/formats/color.js +183 -0
- package/lib/formats/emoji.js +282 -0
- package/lib/formats/font-family.js +548 -0
- package/lib/formats/heading.js +502 -0
- package/lib/formats/image.js +341 -0
- package/lib/formats/import.js +385 -0
- package/lib/formats/indent.js +297 -0
- package/lib/formats/italic.js +27 -0
- package/lib/formats/line-height.js +562 -0
- package/lib/formats/link.js +251 -0
- package/lib/formats/list.js +635 -0
- package/lib/formats/strike.js +31 -0
- package/lib/formats/subscript.js +40 -0
- package/lib/formats/superscript.js +39 -0
- package/lib/formats/table.js +293 -0
- package/lib/formats/tag.js +304 -0
- package/lib/formats/text-align.js +422 -0
- package/lib/formats/text-size.js +498 -0
- package/lib/formats/underline.js +30 -0
- package/lib/formats/video.js +381 -0
- package/lib/modules/block-toolbar.js +639 -0
- package/lib/modules/code-view.js +447 -0
- package/lib/modules/find-replace.js +273 -0
- package/lib/modules/history.js +425 -0
- package/lib/modules/mention.js +200 -0
- package/lib/modules/resize-handles.js +701 -0
- package/lib/modules/slash-menu.js +183 -0
- package/lib/modules/table-toolbar.js +635 -0
- package/lib/modules/toolbar.js +607 -0
- package/lib/serialize.js +241 -0
- package/lib/static.js +28 -0
- package/lib/styles-loader.js +142 -0
- package/{dist → lib}/styles.css +1392 -35
- package/lib/styles.css.js +2 -0
- package/lib/styles.min.css +1 -0
- package/lib/ui/color-picker.js +296 -0
- package/lib/ui/customselect.js +351 -0
- package/lib/ui/emoji-picker.js +196 -0
- package/lib/ui/icons.js +145 -0
- package/lib/ui/image-popup.js +435 -0
- package/lib/ui/import-popup.js +288 -0
- package/lib/ui/link-popup.js +139 -0
- package/lib/ui/list-picker.js +307 -0
- package/lib/ui/select-button.js +68 -0
- package/lib/ui/table-popup.js +171 -0
- package/lib/ui/tag-popup.js +249 -0
- package/lib/ui/text-align-picker.js +278 -0
- package/lib/ui/video-popup.js +413 -0
- package/lib/utils/exec-command.js +72 -0
- package/lib/utils/history-helper.js +50 -0
- package/lib/utils/popup-helper.js +219 -0
- package/lib/utils/popup-positioning.js +234 -0
- package/lib/utils/sanitize.js +164 -0
- package/package.json +51 -32
- package/umd-entry.js +19 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import Module from '../core/module.js';
|
|
2
|
+
import IconUtils from '../ui/icons.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Find & Replace module.
|
|
6
|
+
*
|
|
7
|
+
* Opens with Ctrl/Cmd+F or the toolbar "find" button. Highlights matches
|
|
8
|
+
* (wrapping them in <mark> within single text nodes), supports next/prev
|
|
9
|
+
* navigation, replace-current and replace-all. Matches that span across
|
|
10
|
+
* formatting boundaries (e.g. half inside a <b>) are not highlighted — a
|
|
11
|
+
* documented limitation of the per-text-node approach.
|
|
12
|
+
*/
|
|
13
|
+
export default class FindReplace extends Module {
|
|
14
|
+
constructor(editor, options = {}) {
|
|
15
|
+
super(editor, options);
|
|
16
|
+
this.hits = [];
|
|
17
|
+
this.activeIndex = -1;
|
|
18
|
+
this.caseSensitive = false;
|
|
19
|
+
this.isOpen = false;
|
|
20
|
+
this.buildPanel();
|
|
21
|
+
this.bindEvents();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
buildPanel() {
|
|
25
|
+
const panel = document.createElement('div');
|
|
26
|
+
panel.className = 'yjd-find-replace';
|
|
27
|
+
|
|
28
|
+
const mkInput = (ph, cls) => {
|
|
29
|
+
const i = document.createElement('input');
|
|
30
|
+
i.type = 'text';
|
|
31
|
+
i.placeholder = ph;
|
|
32
|
+
i.className = `yjd-find-input ${cls}`;
|
|
33
|
+
i.setAttribute('aria-label', ph);
|
|
34
|
+
return i;
|
|
35
|
+
};
|
|
36
|
+
const mkBtn = (label, title, cls = '', icon = null) => {
|
|
37
|
+
const b = document.createElement('button');
|
|
38
|
+
b.type = 'button';
|
|
39
|
+
if (icon) {
|
|
40
|
+
const svg = IconUtils.getIcon(icon);
|
|
41
|
+
b.innerHTML = svg ? `<span class="icon">${svg}</span>` : label;
|
|
42
|
+
} else {
|
|
43
|
+
b.textContent = label;
|
|
44
|
+
}
|
|
45
|
+
b.title = title;
|
|
46
|
+
b.setAttribute('aria-label', title);
|
|
47
|
+
b.className = `yjd-find-btn ${cls}`.trim();
|
|
48
|
+
return b;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Two rows: find controls, then replace controls
|
|
52
|
+
const findRow = document.createElement('div');
|
|
53
|
+
findRow.className = 'yjd-find-row';
|
|
54
|
+
const replaceRow = document.createElement('div');
|
|
55
|
+
replaceRow.className = 'yjd-find-row';
|
|
56
|
+
|
|
57
|
+
this.findInput = mkInput('Find', 'yjd-find-field');
|
|
58
|
+
this.replaceInput = mkInput('Replace with', 'yjd-find-field');
|
|
59
|
+
this.countEl = document.createElement('span');
|
|
60
|
+
this.countEl.className = 'yjd-find-count';
|
|
61
|
+
this.countEl.textContent = '0/0';
|
|
62
|
+
|
|
63
|
+
this.prevBtn = mkBtn('', 'Previous match', 'yjd-find-icon', 'chevron-up');
|
|
64
|
+
this.nextBtn = mkBtn('', 'Next match', 'yjd-find-icon', 'chevron-down');
|
|
65
|
+
this.caseBtn = mkBtn('Aa', 'Match case', 'yjd-find-icon yjd-find-toggle');
|
|
66
|
+
this.closeBtn = mkBtn('', 'Close (Esc)', 'yjd-find-icon yjd-find-close', 'close');
|
|
67
|
+
this.replaceBtn = mkBtn('Replace', 'Replace current');
|
|
68
|
+
this.replaceAllBtn = mkBtn('Replace all', 'Replace all matches');
|
|
69
|
+
|
|
70
|
+
findRow.append(this.findInput, this.countEl, this.prevBtn, this.nextBtn, this.caseBtn, this.closeBtn);
|
|
71
|
+
replaceRow.append(this.replaceInput, this.replaceBtn, this.replaceAllBtn);
|
|
72
|
+
panel.append(findRow, replaceRow);
|
|
73
|
+
|
|
74
|
+
this.panel = panel;
|
|
75
|
+
this.editor.wrapper.appendChild(panel);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
bindEvents() {
|
|
79
|
+
// Open with Ctrl/Cmd+F from within the editor
|
|
80
|
+
this._onKeydown = (e) => {
|
|
81
|
+
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'f') {
|
|
82
|
+
e.preventDefault();
|
|
83
|
+
this.open();
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
this.editor.editor.addEventListener('keydown', this._onKeydown);
|
|
87
|
+
|
|
88
|
+
// Open from the toolbar "find" button
|
|
89
|
+
this._onToolbarClick = (data) => {
|
|
90
|
+
if (data && data.command === 'find') this.open();
|
|
91
|
+
};
|
|
92
|
+
this.editor.on('toolbar-click', this._onToolbarClick);
|
|
93
|
+
|
|
94
|
+
this.findInput.addEventListener('input', () => this.runSearch());
|
|
95
|
+
this.findInput.addEventListener('keydown', (e) => {
|
|
96
|
+
if (e.key === 'Enter') { e.preventDefault(); this.navigate(e.shiftKey ? -1 : 1); }
|
|
97
|
+
else if (e.key === 'Escape') { e.preventDefault(); this.close(); }
|
|
98
|
+
});
|
|
99
|
+
this.replaceInput.addEventListener('keydown', (e) => {
|
|
100
|
+
if (e.key === 'Enter') { e.preventDefault(); this.replaceCurrent(); }
|
|
101
|
+
else if (e.key === 'Escape') { e.preventDefault(); this.close(); }
|
|
102
|
+
});
|
|
103
|
+
this.prevBtn.addEventListener('click', () => this.navigate(-1));
|
|
104
|
+
this.nextBtn.addEventListener('click', () => this.navigate(1));
|
|
105
|
+
this.replaceBtn.addEventListener('click', () => this.replaceCurrent());
|
|
106
|
+
this.replaceAllBtn.addEventListener('click', () => this.replaceAll());
|
|
107
|
+
this.caseBtn.addEventListener('click', () => {
|
|
108
|
+
this.caseSensitive = !this.caseSensitive;
|
|
109
|
+
this.caseBtn.classList.toggle('active', this.caseSensitive);
|
|
110
|
+
this.caseBtn.setAttribute('aria-pressed', this.caseSensitive ? 'true' : 'false');
|
|
111
|
+
this.runSearch();
|
|
112
|
+
});
|
|
113
|
+
this.closeBtn.addEventListener('click', () => this.close());
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
open() {
|
|
117
|
+
this.isOpen = true;
|
|
118
|
+
this.panel.classList.add('open');
|
|
119
|
+
// Sit just below the toolbar so the panel never covers its buttons
|
|
120
|
+
// (the toolbar height changes when "More" is expanded).
|
|
121
|
+
const toolbar = this.editor.wrapper.querySelector('.rich-editor-toolbar-container');
|
|
122
|
+
if (toolbar) this.panel.style.top = (toolbar.offsetHeight + 6) + 'px';
|
|
123
|
+
// Prefill with the current selection (if any, single-line)
|
|
124
|
+
const sel = window.getSelection();
|
|
125
|
+
const selText = sel && !sel.isCollapsed ? sel.toString() : '';
|
|
126
|
+
if (selText && !selText.includes('\n')) this.findInput.value = selText;
|
|
127
|
+
this.findInput.focus();
|
|
128
|
+
this.findInput.select();
|
|
129
|
+
this.runSearch();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
close() {
|
|
133
|
+
this.isOpen = false;
|
|
134
|
+
this.panel.classList.remove('open');
|
|
135
|
+
this.clearHighlights();
|
|
136
|
+
this.hits = [];
|
|
137
|
+
this.activeIndex = -1;
|
|
138
|
+
this.editor.focus();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
escapeRegex(s) {
|
|
142
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
clearHighlights() {
|
|
146
|
+
const marks = this.editor.editor.querySelectorAll('mark.yjd-find-hit');
|
|
147
|
+
marks.forEach((m) => {
|
|
148
|
+
const parent = m.parentNode;
|
|
149
|
+
if (!parent) return;
|
|
150
|
+
while (m.firstChild) parent.insertBefore(m.firstChild, m);
|
|
151
|
+
parent.removeChild(m);
|
|
152
|
+
parent.normalize();
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
runSearch() {
|
|
157
|
+
this.clearHighlights();
|
|
158
|
+
this.hits = [];
|
|
159
|
+
this.activeIndex = -1;
|
|
160
|
+
|
|
161
|
+
const term = this.findInput.value;
|
|
162
|
+
if (!term) { this.updateCount(); return; }
|
|
163
|
+
|
|
164
|
+
let regex;
|
|
165
|
+
try {
|
|
166
|
+
regex = new RegExp(this.escapeRegex(term), this.caseSensitive ? 'g' : 'gi');
|
|
167
|
+
} catch (e) {
|
|
168
|
+
this.updateCount();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const root = this.editor.editor;
|
|
173
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
|
|
174
|
+
const textNodes = [];
|
|
175
|
+
let node;
|
|
176
|
+
while ((node = walker.nextNode())) {
|
|
177
|
+
if (node.nodeValue && node.nodeValue.length) textNodes.push(node);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
textNodes.forEach((textNode) => {
|
|
181
|
+
const text = textNode.nodeValue;
|
|
182
|
+
const ranges = [];
|
|
183
|
+
regex.lastIndex = 0;
|
|
184
|
+
let m;
|
|
185
|
+
while ((m = regex.exec(text)) !== null) {
|
|
186
|
+
if (m[0].length === 0) { regex.lastIndex++; continue; }
|
|
187
|
+
ranges.push([m.index, m.index + m[0].length]);
|
|
188
|
+
}
|
|
189
|
+
// Wrap from last match to first so earlier offsets stay valid
|
|
190
|
+
for (let i = ranges.length - 1; i >= 0; i--) {
|
|
191
|
+
const r = document.createRange();
|
|
192
|
+
r.setStart(textNode, ranges[i][0]);
|
|
193
|
+
r.setEnd(textNode, ranges[i][1]);
|
|
194
|
+
const mark = document.createElement('mark');
|
|
195
|
+
mark.className = 'yjd-find-hit';
|
|
196
|
+
try { r.surroundContents(mark); } catch (e) { /* skip un-wrappable */ }
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Collect in document order
|
|
201
|
+
this.hits = Array.from(root.querySelectorAll('mark.yjd-find-hit'));
|
|
202
|
+
if (this.hits.length) {
|
|
203
|
+
this.activeIndex = 0;
|
|
204
|
+
this.highlightActive(true);
|
|
205
|
+
}
|
|
206
|
+
this.updateCount();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
highlightActive(scroll) {
|
|
210
|
+
this.hits.forEach((m, i) => {
|
|
211
|
+
m.classList.toggle('active', i === this.activeIndex);
|
|
212
|
+
});
|
|
213
|
+
if (scroll && this.activeIndex >= 0 && this.hits[this.activeIndex]) {
|
|
214
|
+
this.hits[this.activeIndex].scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
navigate(dir) {
|
|
219
|
+
if (!this.hits.length) return;
|
|
220
|
+
this.activeIndex = (this.activeIndex + dir + this.hits.length) % this.hits.length;
|
|
221
|
+
this.highlightActive(true);
|
|
222
|
+
this.updateCount();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
updateCount() {
|
|
226
|
+
const total = this.hits.length;
|
|
227
|
+
const cur = total ? this.activeIndex + 1 : 0;
|
|
228
|
+
this.countEl.textContent = `${cur}/${total}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
replaceCurrent() {
|
|
232
|
+
if (this.activeIndex < 0 || !this.hits[this.activeIndex]) return;
|
|
233
|
+
const history = this.editor.getModule('history');
|
|
234
|
+
if (history && typeof history.saveBeforeFormat === 'function') history.saveBeforeFormat();
|
|
235
|
+
|
|
236
|
+
const mark = this.hits[this.activeIndex];
|
|
237
|
+
const at = this.activeIndex;
|
|
238
|
+
const parent = mark.parentNode;
|
|
239
|
+
parent.replaceChild(document.createTextNode(this.replaceInput.value), mark);
|
|
240
|
+
parent.normalize();
|
|
241
|
+
this.editor.onContentChange();
|
|
242
|
+
|
|
243
|
+
this.runSearch();
|
|
244
|
+
if (this.hits.length) {
|
|
245
|
+
this.activeIndex = Math.min(at, this.hits.length - 1);
|
|
246
|
+
this.highlightActive(true);
|
|
247
|
+
this.updateCount();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
replaceAll() {
|
|
252
|
+
if (!this.hits.length) return;
|
|
253
|
+
const history = this.editor.getModule('history');
|
|
254
|
+
if (history && typeof history.saveBeforeFormat === 'function') history.saveBeforeFormat();
|
|
255
|
+
|
|
256
|
+
const repl = this.replaceInput.value;
|
|
257
|
+
this.hits.forEach((mark) => {
|
|
258
|
+
const parent = mark.parentNode;
|
|
259
|
+
if (parent) parent.replaceChild(document.createTextNode(repl), mark);
|
|
260
|
+
});
|
|
261
|
+
this.editor.editor.normalize();
|
|
262
|
+
this.editor.onContentChange();
|
|
263
|
+
this.runSearch();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
destroy() {
|
|
267
|
+
this.editor.editor.removeEventListener('keydown', this._onKeydown);
|
|
268
|
+
this.editor.off('toolbar-click', this._onToolbarClick);
|
|
269
|
+
this.clearHighlights();
|
|
270
|
+
if (this.panel && this.panel.parentNode) this.panel.parentNode.removeChild(this.panel);
|
|
271
|
+
super.destroy();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import Module from '../core/module.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* History Module - Handles undo/redo functionality
|
|
5
|
+
* Extracted from FormatManager.js and ToolbarManager.js logic
|
|
6
|
+
*/
|
|
7
|
+
class History extends Module {
|
|
8
|
+
static DEFAULTS = {
|
|
9
|
+
delay: 1000, // Delay between history saves
|
|
10
|
+
maxStack: 100, // Maximum number of undo states
|
|
11
|
+
userOnly: false // Only save user-initiated changes
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
constructor(editor, options = {}) {
|
|
15
|
+
super(editor, options);
|
|
16
|
+
this.stack = [];
|
|
17
|
+
this.index = -1;
|
|
18
|
+
this.lastSave = 0;
|
|
19
|
+
this.savedSelection = null;
|
|
20
|
+
|
|
21
|
+
this.init();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
init() {
|
|
25
|
+
this.setupEventListeners();
|
|
26
|
+
this.saveState(); // Save initial state
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Setup event listeners for automatic history saving
|
|
31
|
+
*/
|
|
32
|
+
setupEventListeners() {
|
|
33
|
+
// Keep references so the listeners can be removed in destroy().
|
|
34
|
+
this._onInput = () => {
|
|
35
|
+
this.handleInput();
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
this._onKeydownSave = (e) => {
|
|
39
|
+
// Save state before destructive operations
|
|
40
|
+
if (e.key === 'Enter' || e.key === 'Backspace' || e.key === 'Delete') {
|
|
41
|
+
this.saveState();
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
this._onToolbarClick = (e) => {
|
|
46
|
+
if (e.target.closest('.rich-editor-toolbar-btn')) {
|
|
47
|
+
// Save state before applying format
|
|
48
|
+
setTimeout(() => {
|
|
49
|
+
this.saveState();
|
|
50
|
+
}, 0);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
this._onUndoRedo = (e) => {
|
|
55
|
+
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 'z') {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
this.undo();
|
|
58
|
+
} else if (((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z') ||
|
|
59
|
+
((e.ctrlKey || e.metaKey) && e.key === 'y')) {
|
|
60
|
+
e.preventDefault();
|
|
61
|
+
this.redo();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Save state on input with debouncing
|
|
66
|
+
this.editor.editor.addEventListener('input', this._onInput);
|
|
67
|
+
// Save state on specific commands
|
|
68
|
+
this.editor.editor.addEventListener('keydown', this._onKeydownSave);
|
|
69
|
+
// Listen for toolbar clicks to save state before formatting
|
|
70
|
+
this.editor.wrapper.addEventListener('click', this._onToolbarClick);
|
|
71
|
+
// Handle undo/redo shortcuts - only when editor is focused
|
|
72
|
+
this.editor.editor.addEventListener('keydown', this._onUndoRedo);
|
|
73
|
+
|
|
74
|
+
// Listen for DOM changes to catch all formatting operations
|
|
75
|
+
this.setupMutationObserver();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Setup mutation observer to watch for DOM changes
|
|
80
|
+
*/
|
|
81
|
+
setupMutationObserver() {
|
|
82
|
+
this.mutationObserver = new MutationObserver((mutations) => {
|
|
83
|
+
let shouldSave = false;
|
|
84
|
+
|
|
85
|
+
for (const mutation of mutations) {
|
|
86
|
+
// Check if the mutation is relevant (not just attribute changes on non-content elements)
|
|
87
|
+
if (mutation.type === 'childList' ||
|
|
88
|
+
(mutation.type === 'attributes' &&
|
|
89
|
+
(mutation.target.nodeType === Node.TEXT_NODE ||
|
|
90
|
+
['P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'PRE', 'UL', 'OL', 'LI', 'SPAN', 'STRONG', 'EM', 'U', 'S', 'SUB', 'SUP', 'A', 'IMG', 'VIDEO', 'TABLE', 'TR', 'TD', 'TH'].includes(mutation.target.tagName)))) {
|
|
91
|
+
shouldSave = true;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (shouldSave) {
|
|
97
|
+
// Debounce the save to avoid too many saves
|
|
98
|
+
clearTimeout(this.mutationTimeout);
|
|
99
|
+
this.mutationTimeout = setTimeout(() => {
|
|
100
|
+
this.saveState();
|
|
101
|
+
}, 100);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Start observing
|
|
106
|
+
this.mutationObserver.observe(this.editor.editor, {
|
|
107
|
+
childList: true,
|
|
108
|
+
subtree: true,
|
|
109
|
+
attributes: true,
|
|
110
|
+
attributeFilter: ['style', 'class', 'href', 'src', 'alt', 'title']
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Handle input event with debouncing
|
|
116
|
+
*/
|
|
117
|
+
handleInput() {
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
if (now - this.lastSave > this.options.delay) {
|
|
120
|
+
this.saveState();
|
|
121
|
+
this.lastSave = now;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Save current editor state
|
|
127
|
+
*/
|
|
128
|
+
saveState() {
|
|
129
|
+
const content = this.editor.getContent();
|
|
130
|
+
const selection = this.saveSelection();
|
|
131
|
+
|
|
132
|
+
// Don't save if content hasn't changed
|
|
133
|
+
if (this.stack.length > 0 && this.stack[this.index]?.content === content) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Don't save if it's too soon after last save (debouncing)
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
if (this.lastSave && now - this.lastSave < 50) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Remove any redo states if we're not at the end
|
|
144
|
+
if (this.index < this.stack.length - 1) {
|
|
145
|
+
this.stack.splice(this.index + 1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Add new state
|
|
149
|
+
this.stack.push({
|
|
150
|
+
content,
|
|
151
|
+
selection,
|
|
152
|
+
timestamp: now
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Limit stack size
|
|
156
|
+
if (this.stack.length > this.options.maxStack) {
|
|
157
|
+
this.stack.shift();
|
|
158
|
+
} else {
|
|
159
|
+
this.index++;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
this.lastSave = now;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Undo last change
|
|
167
|
+
*/
|
|
168
|
+
undo() {
|
|
169
|
+
if (!this.canUndo()) return false;
|
|
170
|
+
|
|
171
|
+
this.index--;
|
|
172
|
+
const state = this.stack[this.index];
|
|
173
|
+
|
|
174
|
+
this.restoreState(state);
|
|
175
|
+
this.onHistoryChange('undo');
|
|
176
|
+
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Redo last undone change
|
|
182
|
+
*/
|
|
183
|
+
redo() {
|
|
184
|
+
if (!this.canRedo()) return false;
|
|
185
|
+
|
|
186
|
+
this.index++;
|
|
187
|
+
const state = this.stack[this.index];
|
|
188
|
+
|
|
189
|
+
this.restoreState(state);
|
|
190
|
+
this.onHistoryChange('redo');
|
|
191
|
+
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Check if undo is possible
|
|
197
|
+
*/
|
|
198
|
+
canUndo() {
|
|
199
|
+
return this.index > 0;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Check if redo is possible
|
|
204
|
+
*/
|
|
205
|
+
canRedo() {
|
|
206
|
+
return this.index < this.stack.length - 1;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Restore editor state
|
|
211
|
+
* @param {object} state - State to restore
|
|
212
|
+
*/
|
|
213
|
+
restoreState(state) {
|
|
214
|
+
if (!state) return;
|
|
215
|
+
|
|
216
|
+
// Restore content
|
|
217
|
+
this.editor.setContent(state.content);
|
|
218
|
+
|
|
219
|
+
// Restore selection
|
|
220
|
+
if (state.selection) {
|
|
221
|
+
setTimeout(() => {
|
|
222
|
+
this.restoreSelection(state.selection);
|
|
223
|
+
}, 10);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Save current selection
|
|
229
|
+
*/
|
|
230
|
+
saveSelection() {
|
|
231
|
+
const selection = window.getSelection();
|
|
232
|
+
if (!selection || !selection.rangeCount) return null;
|
|
233
|
+
|
|
234
|
+
const range = selection.getRangeAt(0);
|
|
235
|
+
const editorEl = this.editor.editor;
|
|
236
|
+
|
|
237
|
+
// Calculate offset relative to editor
|
|
238
|
+
const startOffset = this.getOffsetInEditor(range.startContainer, range.startOffset, editorEl);
|
|
239
|
+
const endOffset = this.getOffsetInEditor(range.endContainer, range.endOffset, editorEl);
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
startOffset,
|
|
243
|
+
endOffset,
|
|
244
|
+
collapsed: range.collapsed
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Restore selection
|
|
250
|
+
* @param {object} selectionState - Selection state to restore
|
|
251
|
+
*/
|
|
252
|
+
restoreSelection(selectionState) {
|
|
253
|
+
if (!selectionState) return;
|
|
254
|
+
|
|
255
|
+
const editorEl = this.editor.editor;
|
|
256
|
+
const range = document.createRange();
|
|
257
|
+
const selection = window.getSelection();
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
const startNode = this.getNodeAtOffset(editorEl, selectionState.startOffset);
|
|
261
|
+
const endNode = this.getNodeAtOffset(editorEl, selectionState.endOffset);
|
|
262
|
+
|
|
263
|
+
if (startNode && endNode) {
|
|
264
|
+
range.setStart(startNode.node, startNode.offset);
|
|
265
|
+
range.setEnd(endNode.node, endNode.offset);
|
|
266
|
+
|
|
267
|
+
selection.removeAllRanges();
|
|
268
|
+
selection.addRange(range);
|
|
269
|
+
}
|
|
270
|
+
} catch (error) {
|
|
271
|
+
console.warn('Could not restore selection:', error);
|
|
272
|
+
// Fallback: focus editor
|
|
273
|
+
this.editor.focus();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Get offset of a position within editor
|
|
279
|
+
* @param {Node} node - DOM node
|
|
280
|
+
* @param {number} offset - Offset within node
|
|
281
|
+
* @param {Element} root - Root element (editor)
|
|
282
|
+
*/
|
|
283
|
+
getOffsetInEditor(node, offset, root) {
|
|
284
|
+
let totalOffset = 0;
|
|
285
|
+
const walker = document.createTreeWalker(
|
|
286
|
+
root,
|
|
287
|
+
NodeFilter.SHOW_TEXT,
|
|
288
|
+
null,
|
|
289
|
+
false
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
let currentNode;
|
|
293
|
+
while (currentNode = walker.nextNode()) {
|
|
294
|
+
if (currentNode === node) {
|
|
295
|
+
return totalOffset + offset;
|
|
296
|
+
}
|
|
297
|
+
totalOffset += currentNode.textContent.length;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return totalOffset;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Get node at specific offset within editor
|
|
305
|
+
* @param {Element} root - Root element (editor)
|
|
306
|
+
* @param {number} targetOffset - Target offset
|
|
307
|
+
*/
|
|
308
|
+
getNodeAtOffset(root, targetOffset) {
|
|
309
|
+
let currentOffset = 0;
|
|
310
|
+
const walker = document.createTreeWalker(
|
|
311
|
+
root,
|
|
312
|
+
NodeFilter.SHOW_TEXT,
|
|
313
|
+
null,
|
|
314
|
+
false
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
let currentNode;
|
|
318
|
+
while (currentNode = walker.nextNode()) {
|
|
319
|
+
const nodeLength = currentNode.textContent.length;
|
|
320
|
+
if (currentOffset + nodeLength >= targetOffset) {
|
|
321
|
+
return {
|
|
322
|
+
node: currentNode,
|
|
323
|
+
offset: targetOffset - currentOffset
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
currentOffset += nodeLength;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Fallback: return last node
|
|
330
|
+
return {
|
|
331
|
+
node: root.lastChild || root,
|
|
332
|
+
offset: 0
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Clear history
|
|
338
|
+
*/
|
|
339
|
+
clear() {
|
|
340
|
+
this.stack = [];
|
|
341
|
+
this.index = -1;
|
|
342
|
+
this.saveState(); // Save current state as first entry
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Get current history state info
|
|
347
|
+
*/
|
|
348
|
+
getState() {
|
|
349
|
+
return {
|
|
350
|
+
canUndo: this.canUndo(),
|
|
351
|
+
canRedo: this.canRedo(),
|
|
352
|
+
stackLength: this.stack.length,
|
|
353
|
+
currentIndex: this.index
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Called when history changes (undo/redo)
|
|
359
|
+
* @param {string} action - 'undo' or 'redo'
|
|
360
|
+
*/
|
|
361
|
+
onHistoryChange(action) {
|
|
362
|
+
// Notify other modules about history change
|
|
363
|
+
this.editor.modules.forEach(module => {
|
|
364
|
+
if (module !== this && typeof module.onHistoryChange === 'function') {
|
|
365
|
+
module.onHistoryChange(action, this.getState());
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// Trigger custom event
|
|
370
|
+
const event = new CustomEvent('historychange', {
|
|
371
|
+
detail: { action, state: this.getState() }
|
|
372
|
+
});
|
|
373
|
+
this.editor.editor.dispatchEvent(event);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Force save current state (useful before major operations)
|
|
378
|
+
*/
|
|
379
|
+
forceSave() {
|
|
380
|
+
// Temporarily disable debouncing for force save
|
|
381
|
+
const originalLastSave = this.lastSave;
|
|
382
|
+
this.lastSave = 0;
|
|
383
|
+
this.saveState();
|
|
384
|
+
this.lastSave = originalLastSave;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Save state before applying format (called by editor)
|
|
389
|
+
*/
|
|
390
|
+
saveBeforeFormat() {
|
|
391
|
+
this.forceSave();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Destroy module
|
|
396
|
+
*/
|
|
397
|
+
destroy() {
|
|
398
|
+
// Remove event listeners
|
|
399
|
+
if (this._onInput) {
|
|
400
|
+
this.editor.editor.removeEventListener('input', this._onInput);
|
|
401
|
+
this.editor.editor.removeEventListener('keydown', this._onKeydownSave);
|
|
402
|
+
this.editor.editor.removeEventListener('keydown', this._onUndoRedo);
|
|
403
|
+
this.editor.wrapper.removeEventListener('click', this._onToolbarClick);
|
|
404
|
+
this._onInput = this._onKeydownSave = this._onUndoRedo = this._onToolbarClick = null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Disconnect mutation observer
|
|
408
|
+
if (this.mutationObserver) {
|
|
409
|
+
this.mutationObserver.disconnect();
|
|
410
|
+
this.mutationObserver = null;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Clear timeout
|
|
414
|
+
if (this.mutationTimeout) {
|
|
415
|
+
clearTimeout(this.mutationTimeout);
|
|
416
|
+
this.mutationTimeout = null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
this.stack = [];
|
|
420
|
+
this.index = -1;
|
|
421
|
+
this.savedSelection = null;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export default History;
|