@oix1987/yjd 1.0.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +223 -142
  3. package/core.js +82 -0
  4. package/dist/core.esm.js +2 -0
  5. package/dist/core.esm.js.map +1 -0
  6. package/dist/rich-editor.esm.js +1 -1
  7. package/dist/rich-editor.esm.js.map +1 -1
  8. package/dist/rich-editor.min.js +1 -1
  9. package/dist/rich-editor.min.js.map +1 -1
  10. package/index.d.ts +230 -103
  11. package/index.js +297 -0
  12. package/lib/core/editor.js +1885 -0
  13. package/lib/core/format.js +540 -0
  14. package/lib/core/module.js +81 -0
  15. package/lib/core/registry.js +158 -0
  16. package/lib/formats/background.js +213 -0
  17. package/lib/formats/bold.js +49 -0
  18. package/lib/formats/capitalization.js +579 -0
  19. package/lib/formats/color.js +183 -0
  20. package/lib/formats/emoji.js +282 -0
  21. package/lib/formats/font-family.js +548 -0
  22. package/lib/formats/heading.js +502 -0
  23. package/lib/formats/image.js +341 -0
  24. package/lib/formats/import.js +385 -0
  25. package/lib/formats/indent.js +297 -0
  26. package/lib/formats/italic.js +27 -0
  27. package/lib/formats/line-height.js +562 -0
  28. package/lib/formats/link.js +251 -0
  29. package/lib/formats/list.js +635 -0
  30. package/lib/formats/strike.js +31 -0
  31. package/lib/formats/subscript.js +40 -0
  32. package/lib/formats/superscript.js +39 -0
  33. package/lib/formats/table.js +293 -0
  34. package/lib/formats/tag.js +304 -0
  35. package/lib/formats/text-align.js +422 -0
  36. package/lib/formats/text-size.js +498 -0
  37. package/lib/formats/underline.js +30 -0
  38. package/lib/formats/video.js +381 -0
  39. package/lib/modules/block-toolbar.js +639 -0
  40. package/lib/modules/code-view.js +447 -0
  41. package/lib/modules/find-replace.js +273 -0
  42. package/lib/modules/history.js +425 -0
  43. package/lib/modules/mention.js +200 -0
  44. package/lib/modules/resize-handles.js +701 -0
  45. package/lib/modules/slash-menu.js +183 -0
  46. package/lib/modules/table-toolbar.js +635 -0
  47. package/lib/modules/toolbar.js +607 -0
  48. package/lib/serialize.js +241 -0
  49. package/lib/static.js +28 -0
  50. package/lib/styles-loader.js +142 -0
  51. package/{dist → lib}/styles.css +1392 -35
  52. package/lib/styles.css.js +2 -0
  53. package/lib/styles.min.css +1 -0
  54. package/lib/ui/color-picker.js +296 -0
  55. package/lib/ui/customselect.js +351 -0
  56. package/lib/ui/emoji-picker.js +196 -0
  57. package/lib/ui/icons.js +145 -0
  58. package/lib/ui/image-popup.js +435 -0
  59. package/lib/ui/import-popup.js +288 -0
  60. package/lib/ui/link-popup.js +139 -0
  61. package/lib/ui/list-picker.js +307 -0
  62. package/lib/ui/select-button.js +68 -0
  63. package/lib/ui/table-popup.js +171 -0
  64. package/lib/ui/tag-popup.js +249 -0
  65. package/lib/ui/text-align-picker.js +278 -0
  66. package/lib/ui/video-popup.js +413 -0
  67. package/lib/utils/exec-command.js +72 -0
  68. package/lib/utils/history-helper.js +50 -0
  69. package/lib/utils/popup-helper.js +219 -0
  70. package/lib/utils/popup-positioning.js +234 -0
  71. package/lib/utils/sanitize.js +164 -0
  72. package/package.json +51 -32
  73. package/umd-entry.js +19 -0
@@ -0,0 +1,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;