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.
Files changed (171) hide show
  1. package/README.md +4 -3
  2. package/dist/suneditor-contents.min.css +1 -1
  3. package/dist/suneditor.min.css +1 -1
  4. package/dist/suneditor.min.js +1 -1
  5. package/package.json +10 -6
  6. package/src/assets/design/color.css +14 -2
  7. package/src/assets/design/typography.css +5 -0
  8. package/src/assets/icons/defaultIcons.js +22 -4
  9. package/src/assets/suneditor-contents.css +1 -1
  10. package/src/assets/suneditor.css +312 -18
  11. package/src/core/config/eventManager.js +6 -9
  12. package/src/core/editor.js +1 -1
  13. package/src/core/event/actions/index.js +5 -0
  14. package/src/core/event/effects/keydown.registry.js +25 -0
  15. package/src/core/event/eventOrchestrator.js +69 -2
  16. package/src/core/event/handlers/handler_ww_mouse.js +1 -0
  17. package/src/core/event/rules/keydown.rule.backspace.js +9 -1
  18. package/src/core/kernel/coreKernel.js +4 -0
  19. package/src/core/kernel/store.js +2 -0
  20. package/src/core/logic/dom/char.js +11 -0
  21. package/src/core/logic/dom/format.js +22 -0
  22. package/src/core/logic/dom/html.js +126 -11
  23. package/src/core/logic/dom/nodeTransform.js +13 -0
  24. package/src/core/logic/dom/offset.js +100 -37
  25. package/src/core/logic/dom/selection.js +54 -22
  26. package/src/core/logic/panel/finder.js +982 -0
  27. package/src/core/logic/panel/menu.js +8 -6
  28. package/src/core/logic/panel/toolbar.js +112 -19
  29. package/src/core/logic/panel/viewer.js +214 -43
  30. package/src/core/logic/shell/_commandExecutor.js +7 -1
  31. package/src/core/logic/shell/commandDispatcher.js +1 -1
  32. package/src/core/logic/shell/component.js +5 -7
  33. package/src/core/logic/shell/history.js +24 -0
  34. package/src/core/logic/shell/shortcuts.js +3 -3
  35. package/src/core/logic/shell/ui.js +25 -26
  36. package/src/core/schema/frameContext.js +15 -1
  37. package/src/core/schema/options.js +180 -39
  38. package/src/core/section/constructor.js +61 -20
  39. package/src/core/section/documentType.js +2 -2
  40. package/src/events.js +12 -0
  41. package/src/helper/clipboard.js +1 -1
  42. package/src/helper/converter.js +15 -0
  43. package/src/helper/dom/domQuery.js +12 -0
  44. package/src/helper/dom/domUtils.js +26 -14
  45. package/src/helper/index.js +3 -0
  46. package/src/helper/markdown.js +876 -0
  47. package/src/interfaces/plugins.js +7 -5
  48. package/src/langs/ckb.js +9 -0
  49. package/src/langs/cs.js +9 -0
  50. package/src/langs/da.js +9 -0
  51. package/src/langs/de.js +9 -0
  52. package/src/langs/en.js +9 -0
  53. package/src/langs/es.js +9 -0
  54. package/src/langs/fa.js +9 -0
  55. package/src/langs/fr.js +9 -0
  56. package/src/langs/he.js +9 -0
  57. package/src/langs/hu.js +9 -0
  58. package/src/langs/it.js +9 -0
  59. package/src/langs/ja.js +9 -0
  60. package/src/langs/km.js +9 -0
  61. package/src/langs/ko.js +9 -0
  62. package/src/langs/lv.js +9 -0
  63. package/src/langs/nl.js +9 -0
  64. package/src/langs/pl.js +9 -0
  65. package/src/langs/pt_br.js +9 -0
  66. package/src/langs/ro.js +9 -0
  67. package/src/langs/ru.js +9 -0
  68. package/src/langs/se.js +9 -0
  69. package/src/langs/tr.js +9 -0
  70. package/src/langs/uk.js +9 -0
  71. package/src/langs/ur.js +9 -0
  72. package/src/langs/zh_cn.js +9 -0
  73. package/src/modules/contract/Browser.js +31 -1
  74. package/src/modules/contract/ColorPicker.js +6 -0
  75. package/src/modules/contract/Controller.js +77 -39
  76. package/src/modules/contract/Figure.js +57 -0
  77. package/src/modules/contract/Modal.js +6 -0
  78. package/src/modules/manager/ApiManager.js +53 -4
  79. package/src/modules/manager/FileManager.js +18 -1
  80. package/src/modules/ui/ModalAnchorEditor.js +35 -2
  81. package/src/modules/ui/SelectMenu.js +44 -12
  82. package/src/plugins/browser/fileBrowser.js +5 -2
  83. package/src/plugins/command/codeBlock.js +324 -0
  84. package/src/plugins/command/exportPDF.js +15 -3
  85. package/src/plugins/command/fileUpload.js +4 -1
  86. package/src/plugins/dropdown/backgroundColor.js +5 -1
  87. package/src/plugins/dropdown/blockStyle.js +8 -2
  88. package/src/plugins/dropdown/fontColor.js +5 -1
  89. package/src/plugins/dropdown/hr.js +6 -0
  90. package/src/plugins/dropdown/layout.js +4 -1
  91. package/src/plugins/dropdown/lineHeight.js +3 -0
  92. package/src/plugins/dropdown/paragraphStyle.js +5 -5
  93. package/src/plugins/dropdown/table/index.js +4 -1
  94. package/src/plugins/dropdown/table/render/table.html.js +1 -1
  95. package/src/plugins/dropdown/table/services/table.grid.js +16 -8
  96. package/src/plugins/dropdown/table/services/table.style.js +5 -9
  97. package/src/plugins/dropdown/template.js +3 -0
  98. package/src/plugins/dropdown/textStyle.js +5 -1
  99. package/src/plugins/field/mention.js +5 -1
  100. package/src/plugins/index.js +3 -0
  101. package/src/plugins/input/fontSize.js +10 -3
  102. package/src/plugins/modal/audio.js +7 -3
  103. package/src/plugins/modal/embed.js +23 -20
  104. package/src/plugins/modal/image/index.js +5 -1
  105. package/src/plugins/modal/math.js +7 -2
  106. package/src/plugins/modal/video/index.js +21 -4
  107. package/src/themes/cobalt.css +13 -4
  108. package/src/themes/cream.css +11 -2
  109. package/src/themes/dark.css +13 -4
  110. package/src/themes/midnight.css +13 -4
  111. package/src/typedef.js +4 -4
  112. package/types/assets/icons/defaultIcons.d.ts +12 -1
  113. package/types/assets/suneditor.css.d.ts +1 -1
  114. package/types/core/config/eventManager.d.ts +6 -8
  115. package/types/core/event/actions/index.d.ts +1 -0
  116. package/types/core/event/effects/keydown.registry.d.ts +2 -0
  117. package/types/core/event/eventOrchestrator.d.ts +2 -1
  118. package/types/core/kernel/coreKernel.d.ts +5 -0
  119. package/types/core/kernel/store.d.ts +5 -0
  120. package/types/core/logic/dom/char.d.ts +11 -0
  121. package/types/core/logic/dom/format.d.ts +22 -0
  122. package/types/core/logic/dom/html.d.ts +16 -0
  123. package/types/core/logic/dom/nodeTransform.d.ts +13 -0
  124. package/types/core/logic/dom/offset.d.ts +23 -2
  125. package/types/core/logic/dom/selection.d.ts +9 -3
  126. package/types/core/logic/panel/finder.d.ts +83 -0
  127. package/types/core/logic/panel/toolbar.d.ts +14 -1
  128. package/types/core/logic/panel/viewer.d.ts +22 -2
  129. package/types/core/logic/shell/shortcuts.d.ts +1 -1
  130. package/types/core/schema/frameContext.d.ts +22 -0
  131. package/types/core/schema/options.d.ts +362 -79
  132. package/types/events.d.ts +11 -0
  133. package/types/helper/converter.d.ts +15 -0
  134. package/types/helper/dom/domQuery.d.ts +12 -0
  135. package/types/helper/dom/domUtils.d.ts +23 -2
  136. package/types/helper/index.d.ts +5 -0
  137. package/types/helper/markdown.d.ts +27 -0
  138. package/types/interfaces/plugins.d.ts +7 -5
  139. package/types/langs/_Lang.d.ts +9 -0
  140. package/types/modules/contract/Browser.d.ts +36 -2
  141. package/types/modules/contract/ColorPicker.d.ts +6 -0
  142. package/types/modules/contract/Controller.d.ts +35 -1
  143. package/types/modules/contract/Figure.d.ts +57 -0
  144. package/types/modules/contract/Modal.d.ts +6 -0
  145. package/types/modules/manager/ApiManager.d.ts +26 -0
  146. package/types/modules/manager/FileManager.d.ts +17 -0
  147. package/types/modules/ui/ModalAnchorEditor.d.ts +41 -4
  148. package/types/modules/ui/SelectMenu.d.ts +40 -2
  149. package/types/plugins/browser/fileBrowser.d.ts +10 -4
  150. package/types/plugins/command/codeBlock.d.ts +53 -0
  151. package/types/plugins/command/fileUpload.d.ts +8 -2
  152. package/types/plugins/dropdown/backgroundColor.d.ts +10 -2
  153. package/types/plugins/dropdown/blockStyle.d.ts +14 -2
  154. package/types/plugins/dropdown/fontColor.d.ts +10 -2
  155. package/types/plugins/dropdown/hr.d.ts +12 -0
  156. package/types/plugins/dropdown/layout.d.ts +8 -2
  157. package/types/plugins/dropdown/lineHeight.d.ts +6 -0
  158. package/types/plugins/dropdown/paragraphStyle.d.ts +14 -3
  159. package/types/plugins/dropdown/table/index.d.ts +9 -3
  160. package/types/plugins/dropdown/template.d.ts +6 -0
  161. package/types/plugins/dropdown/textStyle.d.ts +10 -2
  162. package/types/plugins/field/mention.d.ts +10 -2
  163. package/types/plugins/index.d.ts +3 -0
  164. package/types/plugins/input/fontSize.d.ts +18 -4
  165. package/types/plugins/modal/audio.d.ts +14 -6
  166. package/types/plugins/modal/embed.d.ts +44 -38
  167. package/types/plugins/modal/image/index.d.ts +9 -1
  168. package/types/plugins/modal/link.d.ts +6 -2
  169. package/types/plugins/modal/math.d.ts +23 -5
  170. package/types/plugins/modal/video/index.d.ts +49 -9
  171. 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;