suneditor 3.0.0-rc.4 → 3.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/README.md +4 -3
- package/dist/suneditor-contents.min.css +1 -1
- package/dist/suneditor.min.css +1 -1
- package/dist/suneditor.min.js +1 -1
- package/package.json +10 -6
- package/src/assets/design/color.css +14 -2
- package/src/assets/design/typography.css +5 -0
- package/src/assets/icons/defaultIcons.js +22 -4
- package/src/assets/suneditor-contents.css +1 -1
- package/src/assets/suneditor.css +312 -18
- package/src/core/config/eventManager.js +6 -9
- package/src/core/editor.js +1 -1
- package/src/core/event/actions/index.js +5 -0
- package/src/core/event/effects/keydown.registry.js +25 -0
- package/src/core/event/eventOrchestrator.js +69 -2
- package/src/core/event/handlers/handler_ww_mouse.js +1 -0
- package/src/core/event/rules/keydown.rule.backspace.js +9 -1
- package/src/core/kernel/coreKernel.js +4 -0
- package/src/core/kernel/store.js +2 -0
- package/src/core/logic/dom/char.js +11 -0
- package/src/core/logic/dom/format.js +22 -0
- package/src/core/logic/dom/html.js +126 -11
- package/src/core/logic/dom/nodeTransform.js +13 -0
- package/src/core/logic/dom/offset.js +100 -37
- package/src/core/logic/dom/selection.js +54 -22
- package/src/core/logic/panel/finder.js +982 -0
- package/src/core/logic/panel/menu.js +8 -6
- package/src/core/logic/panel/toolbar.js +112 -19
- package/src/core/logic/panel/viewer.js +214 -43
- package/src/core/logic/shell/_commandExecutor.js +7 -1
- package/src/core/logic/shell/commandDispatcher.js +1 -1
- package/src/core/logic/shell/component.js +5 -7
- package/src/core/logic/shell/history.js +24 -0
- package/src/core/logic/shell/shortcuts.js +3 -3
- package/src/core/logic/shell/ui.js +25 -26
- package/src/core/schema/frameContext.js +15 -1
- package/src/core/schema/options.js +180 -39
- package/src/core/section/constructor.js +61 -20
- package/src/core/section/documentType.js +2 -2
- package/src/events.js +12 -0
- package/src/helper/clipboard.js +1 -1
- package/src/helper/converter.js +15 -0
- package/src/helper/dom/domQuery.js +12 -0
- package/src/helper/dom/domUtils.js +26 -14
- package/src/helper/index.js +3 -0
- package/src/helper/markdown.js +876 -0
- package/src/interfaces/plugins.js +7 -5
- package/src/langs/ckb.js +9 -0
- package/src/langs/cs.js +9 -0
- package/src/langs/da.js +9 -0
- package/src/langs/de.js +9 -0
- package/src/langs/en.js +9 -0
- package/src/langs/es.js +9 -0
- package/src/langs/fa.js +9 -0
- package/src/langs/fr.js +9 -0
- package/src/langs/he.js +9 -0
- package/src/langs/hu.js +9 -0
- package/src/langs/it.js +9 -0
- package/src/langs/ja.js +9 -0
- package/src/langs/km.js +9 -0
- package/src/langs/ko.js +9 -0
- package/src/langs/lv.js +9 -0
- package/src/langs/nl.js +9 -0
- package/src/langs/pl.js +9 -0
- package/src/langs/pt_br.js +9 -0
- package/src/langs/ro.js +9 -0
- package/src/langs/ru.js +9 -0
- package/src/langs/se.js +9 -0
- package/src/langs/tr.js +9 -0
- package/src/langs/uk.js +9 -0
- package/src/langs/ur.js +9 -0
- package/src/langs/zh_cn.js +9 -0
- package/src/modules/contract/Browser.js +31 -1
- package/src/modules/contract/ColorPicker.js +6 -0
- package/src/modules/contract/Controller.js +77 -39
- package/src/modules/contract/Figure.js +57 -0
- package/src/modules/contract/Modal.js +6 -0
- package/src/modules/manager/ApiManager.js +53 -4
- package/src/modules/manager/FileManager.js +18 -1
- package/src/modules/ui/ModalAnchorEditor.js +35 -2
- package/src/modules/ui/SelectMenu.js +44 -12
- package/src/plugins/browser/fileBrowser.js +5 -2
- package/src/plugins/command/codeBlock.js +324 -0
- package/src/plugins/command/exportPDF.js +15 -3
- package/src/plugins/command/fileUpload.js +4 -1
- package/src/plugins/dropdown/backgroundColor.js +5 -1
- package/src/plugins/dropdown/blockStyle.js +8 -2
- package/src/plugins/dropdown/fontColor.js +5 -1
- package/src/plugins/dropdown/hr.js +6 -0
- package/src/plugins/dropdown/layout.js +4 -1
- package/src/plugins/dropdown/lineHeight.js +3 -0
- package/src/plugins/dropdown/paragraphStyle.js +5 -5
- package/src/plugins/dropdown/table/index.js +4 -1
- package/src/plugins/dropdown/table/render/table.html.js +1 -1
- package/src/plugins/dropdown/table/services/table.grid.js +16 -8
- package/src/plugins/dropdown/table/services/table.style.js +5 -9
- package/src/plugins/dropdown/template.js +3 -0
- package/src/plugins/dropdown/textStyle.js +5 -1
- package/src/plugins/field/mention.js +5 -1
- package/src/plugins/index.js +3 -0
- package/src/plugins/input/fontSize.js +10 -3
- package/src/plugins/modal/audio.js +7 -3
- package/src/plugins/modal/embed.js +23 -20
- package/src/plugins/modal/image/index.js +5 -1
- package/src/plugins/modal/math.js +7 -2
- package/src/plugins/modal/video/index.js +21 -4
- package/src/themes/cobalt.css +13 -4
- package/src/themes/cream.css +11 -2
- package/src/themes/dark.css +13 -4
- package/src/themes/midnight.css +13 -4
- package/src/typedef.js +4 -4
- package/types/assets/icons/defaultIcons.d.ts +12 -1
- package/types/assets/suneditor.css.d.ts +1 -1
- package/types/core/config/eventManager.d.ts +6 -8
- package/types/core/event/actions/index.d.ts +1 -0
- package/types/core/event/effects/keydown.registry.d.ts +2 -0
- package/types/core/event/eventOrchestrator.d.ts +2 -1
- package/types/core/kernel/coreKernel.d.ts +5 -0
- package/types/core/kernel/store.d.ts +5 -0
- package/types/core/logic/dom/char.d.ts +11 -0
- package/types/core/logic/dom/format.d.ts +22 -0
- package/types/core/logic/dom/html.d.ts +16 -0
- package/types/core/logic/dom/nodeTransform.d.ts +13 -0
- package/types/core/logic/dom/offset.d.ts +23 -2
- package/types/core/logic/dom/selection.d.ts +9 -3
- package/types/core/logic/panel/finder.d.ts +83 -0
- package/types/core/logic/panel/toolbar.d.ts +14 -1
- package/types/core/logic/panel/viewer.d.ts +22 -2
- package/types/core/logic/shell/shortcuts.d.ts +1 -1
- package/types/core/schema/frameContext.d.ts +22 -0
- package/types/core/schema/options.d.ts +362 -79
- package/types/events.d.ts +11 -0
- package/types/helper/converter.d.ts +15 -0
- package/types/helper/dom/domQuery.d.ts +12 -0
- package/types/helper/dom/domUtils.d.ts +23 -2
- package/types/helper/index.d.ts +5 -0
- package/types/helper/markdown.d.ts +27 -0
- package/types/interfaces/plugins.d.ts +7 -5
- package/types/langs/_Lang.d.ts +9 -0
- package/types/modules/contract/Browser.d.ts +36 -2
- package/types/modules/contract/ColorPicker.d.ts +6 -0
- package/types/modules/contract/Controller.d.ts +35 -1
- package/types/modules/contract/Figure.d.ts +57 -0
- package/types/modules/contract/Modal.d.ts +6 -0
- package/types/modules/manager/ApiManager.d.ts +26 -0
- package/types/modules/manager/FileManager.d.ts +17 -0
- package/types/modules/ui/ModalAnchorEditor.d.ts +41 -4
- package/types/modules/ui/SelectMenu.d.ts +40 -2
- package/types/plugins/browser/fileBrowser.d.ts +10 -4
- package/types/plugins/command/codeBlock.d.ts +53 -0
- package/types/plugins/command/fileUpload.d.ts +8 -2
- package/types/plugins/dropdown/backgroundColor.d.ts +10 -2
- package/types/plugins/dropdown/blockStyle.d.ts +14 -2
- package/types/plugins/dropdown/fontColor.d.ts +10 -2
- package/types/plugins/dropdown/hr.d.ts +12 -0
- package/types/plugins/dropdown/layout.d.ts +8 -2
- package/types/plugins/dropdown/lineHeight.d.ts +6 -0
- package/types/plugins/dropdown/paragraphStyle.d.ts +14 -3
- package/types/plugins/dropdown/table/index.d.ts +9 -3
- package/types/plugins/dropdown/template.d.ts +6 -0
- package/types/plugins/dropdown/textStyle.d.ts +10 -2
- package/types/plugins/field/mention.d.ts +10 -2
- package/types/plugins/index.d.ts +3 -0
- package/types/plugins/input/fontSize.d.ts +18 -4
- package/types/plugins/modal/audio.d.ts +14 -6
- package/types/plugins/modal/embed.d.ts +44 -38
- package/types/plugins/modal/image/index.d.ts +9 -1
- package/types/plugins/modal/link.d.ts +6 -2
- package/types/plugins/modal/math.d.ts +23 -5
- package/types/plugins/modal/video/index.d.ts +49 -9
- package/types/typedef.d.ts +5 -2
|
@@ -0,0 +1,982 @@
|
|
|
1
|
+
import { dom, env } from '../../../helper';
|
|
2
|
+
|
|
3
|
+
const { _d, _w } = env;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @description Find/Replace feature
|
|
7
|
+
*/
|
|
8
|
+
class Finder {
|
|
9
|
+
#$;
|
|
10
|
+
#store;
|
|
11
|
+
|
|
12
|
+
// DOM
|
|
13
|
+
#panel;
|
|
14
|
+
#findInput;
|
|
15
|
+
#replaceInput;
|
|
16
|
+
#countDisplay;
|
|
17
|
+
#replaceRow;
|
|
18
|
+
|
|
19
|
+
// option buttons
|
|
20
|
+
#btnCase;
|
|
21
|
+
#btnWord;
|
|
22
|
+
#btnRegex;
|
|
23
|
+
|
|
24
|
+
// nav buttons
|
|
25
|
+
#btnPrev;
|
|
26
|
+
#btnNext;
|
|
27
|
+
#btnReplace;
|
|
28
|
+
#btnReplaceAll;
|
|
29
|
+
|
|
30
|
+
// State
|
|
31
|
+
#isOpen = false;
|
|
32
|
+
#isReplaceMode = true;
|
|
33
|
+
#matches = [];
|
|
34
|
+
#currentIndex = -1;
|
|
35
|
+
#searchTerm = '';
|
|
36
|
+
#opts = { matchCase: false, wholeWord: false, regex: false };
|
|
37
|
+
|
|
38
|
+
// Highlight
|
|
39
|
+
#useNativeHighlight;
|
|
40
|
+
#markElements = [];
|
|
41
|
+
#highlightDoc = null;
|
|
42
|
+
|
|
43
|
+
// Debounce, observer
|
|
44
|
+
#searchTimer = null;
|
|
45
|
+
#resizeObserver = null;
|
|
46
|
+
#bindCloseKey = null;
|
|
47
|
+
#contentObserver = null;
|
|
48
|
+
#internalUpdate = false;
|
|
49
|
+
|
|
50
|
+
/** @description Inject ::highlight() styles at runtime (avoids PostCSS parse errors). */
|
|
51
|
+
static #highlightStyleInjected = false;
|
|
52
|
+
static #injectHighlightStyles() {
|
|
53
|
+
if (Finder.#highlightStyleInjected) return;
|
|
54
|
+
Finder.#highlightStyleInjected = true;
|
|
55
|
+
const style = _d.createElement('style');
|
|
56
|
+
style.textContent =
|
|
57
|
+
'::highlight(se-find-match){background-color:var(--se-find-match-color,rgba(255,213,0,.4));color:inherit}' + '::highlight(se-find-current){background-color:var(--se-find-current-color,rgba(255,150,50,.7));color:inherit}';
|
|
58
|
+
_d.head.appendChild(style);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @constructor
|
|
63
|
+
* @param {SunEditor.Kernel} kernel
|
|
64
|
+
*/
|
|
65
|
+
constructor(kernel) {
|
|
66
|
+
this.#$ = kernel.$;
|
|
67
|
+
this.#store = kernel.store;
|
|
68
|
+
this.#useNativeHighlight = !!_w.Highlight && !!CSS?.highlights;
|
|
69
|
+
|
|
70
|
+
// Panel UI — only when finder_panel option is enabled
|
|
71
|
+
if (this.#$.options.get('finder_panel')) {
|
|
72
|
+
const panelEl = CreateHTML(this.#$);
|
|
73
|
+
|
|
74
|
+
this.#panel = panelEl.panel;
|
|
75
|
+
this.#findInput = panelEl.findInput;
|
|
76
|
+
this.#replaceInput = panelEl.replaceInput;
|
|
77
|
+
this.#countDisplay = panelEl.countDisplay;
|
|
78
|
+
this.#replaceRow = panelEl.replaceRow;
|
|
79
|
+
this.#btnCase = panelEl.btnCase;
|
|
80
|
+
this.#btnWord = panelEl.btnWord;
|
|
81
|
+
this.#btnRegex = panelEl.btnRegex;
|
|
82
|
+
this.#btnPrev = panelEl.btnPrev;
|
|
83
|
+
this.#btnNext = panelEl.btnNext;
|
|
84
|
+
this.#btnReplace = panelEl.btnReplace;
|
|
85
|
+
this.#btnReplaceAll = panelEl.btnReplaceAll;
|
|
86
|
+
|
|
87
|
+
this.#bindEvents();
|
|
88
|
+
|
|
89
|
+
// Append panel to root container (between toolbar and wrapper)
|
|
90
|
+
const rootFc = this.#$.frameRoots.values().next().value;
|
|
91
|
+
const container = rootFc.get('container');
|
|
92
|
+
if (this.#store.mode.isBottom) {
|
|
93
|
+
const toolbar = this.#$.context.get('toolbar_main');
|
|
94
|
+
container.insertBefore(this.#panel, toolbar);
|
|
95
|
+
} else {
|
|
96
|
+
const wrapper = container.querySelector('.se-wrapper');
|
|
97
|
+
container.insertBefore(this.#panel, wrapper);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Update sticky top (responsive resize, more layer, etc.)
|
|
101
|
+
if (env.isResizeObserverSupported) {
|
|
102
|
+
this.#resizeObserver = new ResizeObserver(() => this.#updateStickyTop()).observe(this.#$.context.get('toolbar_main'));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @description Whether the panel is open.
|
|
109
|
+
* @returns {boolean}
|
|
110
|
+
*/
|
|
111
|
+
get isOpen() {
|
|
112
|
+
return this.#isOpen;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @description Opens the finder. With panel: shows UI. Without panel: activates search state only.
|
|
117
|
+
* @param {boolean} [replaceMode=true] Whether to show replace row
|
|
118
|
+
*/
|
|
119
|
+
open(replaceMode = true) {
|
|
120
|
+
const fc = this.#$.frameContext;
|
|
121
|
+
if (fc.get('isCodeView') || fc.get('isMarkdownView')) return;
|
|
122
|
+
|
|
123
|
+
this.#isOpen = true;
|
|
124
|
+
|
|
125
|
+
// Listen for wysiwyg content changes to refresh highlights
|
|
126
|
+
this.#addContentInputListener();
|
|
127
|
+
|
|
128
|
+
if (this.#panel) {
|
|
129
|
+
this.#btnPrev.disabled = true;
|
|
130
|
+
this.#btnNext.disabled = true;
|
|
131
|
+
this.#btnReplace.disabled = true;
|
|
132
|
+
this.#btnReplaceAll.disabled = true;
|
|
133
|
+
|
|
134
|
+
this.#updateStickyTop();
|
|
135
|
+
|
|
136
|
+
const sel = this.#$.selection.get();
|
|
137
|
+
const selectedText = sel && !sel.isCollapsed ? sel.toString().trim() : '';
|
|
138
|
+
dom.utils.addClass(this.#panel, 'se-find-replace-open');
|
|
139
|
+
this.#store.set('_preventBlur', true);
|
|
140
|
+
this.#addGlobalCloseEvent();
|
|
141
|
+
|
|
142
|
+
if (replaceMode) this.#toggleReplace(replaceMode);
|
|
143
|
+
if (selectedText) this.#findInput.value = selectedText;
|
|
144
|
+
|
|
145
|
+
this.#findInput.focus();
|
|
146
|
+
this.#findInput.select();
|
|
147
|
+
|
|
148
|
+
if (this.#findInput.value) this.#doSearch();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @description Updates the finder panel's sticky top position based on toolbar height.
|
|
154
|
+
*/
|
|
155
|
+
#updateStickyTop() {
|
|
156
|
+
if (!this.#isOpen || !this.#panel) return;
|
|
157
|
+
const stickyTop = this.#$.options.get('toolbar_sticky');
|
|
158
|
+
if (this.#store.mode.isBottom) {
|
|
159
|
+
this.#panel.style.top = 'auto';
|
|
160
|
+
this.#panel.style.bottom = stickyTop >= 0 ? stickyTop + this.#$.context.get('toolbar_main').offsetHeight + 'px' : '0px';
|
|
161
|
+
} else {
|
|
162
|
+
this.#panel.style.top = stickyTop >= 0 ? stickyTop + this.#$.context.get('toolbar_main').offsetHeight + 'px' : '0px';
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @description Closes the finder and clears highlights.
|
|
168
|
+
*/
|
|
169
|
+
close() {
|
|
170
|
+
if (!this.#isOpen) return;
|
|
171
|
+
|
|
172
|
+
this.#isOpen = false;
|
|
173
|
+
this.#clearHighlights();
|
|
174
|
+
this.#matches = [];
|
|
175
|
+
this.#currentIndex = -1;
|
|
176
|
+
this.#updateCount();
|
|
177
|
+
this.#removeContentInputListener();
|
|
178
|
+
|
|
179
|
+
if (this.#panel) {
|
|
180
|
+
dom.utils.removeClass(this.#panel, 'se-find-replace-open');
|
|
181
|
+
this.#removeGlobalCloseEvent();
|
|
182
|
+
this.#store.set('_preventBlur', false);
|
|
183
|
+
this.#$.focusManager.nativeFocus();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ──────────────────────────────────────────────────
|
|
188
|
+
// [[ PUBLIC API ]]
|
|
189
|
+
// ──────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* @description Navigate to next match (public for shortcut binding).
|
|
193
|
+
*/
|
|
194
|
+
findNext() {
|
|
195
|
+
if (!this.#isOpen || this.#matches.length === 0) return;
|
|
196
|
+
this.#currentIndex = (this.#currentIndex + 1) % this.#matches.length;
|
|
197
|
+
this.#goToMatch();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @description Navigate to previous match (public for shortcut binding).
|
|
202
|
+
*/
|
|
203
|
+
findPrev() {
|
|
204
|
+
if (!this.#isOpen || this.#matches.length === 0) return;
|
|
205
|
+
this.#currentIndex = (this.#currentIndex - 1 + this.#matches.length) % this.#matches.length;
|
|
206
|
+
this.#goToMatch();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* @description Search for a term in the editor content (headless API).
|
|
211
|
+
* @param {string} term Search term
|
|
212
|
+
* @param {Object} [options] Search options
|
|
213
|
+
* @param {boolean} [options.matchCase=false] Case-sensitive search
|
|
214
|
+
* @param {boolean} [options.wholeWord=false] Whole word search
|
|
215
|
+
* @param {boolean} [options.regex=false] Regex search
|
|
216
|
+
* @returns {number} Number of matches found
|
|
217
|
+
*/
|
|
218
|
+
search(term, { matchCase, wholeWord, regex } = {}) {
|
|
219
|
+
if (!this.#isOpen) this.open();
|
|
220
|
+
if (matchCase !== undefined) this.#opts.matchCase = matchCase;
|
|
221
|
+
if (wholeWord !== undefined) this.#opts.wholeWord = wholeWord;
|
|
222
|
+
if (regex !== undefined) this.#opts.regex = regex;
|
|
223
|
+
this.#searchTerm = term || '';
|
|
224
|
+
if (this.#findInput) this.#findInput.value = this.#searchTerm;
|
|
225
|
+
this.#doSearch();
|
|
226
|
+
return this.#matches.length;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @description Replace the current match (headless API).
|
|
231
|
+
* @param {string} replaceText Replacement text
|
|
232
|
+
*/
|
|
233
|
+
replace(replaceText) {
|
|
234
|
+
this.#replaceOne(replaceText);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* @description Replace all matches (headless API).
|
|
239
|
+
* @param {string} replaceText Replacement text
|
|
240
|
+
*/
|
|
241
|
+
replaceAll(replaceText) {
|
|
242
|
+
this.#replaceAll(replaceText);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @description Current match count and index.
|
|
247
|
+
* @returns {{ current: number, total: number }}
|
|
248
|
+
*/
|
|
249
|
+
get matchInfo() {
|
|
250
|
+
return { current: this.#currentIndex + 1, total: this.#matches.length };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* @description Re-run search with current term (debounced 300ms). Called on wysiwyg content change.
|
|
255
|
+
*/
|
|
256
|
+
refresh() {
|
|
257
|
+
if (!this.#isOpen || !this.#searchTerm || this.#internalUpdate) return;
|
|
258
|
+
this.#internalUpdate = true;
|
|
259
|
+
this.#removeMarkElements();
|
|
260
|
+
this.#markElements = [];
|
|
261
|
+
this.#internalUpdate = false;
|
|
262
|
+
clearTimeout(this.#searchTimer);
|
|
263
|
+
this.#searchTimer = setTimeout(() => this.#doSearch(), 300);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ──────────────────────────────────────────────────
|
|
267
|
+
// Global events (ESC close, content change)
|
|
268
|
+
// ──────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
/** @description Register global ESC keydown (capture) to close finder. */
|
|
271
|
+
#addGlobalCloseEvent() {
|
|
272
|
+
this.#removeGlobalCloseEvent();
|
|
273
|
+
this.#bindCloseKey = this.#$.eventManager.addGlobalEvent(
|
|
274
|
+
'keydown',
|
|
275
|
+
(e) => {
|
|
276
|
+
if (e.key === 'Escape') {
|
|
277
|
+
e.preventDefault();
|
|
278
|
+
e.stopPropagation();
|
|
279
|
+
this.close();
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
true,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
#removeGlobalCloseEvent() {
|
|
287
|
+
this.#bindCloseKey &&= this.#$.eventManager.removeGlobalEvent(this.#bindCloseKey);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** @description Listen for wysiwyg edits to auto-refresh highlights. */
|
|
291
|
+
#addContentInputListener() {
|
|
292
|
+
this.#removeContentInputListener();
|
|
293
|
+
const wysiwyg = this.#$.frameContext.get('wysiwyg');
|
|
294
|
+
this.#contentObserver = new MutationObserver(() => this.refresh());
|
|
295
|
+
this.#contentObserver.observe(wysiwyg, { childList: true, subtree: true, characterData: true });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
#removeContentInputListener() {
|
|
299
|
+
if (this.#contentObserver) {
|
|
300
|
+
this.#contentObserver.disconnect();
|
|
301
|
+
this.#contentObserver = null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** @description Bind panel UI events (input, click delegation, tab navigation, blur prevention). Panel-only. */
|
|
306
|
+
#bindEvents() {
|
|
307
|
+
const em = this.#$.eventManager;
|
|
308
|
+
|
|
309
|
+
// find input
|
|
310
|
+
em.addEvent(this.#findInput, 'input', this.#OnFindInput.bind(this));
|
|
311
|
+
em.addEvent(this.#findInput, 'keydown', this.#OnFindKeydown.bind(this));
|
|
312
|
+
|
|
313
|
+
// replace input
|
|
314
|
+
em.addEvent(this.#replaceInput, 'keydown', this.#OnReplaceKeydown.bind(this));
|
|
315
|
+
|
|
316
|
+
// panel click
|
|
317
|
+
em.addEvent(this.#panel, 'click', this.#OnPanelAction.bind(this));
|
|
318
|
+
|
|
319
|
+
// prevent blur on panel interaction + Gecko :active fix
|
|
320
|
+
if (env.isGecko) {
|
|
321
|
+
em.addEvent(this.#panel, 'mousedown', (e) => {
|
|
322
|
+
if (e.target.tagName === 'BUTTON') {
|
|
323
|
+
e.preventDefault();
|
|
324
|
+
const btn = dom.query.getCommandTarget(e.target);
|
|
325
|
+
if (btn) this.#$.eventManager._injectActiveEvent(btn);
|
|
326
|
+
}
|
|
327
|
+
this.#store.set('_preventBlur', true);
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** @description Debounced search triggered on find input typing. */
|
|
333
|
+
#OnFindInput() {
|
|
334
|
+
if (!this.#$.options.get('finder_liveSearch')) return;
|
|
335
|
+
clearTimeout(this.#searchTimer);
|
|
336
|
+
this.#searchTimer = setTimeout(() => this.#doSearch(), 120);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* @description Find input keydown — ESC to close, Enter/Shift+Enter to navigate.
|
|
341
|
+
* When liveSearch is off, Enter triggers initial search; subsequent Enter navigates.
|
|
342
|
+
* @param {KeyboardEvent} e
|
|
343
|
+
*/
|
|
344
|
+
#OnFindKeydown(e) {
|
|
345
|
+
if (e.key === 'Escape') {
|
|
346
|
+
e.preventDefault();
|
|
347
|
+
this.close();
|
|
348
|
+
} else if (e.key === 'Enter') {
|
|
349
|
+
e.preventDefault();
|
|
350
|
+
if (!this.#$.options.get('finder_liveSearch') && this.#findInput.value !== this.#searchTerm) {
|
|
351
|
+
this.#doSearch();
|
|
352
|
+
} else if (e.shiftKey) {
|
|
353
|
+
this.findPrev();
|
|
354
|
+
} else {
|
|
355
|
+
this.findNext();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#OnReplaceKeydown(e) {
|
|
361
|
+
if (e.key === 'Escape') {
|
|
362
|
+
e.preventDefault();
|
|
363
|
+
this.close();
|
|
364
|
+
} else if (e.key === 'Enter') {
|
|
365
|
+
e.preventDefault();
|
|
366
|
+
this.#replaceOne();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* @description Panel action function
|
|
372
|
+
* @param {MouseEvent} e event
|
|
373
|
+
* @returns
|
|
374
|
+
*/
|
|
375
|
+
#OnPanelAction(e) {
|
|
376
|
+
const eventTarget = dom.query.getEventTarget(e);
|
|
377
|
+
const btn = dom.query.getCommandTarget(eventTarget);
|
|
378
|
+
if (!btn) return;
|
|
379
|
+
|
|
380
|
+
e.preventDefault();
|
|
381
|
+
const command = btn.getAttribute('data-command');
|
|
382
|
+
|
|
383
|
+
switch (command) {
|
|
384
|
+
case 'prev':
|
|
385
|
+
this.findPrev();
|
|
386
|
+
break;
|
|
387
|
+
case 'next':
|
|
388
|
+
this.findNext();
|
|
389
|
+
break;
|
|
390
|
+
case 'toggle-replace':
|
|
391
|
+
this.#toggleReplace(!this.#isReplaceMode);
|
|
392
|
+
break;
|
|
393
|
+
case 'close':
|
|
394
|
+
this.close();
|
|
395
|
+
break;
|
|
396
|
+
case 'replace':
|
|
397
|
+
this.#replaceOne();
|
|
398
|
+
break;
|
|
399
|
+
case 'replace-all':
|
|
400
|
+
this.#replaceAll();
|
|
401
|
+
break;
|
|
402
|
+
case 'opt-case':
|
|
403
|
+
this.#opts.matchCase = !this.#opts.matchCase;
|
|
404
|
+
dom.utils.toggleClass(this.#btnCase, 'on', this.#opts.matchCase);
|
|
405
|
+
this.#doSearch();
|
|
406
|
+
break;
|
|
407
|
+
case 'opt-word':
|
|
408
|
+
this.#opts.wholeWord = !this.#opts.wholeWord;
|
|
409
|
+
dom.utils.toggleClass(this.#btnWord, 'on', this.#opts.wholeWord);
|
|
410
|
+
this.#doSearch();
|
|
411
|
+
break;
|
|
412
|
+
case 'opt-regex':
|
|
413
|
+
this.#opts.regex = !this.#opts.regex;
|
|
414
|
+
dom.utils.toggleClass(this.#btnRegex, 'on', this.#opts.regex);
|
|
415
|
+
this.#doSearch();
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* @description Toggle replace row visibility. Panel-only.
|
|
422
|
+
* @param {boolean} show
|
|
423
|
+
*/
|
|
424
|
+
#toggleReplace(show) {
|
|
425
|
+
this.#isReplaceMode = show;
|
|
426
|
+
this.#replaceRow.style.display = show ? '' : 'none';
|
|
427
|
+
dom.utils.toggleClass(this.#panel.querySelector('.se-find-toggle-replace'), 'on', show);
|
|
428
|
+
if (show) {
|
|
429
|
+
this.#replaceInput.focus();
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* ──────────────────────────────────────────────────
|
|
435
|
+
* [ Functions ]
|
|
436
|
+
* ──────────────────────────────────────────────────
|
|
437
|
+
*/
|
|
438
|
+
|
|
439
|
+
/** @description Core search — clear previous, find matches, highlight, update count. */
|
|
440
|
+
#doSearch() {
|
|
441
|
+
const term = this.#findInput ? this.#findInput.value : this.#searchTerm;
|
|
442
|
+
this.#internalUpdate = true;
|
|
443
|
+
this.#clearHighlights();
|
|
444
|
+
this.#matches = [];
|
|
445
|
+
this.#currentIndex = -1;
|
|
446
|
+
|
|
447
|
+
if (!term) {
|
|
448
|
+
this.#searchTerm = '';
|
|
449
|
+
this.#updateCount();
|
|
450
|
+
dom.utils.removeClass(this.#findInput, 'se-find-no-match');
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
this.#searchTerm = term;
|
|
455
|
+
const wysiwyg = this.#$.frameContext.get('wysiwyg');
|
|
456
|
+
this.#highlightDoc = this.#getDocument();
|
|
457
|
+
this.#matches = this.#findAllMatches(term, wysiwyg);
|
|
458
|
+
|
|
459
|
+
if (this.#matches.length > 0) {
|
|
460
|
+
this.#currentIndex = 0;
|
|
461
|
+
this.#highlightAll();
|
|
462
|
+
this.#goToMatch();
|
|
463
|
+
dom.utils.removeClass(this.#findInput, 'se-find-no-match');
|
|
464
|
+
} else if (this.#findInput) {
|
|
465
|
+
dom.utils.toggleClass(this.#findInput, 'se-find-no-match', term.length > 0);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
this.#updateCount();
|
|
469
|
+
this.#internalUpdate = false;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* @description Find all text matches. Text nodes are concatenated with `\n` between
|
|
474
|
+
* different line elements to prevent cross-line matching. Single regex execution.
|
|
475
|
+
* @param {string} term Search term
|
|
476
|
+
* @param {HTMLElement} root Root element to search within
|
|
477
|
+
* @returns {Range[]} Array of Range objects
|
|
478
|
+
*/
|
|
479
|
+
#findAllMatches(term, root) {
|
|
480
|
+
const matches = [];
|
|
481
|
+
if (!term || !root) return matches;
|
|
482
|
+
|
|
483
|
+
// Build regex
|
|
484
|
+
const flags = this.#opts.matchCase ? 'g' : 'gi';
|
|
485
|
+
let pattern;
|
|
486
|
+
if (this.#opts.regex) {
|
|
487
|
+
try {
|
|
488
|
+
pattern = new RegExp(term, flags);
|
|
489
|
+
} catch {
|
|
490
|
+
return matches;
|
|
491
|
+
}
|
|
492
|
+
} else {
|
|
493
|
+
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
494
|
+
pattern = this.#opts.wholeWord ? new RegExp(`\\b${escaped}\\b`, flags) : new RegExp(escaped, flags);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const doc = this.#highlightDoc;
|
|
498
|
+
const format = this.#$.format;
|
|
499
|
+
|
|
500
|
+
// Collect text nodes, insert \n between different lines
|
|
501
|
+
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
|
|
502
|
+
/** @type {Array<{node: Text, start: number}>} */
|
|
503
|
+
const textNodes = [];
|
|
504
|
+
let fullText = '';
|
|
505
|
+
let prevLine = null;
|
|
506
|
+
let tn;
|
|
507
|
+
|
|
508
|
+
while ((tn = walker.nextNode())) {
|
|
509
|
+
const line = format.getLine(tn, null) || root;
|
|
510
|
+
if (prevLine && line !== prevLine) fullText += '\n';
|
|
511
|
+
prevLine = line;
|
|
512
|
+
textNodes.push({ node: tn, start: fullText.length });
|
|
513
|
+
fullText += tn.textContent.replace(/\u00A0/g, ' ');
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (!fullText) return matches;
|
|
517
|
+
|
|
518
|
+
// regex search
|
|
519
|
+
pattern.lastIndex = 0;
|
|
520
|
+
let match;
|
|
521
|
+
let nodeIdx = 0;
|
|
522
|
+
while ((match = pattern.exec(fullText)) !== null) {
|
|
523
|
+
if (match[0].length === 0) {
|
|
524
|
+
pattern.lastIndex++;
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const mStart = match.index;
|
|
529
|
+
const mEnd = mStart + match[0].length;
|
|
530
|
+
|
|
531
|
+
const range = doc.createRange();
|
|
532
|
+
let startSet = false;
|
|
533
|
+
|
|
534
|
+
for (let i = nodeIdx; i < textNodes.length; i++) {
|
|
535
|
+
const t = textNodes[i];
|
|
536
|
+
const tEnd = t.start + t.node.textContent.length;
|
|
537
|
+
|
|
538
|
+
if (!startSet && mStart >= t.start && mStart < tEnd) {
|
|
539
|
+
range.setStart(t.node, mStart - t.start);
|
|
540
|
+
startSet = true;
|
|
541
|
+
nodeIdx = i;
|
|
542
|
+
}
|
|
543
|
+
if (startSet && mEnd > t.start && mEnd <= tEnd) {
|
|
544
|
+
range.setEnd(t.node, mEnd - t.start);
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (startSet) matches.push(range);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return matches;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** @description Apply highlights to all matches (native API or mark fallback). */
|
|
556
|
+
#highlightAll() {
|
|
557
|
+
if (this.#matches.length === 0) return;
|
|
558
|
+
|
|
559
|
+
const isIframe = this.#$.frameContext.get('options').get('iframe');
|
|
560
|
+
|
|
561
|
+
// CSS Custom Highlight API doesn't work cross-document (iframe).
|
|
562
|
+
if (this.#useNativeHighlight && !isIframe) {
|
|
563
|
+
this.#applyNativeHighlight();
|
|
564
|
+
} else {
|
|
565
|
+
this.#applyMarkFallback();
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** @description Apply CSS Custom Highlight API highlights. */
|
|
570
|
+
#applyNativeHighlight() {
|
|
571
|
+
Finder.#injectHighlightStyles();
|
|
572
|
+
// eslint-disable-next-line
|
|
573
|
+
const allRanges = new Highlight(...this.#matches);
|
|
574
|
+
CSS.highlights.set('se-find-match', allRanges);
|
|
575
|
+
this.#updateCurrentNativeHighlight();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** @description Update the "current match" native highlight. */
|
|
579
|
+
#updateCurrentNativeHighlight() {
|
|
580
|
+
if (this.#currentIndex >= 0 && this.#currentIndex < this.#matches.length) {
|
|
581
|
+
const current = new Highlight(this.#matches[this.#currentIndex]);
|
|
582
|
+
CSS.highlights.set('se-find-current', current);
|
|
583
|
+
} else {
|
|
584
|
+
CSS.highlights.delete('se-find-current');
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** @description Fallback: wrap matches with `<mark>` elements. */
|
|
589
|
+
#applyMarkFallback() {
|
|
590
|
+
this.#removeMarkElements();
|
|
591
|
+
|
|
592
|
+
const doc = this.#highlightDoc;
|
|
593
|
+
|
|
594
|
+
// Process matches in reverse to preserve earlier Range positions
|
|
595
|
+
for (let i = this.#matches.length - 1; i >= 0; i--) {
|
|
596
|
+
const range = this.#matches[i];
|
|
597
|
+
const marks = this.#wrapRangeTextNodes(doc, range, i);
|
|
598
|
+
for (let m = 0; m < marks.length; m++) {
|
|
599
|
+
this.#markElements.push(marks[m]);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
this.#markElements.reverse();
|
|
604
|
+
this.#updateCurrentMarkHighlight();
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* @description Wrap each text node segment within a Range with a `<mark>`, without extractContents.
|
|
609
|
+
* @param {Document} doc
|
|
610
|
+
* @param {Range} range
|
|
611
|
+
* @param {number} idx - match index
|
|
612
|
+
* @returns {HTMLElement[]} created mark elements
|
|
613
|
+
*/
|
|
614
|
+
#wrapRangeTextNodes(doc, range, idx) {
|
|
615
|
+
const sc = /** @type {Text} */ (range.startContainer);
|
|
616
|
+
const ec = /** @type {Text} */ (range.endContainer);
|
|
617
|
+
const marks = [];
|
|
618
|
+
|
|
619
|
+
if (sc === ec) {
|
|
620
|
+
// Single text node — most common case
|
|
621
|
+
marks.push(this.#wrapTextSegment(doc, sc, range.startOffset, range.endOffset, idx));
|
|
622
|
+
} else {
|
|
623
|
+
// Cross-node: collect text nodes between start and end containers
|
|
624
|
+
const textNodes = [sc];
|
|
625
|
+
let node = sc;
|
|
626
|
+
while (node && node !== ec) {
|
|
627
|
+
node = this.#nextTextNode(node, range.commonAncestorContainer);
|
|
628
|
+
if (node) textNodes.push(node);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Wrap in reverse to preserve offsets
|
|
632
|
+
for (let i = textNodes.length - 1; i >= 0; i--) {
|
|
633
|
+
const tn = textNodes[i];
|
|
634
|
+
const start = tn === sc ? range.startOffset : 0;
|
|
635
|
+
const end = tn === ec ? range.endOffset : tn.textContent.length;
|
|
636
|
+
if (start >= end) continue;
|
|
637
|
+
marks.push(this.#wrapTextSegment(doc, tn, start, end, idx));
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return marks;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* @description Wrap a portion of a text node with a `<mark>`.
|
|
646
|
+
* @param {Document} doc
|
|
647
|
+
* @param {Text} textNode
|
|
648
|
+
* @param {number} start
|
|
649
|
+
* @param {number} end
|
|
650
|
+
* @param {number} idx
|
|
651
|
+
* @returns {HTMLElement}
|
|
652
|
+
*/
|
|
653
|
+
#wrapTextSegment(doc, textNode, start, end, idx) {
|
|
654
|
+
const matchNode = start > 0 ? textNode.splitText(start) : textNode;
|
|
655
|
+
if (end - start < matchNode.textContent.length) {
|
|
656
|
+
matchNode.splitText(end - start);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const mark = doc.createElement('mark');
|
|
660
|
+
mark.className = 'se-find-mark';
|
|
661
|
+
mark.setAttribute('data-se-find-idx', String(idx));
|
|
662
|
+
matchNode.parentNode.insertBefore(mark, matchNode);
|
|
663
|
+
mark.appendChild(matchNode);
|
|
664
|
+
return mark;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* @description Get the next text node in document order within a boundary.
|
|
669
|
+
* @param {Node} node
|
|
670
|
+
* @param {Node} boundary
|
|
671
|
+
* @returns {Text|null}
|
|
672
|
+
*/
|
|
673
|
+
#nextTextNode(node, boundary) {
|
|
674
|
+
let n = node;
|
|
675
|
+
while (n) {
|
|
676
|
+
if (n.firstChild) {
|
|
677
|
+
n = n.firstChild;
|
|
678
|
+
} else {
|
|
679
|
+
while (n && !n.nextSibling) {
|
|
680
|
+
n = n.parentNode;
|
|
681
|
+
if (n === boundary) return null;
|
|
682
|
+
}
|
|
683
|
+
if (!n) return null;
|
|
684
|
+
n = n.nextSibling;
|
|
685
|
+
}
|
|
686
|
+
if (n.nodeType === 3) return /** @type {Text} */ (n);
|
|
687
|
+
}
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** @description Update the "current match" mark element class. */
|
|
692
|
+
#updateCurrentMarkHighlight() {
|
|
693
|
+
for (const m of this.#markElements) {
|
|
694
|
+
dom.utils.removeClass(m, 'se-find-current');
|
|
695
|
+
}
|
|
696
|
+
if (this.#currentIndex >= 0) {
|
|
697
|
+
for (const m of this.#markElements) {
|
|
698
|
+
if (m.getAttribute('data-se-find-idx') === String(this.#currentIndex)) {
|
|
699
|
+
dom.utils.addClass(m, 'se-find-current');
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** @description Remove all highlights (native + mark). */
|
|
706
|
+
#clearHighlights() {
|
|
707
|
+
const isIframe = this.#$.frameContext.get('options').get('iframe');
|
|
708
|
+
|
|
709
|
+
if (this.#useNativeHighlight && !isIframe) {
|
|
710
|
+
CSS.highlights.delete('se-find-match');
|
|
711
|
+
CSS.highlights.delete('se-find-current');
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
this.#removeMarkElements();
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** @description Unwrap all `<mark>` elements and normalize text nodes. */
|
|
718
|
+
#removeMarkElements() {
|
|
719
|
+
const wysiwyg = this.#$.frameContext.get('wysiwyg');
|
|
720
|
+
const marks = wysiwyg.querySelectorAll('mark.se-find-mark');
|
|
721
|
+
if (marks.length === 0) return;
|
|
722
|
+
|
|
723
|
+
for (const mark of marks) {
|
|
724
|
+
const parent = mark.parentNode;
|
|
725
|
+
if (!parent) continue;
|
|
726
|
+
while (mark.firstChild) {
|
|
727
|
+
parent.insertBefore(mark.firstChild, mark);
|
|
728
|
+
}
|
|
729
|
+
parent.removeChild(mark);
|
|
730
|
+
parent.normalize();
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
this.#markElements = [];
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ──────────────────────────────────────────────────
|
|
737
|
+
// Navigation
|
|
738
|
+
// ──────────────────────────────────────────────────
|
|
739
|
+
|
|
740
|
+
/** @description Scroll to current match and update active highlight. */
|
|
741
|
+
#goToMatch() {
|
|
742
|
+
if (this.#currentIndex < 0 || this.#currentIndex >= this.#matches.length) return;
|
|
743
|
+
|
|
744
|
+
const isIframe = this.#$.frameContext.get('options').get('iframe');
|
|
745
|
+
|
|
746
|
+
if (this.#useNativeHighlight && !isIframe) {
|
|
747
|
+
// Native highlight: update current highlight and scroll
|
|
748
|
+
this.#updateCurrentNativeHighlight();
|
|
749
|
+
|
|
750
|
+
const range = this.#matches[this.#currentIndex];
|
|
751
|
+
this.#$.selection.scrollTo(range, { behavior: 'auto' });
|
|
752
|
+
} else {
|
|
753
|
+
// Mark fallback: update active mark
|
|
754
|
+
this.#updateCurrentMarkHighlight();
|
|
755
|
+
|
|
756
|
+
const currentMark = this.#markElements.find((m) => m.getAttribute('data-se-find-idx') === String(this.#currentIndex));
|
|
757
|
+
if (currentMark) {
|
|
758
|
+
this.#$.selection.scrollTo(currentMark, { behavior: 'auto', noFocus: true });
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
this.#updateCount();
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ──────────────────────────────────────────────────
|
|
766
|
+
// Replace
|
|
767
|
+
// ──────────────────────────────────────────────────
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* @description Replace current match, then re-search.
|
|
771
|
+
* @param {string} [replaceText] Falls back to replace input value if omitted.
|
|
772
|
+
*/
|
|
773
|
+
#replaceOne(replaceText) {
|
|
774
|
+
if (this.#currentIndex < 0 || this.#matches.length === 0) return;
|
|
775
|
+
|
|
776
|
+
this.#clearHighlights();
|
|
777
|
+
|
|
778
|
+
// Re-search to get fresh ranges
|
|
779
|
+
const wysiwyg = this.#$.frameContext.get('wysiwyg');
|
|
780
|
+
const freshMatches = this.#findAllMatches(this.#searchTerm, wysiwyg);
|
|
781
|
+
if (this.#currentIndex >= freshMatches.length) return;
|
|
782
|
+
|
|
783
|
+
this.#replaceRange(freshMatches[this.#currentIndex], replaceText ?? (this.#replaceInput ? this.#replaceInput.value : ''));
|
|
784
|
+
this.#$.history.push(false);
|
|
785
|
+
|
|
786
|
+
// Re-search
|
|
787
|
+
this.#doSearch();
|
|
788
|
+
if (this.#currentIndex >= this.#matches.length && this.#matches.length > 0) {
|
|
789
|
+
this.#currentIndex = 0;
|
|
790
|
+
this.#goToMatch();
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* @description Replace all matches in reverse order, then re-search.
|
|
796
|
+
* @param {string} [replaceText] Falls back to replace input value if omitted.
|
|
797
|
+
*/
|
|
798
|
+
#replaceAll(replaceText) {
|
|
799
|
+
if (this.#matches.length === 0) return;
|
|
800
|
+
|
|
801
|
+
this.#clearHighlights();
|
|
802
|
+
|
|
803
|
+
const wysiwyg = this.#$.frameContext.get('wysiwyg');
|
|
804
|
+
const freshMatches = this.#findAllMatches(this.#searchTerm, wysiwyg);
|
|
805
|
+
if (freshMatches.length === 0) return;
|
|
806
|
+
|
|
807
|
+
replaceText = replaceText ?? (this.#replaceInput ? this.#replaceInput.value : '');
|
|
808
|
+
|
|
809
|
+
// Replace in reverse order to preserve earlier positions
|
|
810
|
+
for (let i = freshMatches.length - 1; i >= 0; i--) {
|
|
811
|
+
this.#replaceRange(freshMatches[i], replaceText);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
wysiwyg.normalize();
|
|
815
|
+
this.#$.history.push(false);
|
|
816
|
+
|
|
817
|
+
// Re-search (should find 0)
|
|
818
|
+
this.#doSearch();
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* @description Replace a range's content with text.
|
|
823
|
+
* For cross-node ranges (e.g. `<b>1</b>23` matching "123"), the replacement text
|
|
824
|
+
* is inserted at the start node position, and the matched content across all spanned
|
|
825
|
+
* nodes is removed cleanly.
|
|
826
|
+
* @param {Range} range The range to replace
|
|
827
|
+
* @param {string} replaceText Replacement string
|
|
828
|
+
*/
|
|
829
|
+
#replaceRange(range, replaceText) {
|
|
830
|
+
const startNode = range.startContainer;
|
|
831
|
+
const doc = this.#highlightDoc;
|
|
832
|
+
const textNode = doc.createTextNode(replaceText);
|
|
833
|
+
|
|
834
|
+
if (startNode === range.endContainer) {
|
|
835
|
+
// Simple case: same text node
|
|
836
|
+
range.deleteContents();
|
|
837
|
+
range.insertNode(textNode);
|
|
838
|
+
startNode.parentNode?.normalize();
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Cross-node range: replace based on start node position
|
|
843
|
+
// 1. Delete matched content
|
|
844
|
+
range.deleteContents();
|
|
845
|
+
|
|
846
|
+
// 2. Insert replacement text at start position
|
|
847
|
+
if (startNode.nodeType === 3) {
|
|
848
|
+
// Insert after the remaining text in the start node
|
|
849
|
+
if (startNode.parentNode) {
|
|
850
|
+
startNode.parentNode.insertBefore(textNode, startNode.nextSibling);
|
|
851
|
+
startNode.parentNode.normalize();
|
|
852
|
+
}
|
|
853
|
+
} else {
|
|
854
|
+
range.insertNode(textNode);
|
|
855
|
+
textNode.parentNode?.normalize();
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// ──────────────────────────────────────────────────
|
|
860
|
+
// Helpers
|
|
861
|
+
// ──────────────────────────────────────────────────
|
|
862
|
+
|
|
863
|
+
/** @description Update match count display and prev/next button state. Panel-only. */
|
|
864
|
+
#updateCount() {
|
|
865
|
+
if (!this.#panel) return;
|
|
866
|
+
const hasMatches = this.#matches.length > 0;
|
|
867
|
+
if (hasMatches) {
|
|
868
|
+
this.#countDisplay.textContent = `${this.#currentIndex + 1}/${this.#matches.length}`;
|
|
869
|
+
} else {
|
|
870
|
+
this.#countDisplay.textContent = this.#searchTerm ? '0' : '';
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
this.#btnPrev.disabled = !hasMatches;
|
|
874
|
+
this.#btnNext.disabled = !hasMatches;
|
|
875
|
+
this.#btnReplace.disabled = !hasMatches;
|
|
876
|
+
this.#btnReplaceAll.disabled = !hasMatches;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* @description Get the document object for the current frame (iframe or main document).
|
|
881
|
+
* @returns {Document}
|
|
882
|
+
*/
|
|
883
|
+
#getDocument() {
|
|
884
|
+
const fc = this.#$.frameContext;
|
|
885
|
+
return fc.get('options').get('iframe') ? fc.get('_wd') : _d;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** @internal */
|
|
889
|
+
_destroy() {
|
|
890
|
+
this.#removeGlobalCloseEvent();
|
|
891
|
+
this.#removeContentInputListener();
|
|
892
|
+
this.#resizeObserver &&= this.#resizeObserver.disconnect();
|
|
893
|
+
clearTimeout(this.#searchTimer);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* @description Creates the FindFa/Replace panel HTML.
|
|
899
|
+
* @param {SunEditor.Deps} $ editor deps
|
|
900
|
+
* @returns {{
|
|
901
|
+
* panel: HTMLElement,
|
|
902
|
+
* findInput: HTMLInputElement,
|
|
903
|
+
* replaceInput: HTMLInputElement,
|
|
904
|
+
* countDisplay: HTMLElement,
|
|
905
|
+
* replaceRow: HTMLElement,
|
|
906
|
+
* btnCase: HTMLButtonElement,
|
|
907
|
+
* btnWord: HTMLButtonElement,
|
|
908
|
+
* btnRegex: HTMLButtonElement,
|
|
909
|
+
* btnPrev: HTMLButtonElement,
|
|
910
|
+
* btnNext: HTMLButtonElement
|
|
911
|
+
* btnReplace: HTMLButtonElement
|
|
912
|
+
* btnReplaceAll: HTMLButtonElement
|
|
913
|
+
* }}
|
|
914
|
+
*/
|
|
915
|
+
function CreateHTML({ lang, icons }) {
|
|
916
|
+
const html = /*html*/ `
|
|
917
|
+
<div class="se-find-replace-row">
|
|
918
|
+
<div class="se-find-input-wrapper">
|
|
919
|
+
<input class="se-find-replace-input" type="text" placeholder="${lang.find || 'Find'}" spellcheck="false" autocomplete="off" />
|
|
920
|
+
<div class="se-find-replace-toggle">
|
|
921
|
+
<div class="se-find-replace-info">
|
|
922
|
+
<span class="se-find-replace-count"></span>
|
|
923
|
+
</div>
|
|
924
|
+
<button class="se-btn se-find-replace-btn se-find-opt-case" type="button" data-command="opt-case" title="${lang.finder_matchCase}">
|
|
925
|
+
${icons.match_case}
|
|
926
|
+
</button>
|
|
927
|
+
<button class="se-btn se-find-replace-btn se-find-opt-word" type="button" data-command="opt-word" title="${lang.finder_wholeWord}">
|
|
928
|
+
${icons.whole_word}
|
|
929
|
+
</button>
|
|
930
|
+
<button class="se-btn se-find-replace-btn se-find-opt-regex" type="button" data-command="opt-regex" title="${lang.finder_regex}">
|
|
931
|
+
${icons.regex}
|
|
932
|
+
</button>
|
|
933
|
+
</div>
|
|
934
|
+
</div>
|
|
935
|
+
<div class="se-find-replace-buttons">
|
|
936
|
+
<button class="se-btn se-find-replace-btn" type="button" data-command="prev" title="${lang.finder_prev}\n(${env.shiftIcon} + Enter)">
|
|
937
|
+
${icons.arrow_up_small}
|
|
938
|
+
</button>
|
|
939
|
+
<button class="se-btn se-find-replace-btn" type="button" data-command="next" title="${lang.finder_next}\n(Enter)">
|
|
940
|
+
${icons.arrow_down_small}
|
|
941
|
+
</button>
|
|
942
|
+
<button class="se-btn se-find-replace-btn se-find-toggle-replace" type="button" data-command="toggle-replace" title="${lang.replace}">
|
|
943
|
+
${icons.swap_vert}
|
|
944
|
+
</button>
|
|
945
|
+
<button class="se-btn se-find-replace-btn" type="button" data-command="close" title="${lang.close}">
|
|
946
|
+
${icons.cancel}
|
|
947
|
+
</button>
|
|
948
|
+
</div>
|
|
949
|
+
</div>
|
|
950
|
+
<div class="se-find-replace-row se-find-replace-row-replace">
|
|
951
|
+
<div class="se-find-input-wrapper">
|
|
952
|
+
<input class="se-find-replace-input se-replace-input" type="text" placeholder="${lang.replace}" spellcheck="false" autocomplete="off" />
|
|
953
|
+
</div>
|
|
954
|
+
<div class="se-find-replace-buttons">
|
|
955
|
+
<button class="se-btn se-find-replace-btn" type="button" data-command="replace" title="${lang.replace}\n(Enter)">
|
|
956
|
+
${icons.replaceText}
|
|
957
|
+
</button>
|
|
958
|
+
<button class="se-btn se-find-replace-btn" type="button" data-command="replace-all" title="${lang.replaceAll}">
|
|
959
|
+
${icons.relaceTextAll}
|
|
960
|
+
</button>
|
|
961
|
+
</div>
|
|
962
|
+
</div>`;
|
|
963
|
+
|
|
964
|
+
const panel = dom.utils.createElement('DIV', { class: 'se-find-replace' }, html);
|
|
965
|
+
|
|
966
|
+
return {
|
|
967
|
+
panel,
|
|
968
|
+
findInput: panel.querySelector('.se-find-replace-input'),
|
|
969
|
+
replaceInput: panel.querySelector('.se-replace-input'),
|
|
970
|
+
countDisplay: panel.querySelector('.se-find-replace-count'),
|
|
971
|
+
replaceRow: panel.querySelector('.se-find-replace-row-replace'),
|
|
972
|
+
btnCase: panel.querySelector('.se-find-opt-case'),
|
|
973
|
+
btnWord: panel.querySelector('.se-find-opt-word'),
|
|
974
|
+
btnRegex: panel.querySelector('.se-find-opt-regex'),
|
|
975
|
+
btnPrev: panel.querySelector('[data-command="prev"]'),
|
|
976
|
+
btnNext: panel.querySelector('[data-command="next"]'),
|
|
977
|
+
btnReplace: panel.querySelector('[data-command="replace"]'),
|
|
978
|
+
btnReplaceAll: panel.querySelector('[data-command="replace-all"]'),
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
export default Finder;
|