UNPKG

suneditor

Version:

Vanilla JavaScript based WYSIWYG web editor

983 lines (853 loc) 30.6 kB
import { dom, env } from '../../../helper'; const { _d, _w } = env; /** * @description Find/Replace feature */ class Finder { #$; #store; // DOM #panel; #findInput; #replaceInput; #countDisplay; #replaceRow; // option buttons #btnCase; #btnWord; #btnRegex; // nav buttons #btnPrev; #btnNext; #btnReplace; #btnReplaceAll; // State #isOpen = false; #isReplaceMode = true; #matches = []; #currentIndex = -1; #searchTerm = ''; #opts = { matchCase: false, wholeWord: false, regex: false }; // Highlight #useNativeHighlight; #markElements = []; #highlightDoc = null; // Debounce, observer #searchTimer = null; #resizeObserver = null; #bindCloseKey = null; #contentObserver = null; #internalUpdate = false; /** @description Inject ::highlight() styles at runtime (avoids PostCSS parse errors). */ static #highlightStyleInjected = false; static #injectHighlightStyles() { if (Finder.#highlightStyleInjected) return; Finder.#highlightStyleInjected = true; const style = _d.createElement('style'); style.textContent = '::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}'; _d.head.appendChild(style); } /** * @constructor * @param {SunEditor.Kernel} kernel */ constructor(kernel) { this.#$ = kernel.$; this.#store = kernel.store; this.#useNativeHighlight = !!_w.Highlight && !!CSS?.highlights; // Panel UI — only when finder_panel option is enabled if (this.#$.options.get('finder_panel')) { const panelEl = CreateHTML(this.#$); this.#panel = panelEl.panel; this.#findInput = panelEl.findInput; this.#replaceInput = panelEl.replaceInput; this.#countDisplay = panelEl.countDisplay; this.#replaceRow = panelEl.replaceRow; this.#btnCase = panelEl.btnCase; this.#btnWord = panelEl.btnWord; this.#btnRegex = panelEl.btnRegex; this.#btnPrev = panelEl.btnPrev; this.#btnNext = panelEl.btnNext; this.#btnReplace = panelEl.btnReplace; this.#btnReplaceAll = panelEl.btnReplaceAll; this.#bindEvents(); // Append panel to root container (between toolbar and wrapper) const rootFc = this.#$.frameRoots.values().next().value; const container = rootFc.get('container'); if (this.#store.mode.isBottom) { const toolbar = this.#$.context.get('toolbar_main'); container.insertBefore(this.#panel, toolbar); } else { const wrapper = container.querySelector('.se-wrapper'); container.insertBefore(this.#panel, wrapper); } // Update sticky top (responsive resize, more layer, etc.) if (env.isResizeObserverSupported) { this.#resizeObserver = new ResizeObserver(() => this.#updateStickyTop()).observe(this.#$.context.get('toolbar_main')); } } } /** * @description Whether the panel is open. * @returns {boolean} */ get isOpen() { return this.#isOpen; } /** * @description Opens the finder. With panel: shows UI. Without panel: activates search state only. * @param {boolean} [replaceMode=true] Whether to show replace row */ open(replaceMode = true) { const fc = this.#$.frameContext; if (fc.get('isCodeView') || fc.get('isMarkdownView')) return; this.#isOpen = true; // Listen for wysiwyg content changes to refresh highlights this.#addContentInputListener(); if (this.#panel) { this.#btnPrev.disabled = true; this.#btnNext.disabled = true; this.#btnReplace.disabled = true; this.#btnReplaceAll.disabled = true; this.#updateStickyTop(); const sel = this.#$.selection.get(); const selectedText = sel && !sel.isCollapsed ? sel.toString().trim() : ''; dom.utils.addClass(this.#panel, 'se-find-replace-open'); this.#store.set('_preventBlur', true); this.#addGlobalCloseEvent(); if (replaceMode) this.#toggleReplace(replaceMode); if (selectedText) this.#findInput.value = selectedText; this.#findInput.focus(); this.#findInput.select(); if (this.#findInput.value) this.#doSearch(); } } /** * @description Updates the finder panel's sticky top position based on toolbar height. */ #updateStickyTop() { if (!this.#isOpen || !this.#panel) return; const stickyTop = this.#$.options.get('_toolbar_sticky'); if (this.#store.mode.isBottom) { this.#panel.style.top = 'auto'; this.#panel.style.bottom = stickyTop >= 0 ? stickyTop + this.#$.context.get('toolbar_main').offsetHeight + 'px' : '0px'; } else { this.#panel.style.top = stickyTop >= 0 ? stickyTop + this.#$.context.get('toolbar_main').offsetHeight + 'px' : '0px'; } } /** * @description Closes the finder and clears highlights. */ close() { if (!this.#isOpen) return; this.#isOpen = false; this.#clearHighlights(); this.#matches = []; this.#currentIndex = -1; this.#updateCount(); this.#removeContentInputListener(); if (this.#panel) { dom.utils.removeClass(this.#panel, 'se-find-replace-open'); this.#removeGlobalCloseEvent(); this.#store.set('_preventBlur', false); this.#$.focusManager.nativeFocus(); } } // ────────────────────────────────────────────────── // [[ PUBLIC API ]] // ────────────────────────────────────────────────── /** * @description Navigate to next match (public for shortcut binding). */ findNext() { if (!this.#isOpen || this.#matches.length === 0) return; this.#currentIndex = (this.#currentIndex + 1) % this.#matches.length; this.#goToMatch(); } /** * @description Navigate to previous match (public for shortcut binding). */ findPrev() { if (!this.#isOpen || this.#matches.length === 0) return; this.#currentIndex = (this.#currentIndex - 1 + this.#matches.length) % this.#matches.length; this.#goToMatch(); } /** * @description Search for a term in the editor content (headless API). * @param {string} term Search term * @param {Object} [options] Search options * @param {boolean} [options.matchCase=false] Case-sensitive search * @param {boolean} [options.wholeWord=false] Whole word search * @param {boolean} [options.regex=false] Regex search * @returns {number} Number of matches found */ search(term, { matchCase, wholeWord, regex } = {}) { if (!this.#isOpen) this.open(); if (matchCase !== undefined) this.#opts.matchCase = matchCase; if (wholeWord !== undefined) this.#opts.wholeWord = wholeWord; if (regex !== undefined) this.#opts.regex = regex; this.#searchTerm = term || ''; if (this.#findInput) this.#findInput.value = this.#searchTerm; this.#doSearch(); return this.#matches.length; } /** * @description Replace the current match (headless API). * @param {string} replaceText Replacement text */ replace(replaceText) { this.#replaceOne(replaceText); } /** * @description Replace all matches (headless API). * @param {string} replaceText Replacement text */ replaceAll(replaceText) { this.#replaceAll(replaceText); } /** * @description Current match count and index. * @returns {{ current: number, total: number }} */ get matchInfo() { return { current: this.#currentIndex + 1, total: this.#matches.length }; } /** * @description Re-run search with current term (debounced 300ms). Called on wysiwyg content change. */ refresh() { if (!this.#isOpen || !this.#searchTerm || this.#internalUpdate) return; this.#internalUpdate = true; this.#removeMarkElements(); this.#markElements = []; this.#internalUpdate = false; clearTimeout(this.#searchTimer); this.#searchTimer = setTimeout(() => this.#doSearch(), 300); } // ────────────────────────────────────────────────── // Global events (ESC close, content change) // ────────────────────────────────────────────────── /** @description Register global ESC keydown (capture) to close finder. */ #addGlobalCloseEvent() { this.#removeGlobalCloseEvent(); this.#bindCloseKey = this.#$.eventManager.addGlobalEvent( 'keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); this.close(); } }, true, ); } #removeGlobalCloseEvent() { this.#bindCloseKey &&= this.#$.eventManager.removeGlobalEvent(this.#bindCloseKey); } /** @description Listen for wysiwyg edits to auto-refresh highlights. */ #addContentInputListener() { this.#removeContentInputListener(); const wysiwyg = this.#$.frameContext.get('wysiwyg'); this.#contentObserver = new MutationObserver(() => this.refresh()); this.#contentObserver.observe(wysiwyg, { childList: true, subtree: true, characterData: true }); } #removeContentInputListener() { if (this.#contentObserver) { this.#contentObserver.disconnect(); this.#contentObserver = null; } } /** @description Bind panel UI events (input, click delegation, tab navigation, blur prevention). Panel-only. */ #bindEvents() { const em = this.#$.eventManager; // find input em.addEvent(this.#findInput, 'input', this.#OnFindInput.bind(this)); em.addEvent(this.#findInput, 'keydown', this.#OnFindKeydown.bind(this)); // replace input em.addEvent(this.#replaceInput, 'keydown', this.#OnReplaceKeydown.bind(this)); // panel click em.addEvent(this.#panel, 'click', this.#OnPanelAction.bind(this)); // prevent blur on panel interaction + Gecko :active fix if (env.isGecko) { em.addEvent(this.#panel, 'mousedown', (e) => { if (e.target.tagName === 'BUTTON') { e.preventDefault(); const btn = dom.query.getCommandTarget(e.target); if (btn) this.#$.eventManager._injectActiveEvent(btn); } this.#store.set('_preventBlur', true); }); } } /** @description Debounced search triggered on find input typing. */ #OnFindInput() { if (!this.#$.options.get('finder_liveSearch')) return; clearTimeout(this.#searchTimer); this.#searchTimer = setTimeout(() => this.#doSearch(), 120); } /** * @description Find input keydown — ESC to close, Enter/Shift+Enter to navigate. * When liveSearch is off, Enter triggers initial search; subsequent Enter navigates. * @param {KeyboardEvent} e */ #OnFindKeydown(e) { if (e.key === 'Escape') { e.preventDefault(); this.close(); } else if (e.key === 'Enter') { e.preventDefault(); if (!this.#$.options.get('finder_liveSearch') && this.#findInput.value !== this.#searchTerm) { this.#doSearch(); } else if (e.shiftKey) { this.findPrev(); } else { this.findNext(); } } } #OnReplaceKeydown(e) { if (e.key === 'Escape') { e.preventDefault(); this.close(); } else if (e.key === 'Enter') { e.preventDefault(); this.#replaceOne(); } } /** * @description Panel action function * @param {MouseEvent} e event * @returns */ #OnPanelAction(e) { const eventTarget = dom.query.getEventTarget(e); const btn = dom.query.getCommandTarget(eventTarget); if (!btn) return; e.preventDefault(); const command = btn.getAttribute('data-command'); switch (command) { case 'prev': this.findPrev(); break; case 'next': this.findNext(); break; case 'toggle-replace': this.#toggleReplace(!this.#isReplaceMode); break; case 'close': this.close(); break; case 'replace': this.#replaceOne(); break; case 'replace-all': this.#replaceAll(); break; case 'opt-case': this.#opts.matchCase = !this.#opts.matchCase; dom.utils.toggleClass(this.#btnCase, 'on', this.#opts.matchCase); this.#doSearch(); break; case 'opt-word': this.#opts.wholeWord = !this.#opts.wholeWord; dom.utils.toggleClass(this.#btnWord, 'on', this.#opts.wholeWord); this.#doSearch(); break; case 'opt-regex': this.#opts.regex = !this.#opts.regex; dom.utils.toggleClass(this.#btnRegex, 'on', this.#opts.regex); this.#doSearch(); break; } } /** * @description Toggle replace row visibility. Panel-only. * @param {boolean} show */ #toggleReplace(show) { this.#isReplaceMode = show; this.#replaceRow.style.display = show ? '' : 'none'; dom.utils.toggleClass(this.#panel.querySelector('.se-find-toggle-replace'), 'on', show); if (show) { this.#replaceInput.focus(); } } /** * ────────────────────────────────────────────────── * [ Functions ] * ────────────────────────────────────────────────── */ /** @description Core search — clear previous, find matches, highlight, update count. */ #doSearch() { const term = this.#findInput ? this.#findInput.value : this.#searchTerm; this.#internalUpdate = true; this.#clearHighlights(); this.#matches = []; this.#currentIndex = -1; if (!term) { this.#searchTerm = ''; this.#updateCount(); dom.utils.removeClass(this.#findInput, 'se-find-no-match'); return; } this.#searchTerm = term; const wysiwyg = this.#$.frameContext.get('wysiwyg'); this.#highlightDoc = this.#getDocument(); this.#matches = this.#findAllMatches(term, wysiwyg); if (this.#matches.length > 0) { this.#currentIndex = 0; this.#highlightAll(); this.#goToMatch(); dom.utils.removeClass(this.#findInput, 'se-find-no-match'); } else if (this.#findInput) { dom.utils.toggleClass(this.#findInput, 'se-find-no-match', term.length > 0); } this.#updateCount(); this.#internalUpdate = false; } /** * @description Find all text matches. Text nodes are concatenated with `\n` between * different line elements to prevent cross-line matching. Single regex execution. * @param {string} term Search term * @param {HTMLElement} root Root element to search within * @returns {Range[]} Array of Range objects */ #findAllMatches(term, root) { const matches = []; if (!term || !root) return matches; // Build regex const flags = this.#opts.matchCase ? 'g' : 'gi'; let pattern; if (this.#opts.regex) { try { pattern = new RegExp(term, flags); } catch { return matches; } } else { const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); pattern = this.#opts.wholeWord ? new RegExp(`\\b${escaped}\\b`, flags) : new RegExp(escaped, flags); } const doc = this.#highlightDoc; const format = this.#$.format; // Collect text nodes, insert \n between different lines const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT, null); /** @type {Array<{node: Text, start: number}>} */ const textNodes = []; let fullText = ''; let prevLine = null; let tn; while ((tn = walker.nextNode())) { const line = format.getLine(tn, null) || root; if (prevLine && line !== prevLine) fullText += '\n'; prevLine = line; textNodes.push({ node: tn, start: fullText.length }); fullText += tn.textContent.replace(/\u00A0/g, ' '); } if (!fullText) return matches; // regex search pattern.lastIndex = 0; let match; let nodeIdx = 0; while ((match = pattern.exec(fullText)) !== null) { if (match[0].length === 0) { pattern.lastIndex++; continue; } const mStart = match.index; const mEnd = mStart + match[0].length; const range = doc.createRange(); let startSet = false; for (let i = nodeIdx; i < textNodes.length; i++) { const t = textNodes[i]; const tEnd = t.start + t.node.textContent.length; if (!startSet && mStart >= t.start && mStart < tEnd) { range.setStart(t.node, mStart - t.start); startSet = true; nodeIdx = i; } if (startSet && mEnd > t.start && mEnd <= tEnd) { range.setEnd(t.node, mEnd - t.start); break; } } if (startSet) matches.push(range); } return matches; } /** @description Apply highlights to all matches (native API or mark fallback). */ #highlightAll() { if (this.#matches.length === 0) return; const isIframe = this.#$.frameContext.get('options').get('iframe'); // CSS Custom Highlight API doesn't work cross-document (iframe). if (this.#useNativeHighlight && !isIframe) { this.#applyNativeHighlight(); } else { this.#applyMarkFallback(); } } /** @description Apply CSS Custom Highlight API highlights. */ #applyNativeHighlight() { Finder.#injectHighlightStyles(); // eslint-disable-next-line const allRanges = new Highlight(...this.#matches); CSS.highlights.set('se-find-match', allRanges); this.#updateCurrentNativeHighlight(); } /** @description Update the "current match" native highlight. */ #updateCurrentNativeHighlight() { if (this.#currentIndex >= 0 && this.#currentIndex < this.#matches.length) { const current = new Highlight(this.#matches[this.#currentIndex]); CSS.highlights.set('se-find-current', current); } else { CSS.highlights.delete('se-find-current'); } } /** @description Fallback: wrap matches with `<mark>` elements. */ #applyMarkFallback() { this.#removeMarkElements(); const doc = this.#highlightDoc; // Process matches in reverse to preserve earlier Range positions for (let i = this.#matches.length - 1; i >= 0; i--) { const range = this.#matches[i]; const marks = this.#wrapRangeTextNodes(doc, range, i); for (let m = 0; m < marks.length; m++) { this.#markElements.push(marks[m]); } } this.#markElements.reverse(); this.#updateCurrentMarkHighlight(); } /** * @description Wrap each text node segment within a Range with a `<mark>`, without extractContents. * @param {Document} doc * @param {Range} range * @param {number} idx - match index * @returns {HTMLElement[]} created mark elements */ #wrapRangeTextNodes(doc, range, idx) { const sc = /** @type {Text} */ (range.startContainer); const ec = /** @type {Text} */ (range.endContainer); const marks = []; if (sc === ec) { // Single text node — most common case marks.push(this.#wrapTextSegment(doc, sc, range.startOffset, range.endOffset, idx)); } else { // Cross-node: collect text nodes between start and end containers const textNodes = [sc]; let node = sc; while (node && node !== ec) { node = this.#nextTextNode(node, range.commonAncestorContainer); if (node) textNodes.push(node); } // Wrap in reverse to preserve offsets for (let i = textNodes.length - 1; i >= 0; i--) { const tn = textNodes[i]; const start = tn === sc ? range.startOffset : 0; const end = tn === ec ? range.endOffset : tn.textContent.length; if (start >= end) continue; marks.push(this.#wrapTextSegment(doc, tn, start, end, idx)); } } return marks; } /** * @description Wrap a portion of a text node with a `<mark>`. * @param {Document} doc * @param {Text} textNode * @param {number} start * @param {number} end * @param {number} idx * @returns {HTMLElement} */ #wrapTextSegment(doc, textNode, start, end, idx) { const matchNode = start > 0 ? textNode.splitText(start) : textNode; if (end - start < matchNode.textContent.length) { matchNode.splitText(end - start); } const mark = doc.createElement('mark'); mark.className = 'se-find-mark'; mark.setAttribute('data-se-find-idx', String(idx)); matchNode.parentNode.insertBefore(mark, matchNode); mark.appendChild(matchNode); return mark; } /** * @description Get the next text node in document order within a boundary. * @param {Node} node * @param {Node} boundary * @returns {Text|null} */ #nextTextNode(node, boundary) { let n = node; while (n) { if (n.firstChild) { n = n.firstChild; } else { while (n && !n.nextSibling) { n = n.parentNode; if (n === boundary) return null; } if (!n) return null; n = n.nextSibling; } if (n.nodeType === 3) return /** @type {Text} */ (n); } return null; } /** @description Update the "current match" mark element class. */ #updateCurrentMarkHighlight() { for (const m of this.#markElements) { dom.utils.removeClass(m, 'se-find-current'); } if (this.#currentIndex >= 0) { for (const m of this.#markElements) { if (m.getAttribute('data-se-find-idx') === String(this.#currentIndex)) { dom.utils.addClass(m, 'se-find-current'); } } } } /** @description Remove all highlights (native + mark). */ #clearHighlights() { const isIframe = this.#$.frameContext.get('options').get('iframe'); if (this.#useNativeHighlight && !isIframe) { CSS.highlights.delete('se-find-match'); CSS.highlights.delete('se-find-current'); } this.#removeMarkElements(); } /** @description Unwrap all `<mark>` elements and normalize text nodes. */ #removeMarkElements() { const wysiwyg = this.#$.frameContext.get('wysiwyg'); const marks = wysiwyg.querySelectorAll('mark.se-find-mark'); if (marks.length === 0) return; for (const mark of marks) { const parent = mark.parentNode; if (!parent) continue; while (mark.firstChild) { parent.insertBefore(mark.firstChild, mark); } parent.removeChild(mark); parent.normalize(); } this.#markElements = []; } // ────────────────────────────────────────────────── // Navigation // ────────────────────────────────────────────────── /** @description Scroll to current match and update active highlight. */ #goToMatch() { if (this.#currentIndex < 0 || this.#currentIndex >= this.#matches.length) return; const isIframe = this.#$.frameContext.get('options').get('iframe'); if (this.#useNativeHighlight && !isIframe) { // Native highlight: update current highlight and scroll this.#updateCurrentNativeHighlight(); const range = this.#matches[this.#currentIndex]; this.#$.selection.scrollTo(range, { behavior: 'auto' }); } else { // Mark fallback: update active mark this.#updateCurrentMarkHighlight(); const currentMark = this.#markElements.find((m) => m.getAttribute('data-se-find-idx') === String(this.#currentIndex)); if (currentMark) { this.#$.selection.scrollTo(currentMark, { behavior: 'auto', noFocus: true }); } } this.#updateCount(); } // ────────────────────────────────────────────────── // Replace // ────────────────────────────────────────────────── /** * @description Replace current match, then re-search. * @param {string} [replaceText] Falls back to replace input value if omitted. */ #replaceOne(replaceText) { if (this.#currentIndex < 0 || this.#matches.length === 0) return; this.#clearHighlights(); // Re-search to get fresh ranges const wysiwyg = this.#$.frameContext.get('wysiwyg'); const freshMatches = this.#findAllMatches(this.#searchTerm, wysiwyg); if (this.#currentIndex >= freshMatches.length) return; this.#replaceRange(freshMatches[this.#currentIndex], replaceText ?? (this.#replaceInput ? this.#replaceInput.value : '')); this.#$.history.push(false); // Re-search this.#doSearch(); if (this.#currentIndex >= this.#matches.length && this.#matches.length > 0) { this.#currentIndex = 0; this.#goToMatch(); } } /** * @description Replace all matches in reverse order, then re-search. * @param {string} [replaceText] Falls back to replace input value if omitted. */ #replaceAll(replaceText) { if (this.#matches.length === 0) return; this.#clearHighlights(); const wysiwyg = this.#$.frameContext.get('wysiwyg'); const freshMatches = this.#findAllMatches(this.#searchTerm, wysiwyg); if (freshMatches.length === 0) return; replaceText = replaceText ?? (this.#replaceInput ? this.#replaceInput.value : ''); // Replace in reverse order to preserve earlier positions for (let i = freshMatches.length - 1; i >= 0; i--) { this.#replaceRange(freshMatches[i], replaceText); } wysiwyg.normalize(); this.#$.history.push(false); // Re-search (should find 0) this.#doSearch(); } /** * @description Replace a range's content with text. * For cross-node ranges (e.g. `<b>1</b>23` matching "123"), the replacement text * is inserted at the start node position, and the matched content across all spanned * nodes is removed cleanly. * @param {Range} range The range to replace * @param {string} replaceText Replacement string */ #replaceRange(range, replaceText) { const startNode = range.startContainer; const doc = this.#highlightDoc; const textNode = doc.createTextNode(replaceText); if (startNode === range.endContainer) { // Simple case: same text node range.deleteContents(); range.insertNode(textNode); startNode.parentNode?.normalize(); return; } // Cross-node range: replace based on start node position // 1. Delete matched content range.deleteContents(); // 2. Insert replacement text at start position if (startNode.nodeType === 3) { // Insert after the remaining text in the start node if (startNode.parentNode) { startNode.parentNode.insertBefore(textNode, startNode.nextSibling); startNode.parentNode.normalize(); } } else { range.insertNode(textNode); textNode.parentNode?.normalize(); } } // ────────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────────── /** @description Update match count display and prev/next button state. Panel-only. */ #updateCount() { if (!this.#panel) return; const hasMatches = this.#matches.length > 0; if (hasMatches) { this.#countDisplay.textContent = `${this.#currentIndex + 1}/${this.#matches.length}`; } else { this.#countDisplay.textContent = this.#searchTerm ? '0' : ''; } this.#btnPrev.disabled = !hasMatches; this.#btnNext.disabled = !hasMatches; this.#btnReplace.disabled = !hasMatches; this.#btnReplaceAll.disabled = !hasMatches; } /** * @description Get the document object for the current frame (iframe or main document). * @returns {Document} */ #getDocument() { const fc = this.#$.frameContext; return fc.get('options').get('iframe') ? fc.get('_wd') : _d; } /** @internal */ _destroy() { this.#removeGlobalCloseEvent(); this.#removeContentInputListener(); this.#resizeObserver &&= this.#resizeObserver.disconnect(); clearTimeout(this.#searchTimer); } } /** * @description Creates the FindFa/Replace panel HTML. * @param {SunEditor.Deps} $ editor deps * @returns {{ * panel: HTMLElement, * findInput: HTMLInputElement, * replaceInput: HTMLInputElement, * countDisplay: HTMLElement, * replaceRow: HTMLElement, * btnCase: HTMLButtonElement, * btnWord: HTMLButtonElement, * btnRegex: HTMLButtonElement, * btnPrev: HTMLButtonElement, * btnNext: HTMLButtonElement * btnReplace: HTMLButtonElement * btnReplaceAll: HTMLButtonElement * }} */ function CreateHTML({ lang, icons }) { const html = /*html*/ ` <div class="se-find-replace-row"> <div class="se-find-input-wrapper"> <input class="se-find-replace-input" type="text" placeholder="${lang.find || 'Find'}" spellcheck="false" autocomplete="off" /> <div class="se-find-replace-toggle"> <div class="se-find-replace-info"> <span class="se-find-replace-count"></span> </div> <button class="se-btn se-find-replace-btn se-find-opt-case" type="button" data-command="opt-case" title="${lang.finder_matchCase}"> ${icons.match_case} </button> <button class="se-btn se-find-replace-btn se-find-opt-word" type="button" data-command="opt-word" title="${lang.finder_wholeWord}"> ${icons.whole_word} </button> <button class="se-btn se-find-replace-btn se-find-opt-regex" type="button" data-command="opt-regex" title="${lang.finder_regex}"> ${icons.regex} </button> </div> </div> <div class="se-find-replace-buttons"> <button class="se-btn se-find-replace-btn" type="button" data-command="prev" title="${lang.finder_prev}\n(${env.shiftIcon} + Enter)"> ${icons.arrow_up_small} </button> <button class="se-btn se-find-replace-btn" type="button" data-command="next" title="${lang.finder_next}\n(Enter)"> ${icons.arrow_down_small} </button> <button class="se-btn se-find-replace-btn se-find-toggle-replace" type="button" data-command="toggle-replace" title="${lang.replace}"> ${icons.swap_vert} </button> <button class="se-btn se-find-replace-btn" type="button" data-command="close" title="${lang.close}"> ${icons.cancel} </button> </div> </div> <div class="se-find-replace-row se-find-replace-row-replace"> <div class="se-find-input-wrapper"> <input class="se-find-replace-input se-replace-input" type="text" placeholder="${lang.replace}" spellcheck="false" autocomplete="off" /> </div> <div class="se-find-replace-buttons"> <button class="se-btn se-find-replace-btn" type="button" data-command="replace" title="${lang.replace}\n(Enter)"> ${icons.replaceText} </button> <button class="se-btn se-find-replace-btn" type="button" data-command="replace-all" title="${lang.replaceAll}"> ${icons.relaceTextAll} </button> </div> </div>`; const panel = dom.utils.createElement('DIV', { class: 'se-find-replace' }, html); return { panel, findInput: panel.querySelector('.se-find-replace-input'), replaceInput: panel.querySelector('.se-replace-input'), countDisplay: panel.querySelector('.se-find-replace-count'), replaceRow: panel.querySelector('.se-find-replace-row-replace'), btnCase: panel.querySelector('.se-find-opt-case'), btnWord: panel.querySelector('.se-find-opt-word'), btnRegex: panel.querySelector('.se-find-opt-regex'), btnPrev: panel.querySelector('[data-command="prev"]'), btnNext: panel.querySelector('[data-command="next"]'), btnReplace: panel.querySelector('[data-command="replace"]'), btnReplaceAll: panel.querySelector('[data-command="replace-all"]'), }; } export default Finder;