Plugin Directory

Changeset 3485723


Ignore:
Timestamp:
03/18/2026 01:19:35 PM (2 weeks ago)
Author:
kasuga16
Message:

Update 1.5.6 -> 1.6.0

Location:
livedraft-search-replace
Files:
10 added
3 edited

Legend:

Unmodified
Added
Removed
  • livedraft-search-replace/trunk/livedraft-search-replace.js

    r3482326 r3485723  
    99 * @copyright         2026 Kasuga
    1010 * @license           GPL-2.0-or-later
    11  * @version           1.8.9
     11 * @version           2.0.0
    1212 */
    1313
    1414(function (wp) {
    1515    const { registerPlugin } = wp.plugins;
    16     const { PluginSidebar } = wp.editPost;
     16    // Prefer wp.editor.PluginSidebar (WP 6.6+); fall back to wp.editPost for older versions.
     17    const { PluginSidebar } = (wp.editor && wp.editor.PluginSidebar) ? wp.editor : wp.editPost;
    1718    const { TextControl, Button, CheckboxControl } = wp.components;
    1819    const { useState, useEffect, useRef, useCallback } = wp.element;
     
    2021
    2122    const i18n = window.ESR_L10N || {};
     23
     24    // ---------------------------------------------------------------------------
     25    // Resolves the editor iframe element.
     26    //
     27    // Selector priority:
     28    //   1. iframe[name="editor-canvas"]  — name attribute, language-independent (most reliable)
     29    //   2. .editor-canvas__iframe        — class name, language-independent
     30    //   3. iframe[title*="editor" i]     — title attribute, case-insensitive
     31    //
     32    // Selectors 1 and 2 are language-independent and cover virtually all cases.
     33    // Selector 3 is a last resort: WordPress translates the title attribute per
     34    // locale (e.g. "エディターキャンバス" in Japanese, "Lienzo del editor" in
     35    // Spanish), so a title-based match is unreliable across languages.
     36    // The case-insensitive flag ( i ) on selector 3 consolidates the previously
     37    // separate "editor" / "Editor" variants into one.
     38    // ---------------------------------------------------------------------------
     39    const getEditorIframe = () =>
     40        document.querySelector('iframe[name="editor-canvas"]') ||
     41        document.querySelector('.editor-canvas__iframe') ||
     42        document.querySelector('iframe[title*="editor" i]');
     43
     44    // Returns the document in which the block editor content is rendered.
     45    // Falls back to the top-level document for themes that do not use an iframe (e.g. Cocoon).
     46    const getEditorDocument = () => {
     47        const iframe = getEditorIframe();
     48        // Only use the iframe's document once it has fully loaded.
     49        if (iframe && iframe.contentDocument && iframe.contentDocument.readyState === 'complete') {
     50            return iframe.contentDocument;
     51        }
     52        return document;
     53    };
     54
     55    // ---------------------------------------------------------------------------
     56    // iframe-aware window accessor
     57    //
     58    // CSS Custom Highlight API (CSS.highlights) is scoped per window object, so
     59    // we must call it on the iframe's contentWindow when the editor is inside one.
     60    // ---------------------------------------------------------------------------
     61    const getEditorWindow = () => {
     62        const iframe = getEditorIframe();
     63        if (iframe && iframe.contentWindow) {
     64            return iframe.contentWindow;
     65        }
     66        return window;
     67    };
    2268
    2369    const SearchReplacePanel = () => {
     
    3076        const [autoPopulate, setAutoPopulate] = useState(false);
    3177
    32         const matchesRef = useRef([]);
    33         const isInternalUpdating = useRef(false);
    34         const currentIndexRef = useRef(0);
    35         const ticking = useRef(false);
    36         const lastSearchedRef = useRef('');
     78        const matchesRef = useRef([]);           // Array of all current match objects.
     79        const isInternalUpdating = useRef(false); // Prevents re-scan loops during replace operations.
     80        const currentIndexRef = useRef(0);        // 1-based index of the active (orange) match.
     81        const ticking = useRef(false);             // rAF lock to avoid redundant scan frames.
     82        const lastSearchedRef = useRef('');        // Tracks the last committed search term for Enter-key navigation.
    3783
    3884        const { blocks, editorMode } = useSelect((select) => {
     
    4793        const { updateBlockAttributes } = useDispatch('core/block-editor');
    4894
    49         const getCanvas = () => document.querySelector('.interface-interface-skeleton__content') ||
    50                                 document.querySelector('.edit-post-visual-editor__scroll-container');
    51 
    52         // Encodes special characters to HTML entities for safe storage.
     95        // ---------------------------------------------------------------------------
     96        // Returns the scroll container used to center the active match in the viewport.
     97        // Checks the iframe document first, then falls back to the top-level document.
     98        // ---------------------------------------------------------------------------
     99        const getCanvas = () => {
     100            const editorDoc = getEditorDocument();
     101            return (
     102                editorDoc.querySelector('.interface-interface-skeleton__content') ||
     103                editorDoc.querySelector('.edit-post-visual-editor__scroll-container') ||
     104                editorDoc.querySelector('.editor-visual-editor') ||
     105                editorDoc.querySelector('.block-editor-writing-flow') ||
     106                document.querySelector('.interface-interface-skeleton__content') ||
     107                document.querySelector('.edit-post-visual-editor__scroll-container')
     108            );
     109        };
     110
     111        // Encodes special characters to HTML entities for safe storage in block attributes.
    53112        const encodeForStorage = (str) => {
    54113            if (typeof str !== 'string') return str;
     
    62121        };
    63122
     123        // ---------------------------------------------------------------------------
     124        // Inject highlight styles into both the top-level document and the iframe.
     125        //
     126        // The CSS Custom Highlight API requires ::highlight() pseudo-element rules to
     127        // be present in the same document where the highlights are registered.
     128        // When the editor lives inside an iframe we inject the rules there as well.
     129        // A 1-second delayed retry handles cases where the iframe is not yet fully
     130        // loaded when the component first mounts.
     131        // ---------------------------------------------------------------------------
    64132        useEffect(() => {
    65             const id = 'esr-core-style';
    66             if (!document.getElementById(id)) {
    67                 const s = document.createElement('style');
    68                 s.id = id;
    69                 s.innerHTML = `
    70                     ::highlight(esr-match) { background-color: #fff59d !important; color: #000 !important; }
    71                     ::highlight(esr-current) { background-color: #ff9800 !important; color: #fff !important; outline: 2px solid #e65100; }
    72                 `;
    73                 document.head.appendChild(s);
    74             }
     133            const injectStyle = (targetDoc) => {
     134                const id = 'esr-core-style';
     135                if (targetDoc && !targetDoc.getElementById(id)) {
     136                    const s = targetDoc.createElement('style');
     137                    s.id = id;
     138                    s.innerHTML = `
     139                        ::highlight(esr-match) { background-color: #fff59d !important; color: #000 !important; }
     140                        ::highlight(esr-current) { background-color: #ff9800 !important; color: #fff !important; outline: 2px solid #e65100; }
     141                    `;
     142                    if (targetDoc.head) targetDoc.head.appendChild(s);
     143                }
     144            };
     145
     146            // Inject into the top-level document.
     147            injectStyle(document);
     148
     149            // Inject into the iframe document if one exists.
     150            const tryInjectIframe = () => {
     151                const editorDoc = getEditorDocument();
     152                if (editorDoc !== document) {
     153                    injectStyle(editorDoc);
     154                }
     155            };
     156
     157            tryInjectIframe();
     158
     159            // Retry after a short delay in case the iframe hasn't finished loading yet.
     160            const timer = setTimeout(tryInjectIframe, 1000);
     161
    75162            return () => {
     163                clearTimeout(timer);
     164                // Clean up all highlights from both the iframe window and the top window.
     165                try {
     166                    const editorWin = getEditorWindow();
     167                    if (editorWin.CSS && editorWin.CSS.highlights) editorWin.CSS.highlights.clear();
     168                } catch(e) {}
    76169                if (window.CSS && CSS.highlights) CSS.highlights.clear();
    77170            };
    78171        }, []);
    79172
    80         // Updates the search field automatically when text is selected in the editor.
     173        // ---------------------------------------------------------------------------
     174        // Auto-populate: updates the search field when the user selects text in the
     175        // editor.  Listens for selectionchange on both the top-level document and the
     176        // iframe document so it works regardless of whether an iframe is used.
     177        // Skipped while the plugin itself is modifying content (isInternalUpdating).
     178        // ---------------------------------------------------------------------------
    81179        useEffect(() => {
    82180            if (!autoPopulate) return;
    83181            const handleSelectionChange = () => {
    84182                if (isInternalUpdating.current) return;
    85                 if (document.activeElement && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA')) return;
     183                // Ignore selections made inside the plugin's own input fields.
     184                const activeEl = document.activeElement;
     185                if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA')) return;
     186
     187                // Check the top-level window selection.
    86188                const selection = window.getSelection();
    87189                if (selection && selection.rangeCount > 0) {
     
    92194                    }
    93195                }
     196                // Also check the iframe window selection.
     197                try {
     198                    const editorWin = getEditorWindow();
     199                    if (editorWin !== window) {
     200                        const iframeSel = editorWin.getSelection();
     201                        if (iframeSel && iframeSel.rangeCount > 0) {
     202                            const text = iframeSel.toString();
     203                            if (text && text.length > 0) {
     204                                setSearch(text);
     205                                lastSearchedRef.current = text;
     206                            }
     207                        }
     208                    }
     209                } catch(e) {}
    94210            };
     211
    95212            document.addEventListener('selectionchange', handleSelectionChange);
    96             return () => document.removeEventListener('selectionchange', handleSelectionChange);
     213            // Register on the iframe document as well.
     214            try {
     215                const editorDoc = getEditorDocument();
     216                if (editorDoc !== document) {
     217                    editorDoc.addEventListener('selectionchange', handleSelectionChange);
     218                }
     219            } catch(e) {}
     220
     221            return () => {
     222                document.removeEventListener('selectionchange', handleSelectionChange);
     223                try {
     224                    const editorDoc = getEditorDocument();
     225                    if (editorDoc !== document) {
     226                        editorDoc.removeEventListener('selectionchange', handleSelectionChange);
     227                    }
     228                } catch(e) {}
     229            };
    97230        }, [autoPopulate]);
    98231
     232        // ---------------------------------------------------------------------------
     233        // Core scan function: walks all text nodes in the editor, builds the match
     234        // list, and applies CSS Custom Highlight API ranges for yellow (all matches)
     235        // and orange (active match) highlights.
     236        //
     237        // Uses requestAnimationFrame with a ticking lock to coalesce rapid calls
     238        // (e.g. during typing) into a single DOM pass per frame.
     239        //
     240        // iframe support: all DOM queries and Range / Highlight objects are created
     241        // from editorDoc / editorWin so they work correctly inside an iframe.
     242        //
     243        // Editor root fallback chain (for themes that omit .block-editor-writing-flow):
     244        //   .block-editor-writing-flow      → standard WP editor
     245        //   .editor-writing-flow            → older WP versions
     246        //   .is-root-container              → Astra and similar iframe-based themes
     247        //   .block-editor-block-list__layout → further fallback
     248        //   document.body                   → last resort
     249        // ---------------------------------------------------------------------------
    99250        const performScan = useCallback((targetIndex = null, shouldJump = true) => {
    100             if (editorMode !== 'visual' || !window.CSS || !CSS.highlights) return;
     251            if (editorMode !== 'visual') return;
    101252            if (ticking.current) return;
    102253            ticking.current = true;
    103254
    104255            window.requestAnimationFrame(() => {
    105                 const editor = document.querySelector('.block-editor-writing-flow');
     256                const editorDoc = getEditorDocument();
     257                const editorWin = getEditorWindow();
     258
     259                // CSS Custom Highlight API is required; abort gracefully if unavailable.
     260                if (!editorWin.CSS || !editorWin.CSS.highlights) {
     261                    ticking.current = false;
     262                    return;
     263                }
     264
     265                // Resolve the editor root element using the fallback chain.
     266                const editor =
     267                    editorDoc.querySelector('.block-editor-writing-flow') ||
     268                    editorDoc.querySelector('.editor-writing-flow') ||
     269                    editorDoc.querySelector('.is-root-container') ||
     270                    editorDoc.querySelector('.block-editor-block-list__layout') ||
     271                    editorDoc.body;
     272
    106273                if (!editor) { ticking.current = false; return; }
    107274
    108                 CSS.highlights.delete('esr-match');
    109                 CSS.highlights.delete('esr-current');
     275                // Clear previous highlights before rebuilding.
     276                editorWin.CSS.highlights.delete('esr-match');
     277                editorWin.CSS.highlights.delete('esr-current');
    110278
    111279                if (!search || search === '') {
     
    124292                    regex = new RegExp(rawPattern, flags);
    125293
    126                     // Prevent infinite loops from regex that matches empty strings (e.g., [1-9]*)
    127                     if (regex.test('')) { 
     294                    // Prevent infinite loops from patterns that match empty strings (e.g. [1-9]*).
     295                    if (regex.test('')) {
    128296                        matchesRef.current = [];
    129297                        setTotalCount(0);
    130298                        setCurrent(0);
    131299                        ticking.current = false;
    132                         return; 
     300                        return;
    133301                    }
    134302                } catch (e) {
     303                    // Invalid regex — silently abort.
    135304                    ticking.current = false;
    136305                    return;
    137306                }
    138307
     308                // ---------------------------------------------------------------------------
     309                // TreeWalker: collect all matching text-node ranges.
     310                //
     311                // Accepted nodes: text nodes whose nearest ancestor is a .rich-text,
     312                // contenteditable, or .block-editor-rich-text__editable element.
     313                // The last selector is required for Astra, where that class is used
     314                // directly on the block element instead of a nested wrapper.
     315                //
     316                // Rejected nodes: anything inside toolbars, popovers, or insertion-point
     317                // UI to avoid false matches in non-content areas.
     318                // ---------------------------------------------------------------------------
    139319                const found = [];
    140                 const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, {
     320                const walker = editorDoc.createTreeWalker(editor, NodeFilter.SHOW_TEXT, {
    141321                    acceptNode: (node) => {
    142322                        const p = node.parentElement;
    143                         if (!p || p.closest('.block-editor-block-contextual-toolbar')) return NodeFilter.FILTER_REJECT;
    144                         return (p.closest('.rich-text') || p.closest('[contenteditable="true"]')) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
     323                        if (!p) return NodeFilter.FILTER_REJECT;
     324                        // Exclude toolbar and UI chrome.
     325                        if (
     326                            p.closest('.block-editor-block-contextual-toolbar') ||
     327                            p.closest('.block-editor-block-toolbar') ||
     328                            p.closest('.components-popover') ||
     329                            p.closest('.block-editor-block-list__insertion-point')
     330                        ) return NodeFilter.FILTER_REJECT;
     331                        // Accept only text inside editable content areas.
     332                        // .block-editor-rich-text__editable is needed for Astra's iframe structure.
     333                        return (
     334                            p.closest('.rich-text') ||
     335                            p.closest('[contenteditable="true"]') ||
     336                            p.closest('.block-editor-rich-text__editable')
     337                        ) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
    145338                    }
    146339                });
    147340
     341                // Walk every accepted text node and record each regex match.
     342                // localIndex counts matches per block so individual replacement can
     343                // target the correct occurrence within a block.
    148344                const blockCounters = {};
    149345                let n;
     
    157353                        blockCounters[cid] = (blockCounters[cid] || 0) + 1;
    158354                        found.push({ node: n, start: m.index, end: m.index + m[0].length, clientId: cid, localIndex: blockCounters[cid] });
    159 
    160                         // Safety: If regex matches an empty string, increment lastIndex to prevent infinite loop
     355                        // Safety: if the regex matches an empty string, advance lastIndex to prevent an infinite loop.
    161356                        if (m.index === regex.lastIndex) regex.lastIndex++;
    162357                    }
     
    172367                    currentIndexRef.current = nextIdx;
    173368
    174                     // Limits the number of visible highlights for better performance.
     369                    // Limit the number of visible highlights for better rendering performance.
     370                    // For large documents we render only a window of MAX_HIGHLIGHT ranges
     371                    // centered on the active match.
    175372                    const MAX_HIGHLIGHT = 300;
    176 
    177373                    let startIndex, endIndex;
    178374                    if (found.length <= MAX_HIGHLIGHT) {
     
    188384                    }
    189385
     386                    // Build Range objects using the iframe's Range constructor so they are
     387                    // valid within the iframe's document context.
    190388                    const matchRanges = found.slice(startIndex, endIndex).map(m => {
    191                         const r = new Range();
     389                        const r = new editorDoc.defaultView.Range();
    192390                        r.setStart(m.node, m.start);
    193391                        r.setEnd(m.node, m.end);
    194392                        return r;
    195393                    });
    196                     CSS.highlights.set('esr-match', new Highlight(...matchRanges));
    197 
     394                    editorWin.CSS.highlights.set('esr-match', new editorWin.Highlight(...matchRanges));
     395
     396                    // Highlight and optionally scroll to the active match.
    198397                    const active = found[nextIdx - 1];
    199398                    if (active && active.node && active.node.isConnected) {
    200                         const activeRange = new Range();
     399                        const activeRange = new editorDoc.defaultView.Range();
    201400                        activeRange.setStart(active.node, active.start);
    202401                        activeRange.setEnd(active.node, active.end);
    203                         CSS.highlights.set('esr-current', new Highlight(activeRange));
     402                        editorWin.CSS.highlights.set('esr-current', new editorWin.Highlight(activeRange));
    204403
    205404                        // Smoothly scrolls to the active match and centers it in the viewport.
     
    209408                                const canvas = getCanvas();
    210409                                if (canvas && rect.top !== undefined && rect.height > 0) {
    211                                     canvas.scrollTo({ top: canvas.scrollTop + rect.top - (window.innerHeight / 2), behavior: 'smooth' });
     410                                    canvas.scrollTo({ top: canvas.scrollTop + rect.top - (editorWin.innerHeight / 2), behavior: 'smooth' });
    212411                                } else if (active.node.parentElement) {
    213412                                    active.node.parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
     
    224423        }, [search, useRegex, caseSensitive, editorMode]);
    225424
     425        // ---------------------------------------------------------------------------
     426        // Replace handler: replaces either the current match or all matches at once.
     427        //
     428        // Replacements are written directly to block attributes via updateBlockAttributes
     429        // so they integrate with the editor's undo/redo history.
     430        //
     431        // HTML entity handling:
     432        //   - Standard mode builds a flexible pattern that matches both raw characters
     433        //     (e.g. "&") and their entity equivalents (e.g. "&amp;") so that content
     434        //     stored as HTML entities in block attributes is still found and replaced.
     435        //   - Regex mode processes the stored HTML character-by-character, decoding
     436        //     entities before matching so the user's regex sees plain text.
     437        // ---------------------------------------------------------------------------
    226438        const handleReplace = useCallback((all = false, specificMatch = null) => {
    227439            if (editorMode !== 'visual' || matchesRef.current.length === 0) return;
     
    239451                let pattern = useRegex ? search : search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    240452
    241                 // Handles mixed content where characters may exist as raw symbols or HTML entities.
    242453                if (!useRegex && /^(amp|lt|gt|quot|#39)$/i.test(search)) {
    243                     // Prevent matching partial entities (e.g., searching "lt" shouldn't match "&lt;").
     454                    // Prevent matching partial entities (e.g. searching "lt" must not match "&lt;").
    244455                    pattern = `(?<!&)${pattern}(?!;)`;
    245 
    246456                } else if (!useRegex) {
    247457                    // Create a flexible pattern to match both raw characters and their entity versions.
     
    265475
    266476            const encodedReplace = encodeForStorage(replace);
     477            // Flatten nested blocks into a single array for easy lookup by clientId.
    267478            const flatten = (bls) => bls.flatMap(b => [b, ...(b.innerBlocks ? flatten(b.innerBlocks) : [])]);
    268479            const allBlocks = flatten(blocks);
    269480
     481            // Applies the replacement to all string attributes of the given block.
     482            // If matchToReplace is provided, only replaces that specific occurrence
     483            // (identified by localIndex) rather than all occurrences in the block.
    270484            const performUpdate = (cid, matchToReplace = null) => {
    271485                const block = allBlocks.find(b => b.clientId === cid);
     
    282496
    283497                            if (useRegex) {
    284                                 // Treat HTML entities (e.g., &amp;) as a single unit to prevent partial replacement in Regex mode.
     498                                // Regex mode: treat HTML entities (e.g. &amp;) as a single unit to
     499                                // prevent partial replacement, then decode before matching.
    285500                                processed = original.replace(/&[a-z0-9#]+;|./gi, (m) => {
    286501                                    let matchText = m;
    287 
    288                                     // For HTML entities (e.g., &amp;), use the decoded character for matching.
    289502                                    if (m.startsWith('&') && m.endsWith(';')) {
    290503                                        const doc = new DOMParser().parseFromString(m, 'text/html');
    291504                                        matchText = doc.documentElement.textContent;
    292505                                    }
    293 
    294506                                    regex.lastIndex = 0;
    295507                                    if (regex.test(matchText)) {
     
    302514                                    return m;
    303515                                });
    304 
    305516                            } else {
    306                                 // Standard mode: Use the existing high-performance replacement logic.
     517                                // Standard mode: use the flexible entity-aware pattern built above.
    307518                                processed = original.replace(regex, (m) => {
    308519                                    counter++;
     
    325536
    326537            if (all) {
     538                // Replace All: update every block that contains at least one match,
     539                // then immediately clear the match list.
    327540                const clientIds = [...new Set(matchesRef.current.map(m => m.clientId))];
    328541                clientIds.forEach(cid => performUpdate(cid));
    329                 // Clear matches immediately on replace all
    330542                matchesRef.current = [];
    331543            } else {
     544                // Replace Current: update only the targeted match's block.
    332545                performUpdate(targetMatch.clientId, targetMatch);
     546                // Clear any browser selection left behind by the replacement.
     547                try {
     548                    const editorWin = getEditorWindow();
     549                    if (editorWin.getSelection) editorWin.getSelection().removeAllRanges();
     550                } catch(e) {}
    333551                if (window.getSelection) window.getSelection().removeAllRanges();
    334552            }
    335553
    336             // After replacement, finalize state if 'all' is true, or force a rescan to sync highlights for individual replacement.
     554            // After replacement, finalize state:
     555            // - Replace All: reset counters and clear highlights immediately.
     556            // - Replace Current: force a re-scan to restore highlights for remaining matches.
    337557            setTimeout(() => {
     558                try {
     559                    const editorWin = getEditorWindow();
     560                    if (editorWin.getSelection) editorWin.getSelection().removeAllRanges();
     561                } catch(e) {}
    338562                if (window.getSelection) window.getSelection().removeAllRanges();
     563
    339564                if (all) {
    340565                    setCurrent(0);
    341566                    setTotalCount(0);
    342567                    currentIndexRef.current = 0;
     568                    try {
     569                        const editorWin = getEditorWindow();
     570                        if (editorWin.CSS && editorWin.CSS.highlights) {
     571                            editorWin.CSS.highlights.delete('esr-match');
     572                            editorWin.CSS.highlights.delete('esr-current');
     573                        }
     574                    } catch(e) {}
    343575                    if (window.CSS && CSS.highlights) {
    344576                        CSS.highlights.delete('esr-match');
     
    347579                    isInternalUpdating.current = false;
    348580                } else {
    349                     // Force a re-scan to restore highlights for remaining matches.
    350581                    isInternalUpdating.current = false;
    351                     // Loop back to start if the last match was replaced.
     582                    // Loop back to the first match if the last one was just replaced.
    352583                    const nextIdx = currentIdx > prevTotal - 1 ? 1 : currentIdx;
    353584                    performScan(nextIdx, true);
     
    356587        }, [blocks, search, replace, useRegex, caseSensitive, editorMode, updateBlockAttributes, performScan]);
    357588
     589        // ---------------------------------------------------------------------------
     590        // Click handler registered on the editor writing area.
     591        //
     592        // Single click on a highlighted match: moves the active (orange) highlight
     593        //   to that match without scrolling the view.
     594        // Double click on a highlighted match: immediately replaces that specific
     595        //   match and prevents the default double-click text selection.
     596        //
     597        // Uses caretPositionFromPoint (standard) with a caretRangeFromPoint fallback
     598        // for older Chromium builds.  The listener is attached to the iframe's editor
     599        // element so it receives events from within the iframe.
     600        // ---------------------------------------------------------------------------
    358601        useEffect(() => {
    359             const editorEl = document.querySelector('.block-editor-writing-flow');
     602            const editorDoc = getEditorDocument();
     603            const editorEl =
     604                editorDoc.querySelector('.block-editor-writing-flow') ||
     605                editorDoc.querySelector('.editor-writing-flow') ||
     606                editorDoc.querySelector('.is-root-container') ||
     607                editorDoc.querySelector('.block-editor-block-list__layout');
    360608            if (!editorEl) return;
    361609
     
    364612
    365613                let range = null;
    366                 if (typeof document.caretPositionFromPoint === 'function') {
    367                     const pos = document.caretPositionFromPoint(e.clientX, e.clientY);
     614                if (typeof editorDoc.caretPositionFromPoint === 'function') {
     615                    const pos = editorDoc.caretPositionFromPoint(e.clientX, e.clientY);
    368616                    if (pos && pos.offsetNode) {
    369                         range = document.createRange();
     617                        range = editorDoc.createRange();
    370618                        range.setStart(pos.offsetNode, pos.offset);
    371619                        range.collapse(true);
    372620                    }
    373 
    374                 // Fallback: Legacy CaretRange API (Deprecated but kept for older Chromium)
    375                 } else if (typeof document.caretRangeFromPoint === 'function') {
    376                     range = document.caretRangeFromPoint(e.clientX, e.clientY);
     621                // Fallback: legacy caretRangeFromPoint API (deprecated but kept for older Chromium).
     622                } else if (typeof editorDoc.caretRangeFromPoint === 'function') {
     623                    range = editorDoc.caretRangeFromPoint(e.clientX, e.clientY);
    377624                }
    378625
     
    381628                const node = range.startContainer || range.offsetNode;
    382629                const offset = range.startOffset;
    383 
    384630                if (!node) return;
    385631
    386632                const clickedMatchIdx = matchesRef.current.findIndex(m => m.node === node && offset >= m.start && offset <= m.end);
    387633                if (clickedMatchIdx !== -1) {
    388                     // Double-click: Execute immediate replacement for the clicked match.
    389634                    if (e.detail === 2) {
     635                        // Double-click: execute immediate replacement for the clicked match.
    390636                        e.preventDefault();
    391637                        handleReplace(false, matchesRef.current[clickedMatchIdx]);
    392                     // Single-click: Update the current selection (orange highlight) without jumping the screen.
    393638                    } else {
     639                        // Single-click: update the active (orange) highlight without scrolling.
    394640                        performScan(clickedMatchIdx + 1, false);
    395641                    }
     
    400646        }, [handleReplace, performScan]);
    401647
     648        // ---------------------------------------------------------------------------
     649        // Reactive re-scan triggered on every block change (including Undo/Redo and
     650        // after individual Replace operations).
     651        //
     652        // When the editor's DOM is rebuilt (e.g. after Undo), previously stored node
     653        // references become detached.  We detect this, clear the stale match list,
     654        // and immediately re-scan to rebuild valid references.
     655        //
     656        // isInternalUpdating prevents this effect from firing during our own replace
     657        // operations, avoiding redundant scans.
     658        // ---------------------------------------------------------------------------
    402659        useEffect(() => {
    403             // Triggered on every block change (including Undo/Redo and after Replace).
    404660            if (isInternalUpdating.current) return;
    405661
    406             // When Undo happens, the DOM nodes are replaced.
     662            // Check if any stored node has been detached from the DOM (e.g. after Undo).
    407663            if (matchesRef.current.length > 0) {
    408664                const isAnyNodeDetached = matchesRef.current.some(m => !m.node || !m.node.isConnected);
    409665                if (isAnyNodeDetached) {
    410666                    matchesRef.current = [];
     667                    try {
     668                        const editorWin = getEditorWindow();
     669                        if (editorWin.CSS && editorWin.CSS.highlights) editorWin.CSS.highlights.clear();
     670                    } catch(e) {}
    411671                    if (window.CSS && CSS.highlights) CSS.highlights.clear();
    412672                }
    413673            }
    414674
    415             // This acts like clicking the "◎" button every time the content changes.
     675            // Re-scan without jumping the scroll position (acts like a silent refresh).
    416676            performScan(currentIndexRef.current || 1, false);
    417677        }, [blocks, search, useRegex, caseSensitive, performScan]);
    418678
    419         // Handles keyboard navigation ( Enter: Move to Next match. Shift + Enter: Move to Previous match. )
     679        // ---------------------------------------------------------------------------
     680        // Keyboard navigation via the search input field.
     681        //   Enter            → move to the next match (or start a fresh search).
     682        //   Shift + Enter    → move to the previous match.
     683        // ---------------------------------------------------------------------------
    420684        const handleEnterKey = (e) => {
    421685            if (e.key === 'Enter') {
     
    426690                    return;
    427691                }
    428                 // Determine if this is a fresh search or a navigation through existing matches.
     692                // Distinguish between a new search term and navigation within existing results.
    429693                const isNewSearch = search !== lastSearchedRef.current;
    430694                if (isNewSearch) {
     
    432696                    performScan(1, true);
    433697                } else {
    434                     const nextIndex = e.shiftKey ? (current <= 1 ? totalCount : current - 1) : (current >= totalCount ? 1 : current + 1);
     698                    const nextIndex = e.shiftKey
     699                        ? (current <= 1 ? totalCount : current - 1)
     700                        : (current >= totalCount ? 1 : current + 1);
    435701                    performScan(nextIndex, true);
    436702                }
     
    442708
    443709        return wp.element.createElement('div', { style: { padding: '16px' } },
    444             wp.element.createElement(TextControl, { label: i18n.searchLabel, value: search, onChange: setSearch, onKeyDown: handleEnterKey }),
    445             wp.element.createElement(TextControl, { label: i18n.replaceLabel, value: replace, onChange: setReplace, onKeyDown: handleEnterKey }),
     710            wp.element.createElement(TextControl, { label: i18n.searchLabel, value: search, onChange: setSearch, onKeyDown: handleEnterKey, __next40pxDefaultSize: true, __nextHasNoMarginBottom: true, style: { marginBottom: '8px' } }),
     711            wp.element.createElement(TextControl, { label: i18n.replaceLabel, value: replace, onChange: setReplace, onKeyDown: handleEnterKey, __next40pxDefaultSize: true, __nextHasNoMarginBottom: true, style: { marginBottom: '8px' } }),
    446712            wp.element.createElement('div', { style: { textAlign: 'center', margin: '15px 0', padding: '12px 8px', backgroundColor: '#f8f9fa', borderRadius: '6px', border: '1px solid #e2e8f0' } },
    447713                totalCount > 0 ? [
     
    460726            wp.element.createElement(Button, { isSecondary: true, onClick: () => handleReplace(true), disabled: totalCount === 0, style: { width: '100%', marginBottom: '15px', justifyContent: 'center' } }, i18n.replaceAll),
    461727            wp.element.createElement('div', { style: { border: '1px solid #ddd', padding: '8px', borderRadius: '4px' } },
    462                 wp.element.createElement(CheckboxControl, { label: i18n.useRegex, checked: useRegex, onChange: setUseRegex }),
    463                 wp.element.createElement(CheckboxControl, { label: i18n.caseSensitive, checked: caseSensitive, onChange: setCaseSensitive })
     728                wp.element.createElement(CheckboxControl, { label: i18n.useRegex, checked: useRegex, onChange: setUseRegex, __nextHasNoMarginBottom: true, style: { marginBottom: '8px' } }),
     729                wp.element.createElement(CheckboxControl, { label: i18n.caseSensitive, checked: caseSensitive, onChange: setCaseSensitive, __nextHasNoMarginBottom: true })
    464730            ),
    465731            wp.element.createElement('div', { style: { marginTop: '5px', border: '1px solid #ddd', padding: '8px', borderRadius: '4px' } },
    466                 wp.element.createElement(CheckboxControl, { label: i18n.autoPopulate, checked: autoPopulate, onChange: setAutoPopulate })
     732                wp.element.createElement(CheckboxControl, { label: i18n.autoPopulate, checked: autoPopulate, onChange: setAutoPopulate, __nextHasNoMarginBottom: true })
    467733            ),
    468734            wp.element.createElement('div', { style: { marginTop: '15px', padding: '10px', backgroundColor: '#f1f5f9', borderRadius: '4px' } },
  • livedraft-search-replace/trunk/livedraft-search-replace.php

    r3482326 r3485723  
    33 * Plugin Name: LiveDraft Search & Replace
    44 * Description: A high-performance, real-time Search and Replace tool for the Block Editor sidebar.
    5  * Version: 1.5.6
     5 * Version: 1.6.0
    66 * Author: Kasuga
    77 * License: GPLv2 or later
  • livedraft-search-replace/trunk/readme.txt

    r3482326 r3485723  
    66Tested up to: 6.9
    77Requires PHP: 7.4
    8 Stable tag: 1.5.6
     8Stable tag: 1.6.0
    99License: GPLv2 or later
    1010License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    101101== Changelog ==
    102102
     103= 1.6.0 =
     104* Fixed: Added compatibility with iframe-based block editors used by themes such as Astra. Search, highlight, and replace now work correctly in these environments.
     105* Improved: Suppressed deprecation warnings for `PluginSidebar`, `TextControl`, and `CheckboxControl` introduced in WP 6.6–6.8.
     106
    103107= 1.5.6 =
    104108* Fixed: Improved whitespace handling in "Auto-populate from Selection".
Note: See TracChangeset for help on using the changeset viewer.