Changeset 3485723
- Timestamp:
- 03/18/2026 01:19:35 PM (2 weeks ago)
- Location:
- livedraft-search-replace
- Files:
-
- 10 added
- 3 edited
-
tags/1.6.0 (added)
-
tags/1.6.0/languages (added)
-
tags/1.6.0/languages/livedraft-search-replace-ja.mo (added)
-
tags/1.6.0/languages/livedraft-search-replace-ja.po (added)
-
tags/1.6.0/languages/livedraft-search-replace.pot (added)
-
tags/1.6.0/livedraft-search-replace.js (added)
-
tags/1.6.0/livedraft-search-replace.php (added)
-
tags/1.6.0/readme.txt (added)
-
tags/1.6.0/screenshot-1.png (added)
-
tags/1.6.0/screenshot-2.png (added)
-
trunk/livedraft-search-replace.js (modified) (26 diffs)
-
trunk/livedraft-search-replace.php (modified) (1 diff)
-
trunk/readme.txt (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
livedraft-search-replace/trunk/livedraft-search-replace.js
r3482326 r3485723 9 9 * @copyright 2026 Kasuga 10 10 * @license GPL-2.0-or-later 11 * @version 1.8.911 * @version 2.0.0 12 12 */ 13 13 14 14 (function (wp) { 15 15 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; 17 18 const { TextControl, Button, CheckboxControl } = wp.components; 18 19 const { useState, useEffect, useRef, useCallback } = wp.element; … … 20 21 21 22 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 }; 22 68 23 69 const SearchReplacePanel = () => { … … 30 76 const [autoPopulate, setAutoPopulate] = useState(false); 31 77 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. 37 83 38 84 const { blocks, editorMode } = useSelect((select) => { … … 47 93 const { updateBlockAttributes } = useDispatch('core/block-editor'); 48 94 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. 53 112 const encodeForStorage = (str) => { 54 113 if (typeof str !== 'string') return str; … … 62 121 }; 63 122 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 // --------------------------------------------------------------------------- 64 132 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 75 162 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) {} 76 169 if (window.CSS && CSS.highlights) CSS.highlights.clear(); 77 170 }; 78 171 }, []); 79 172 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 // --------------------------------------------------------------------------- 81 179 useEffect(() => { 82 180 if (!autoPopulate) return; 83 181 const handleSelectionChange = () => { 84 182 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. 86 188 const selection = window.getSelection(); 87 189 if (selection && selection.rangeCount > 0) { … … 92 194 } 93 195 } 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) {} 94 210 }; 211 95 212 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 }; 97 230 }, [autoPopulate]); 98 231 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 // --------------------------------------------------------------------------- 99 250 const performScan = useCallback((targetIndex = null, shouldJump = true) => { 100 if (editorMode !== 'visual' || !window.CSS || !CSS.highlights) return;251 if (editorMode !== 'visual') return; 101 252 if (ticking.current) return; 102 253 ticking.current = true; 103 254 104 255 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 106 273 if (!editor) { ticking.current = false; return; } 107 274 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'); 110 278 111 279 if (!search || search === '') { … … 124 292 regex = new RegExp(rawPattern, flags); 125 293 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('')) { 128 296 matchesRef.current = []; 129 297 setTotalCount(0); 130 298 setCurrent(0); 131 299 ticking.current = false; 132 return; 300 return; 133 301 } 134 302 } catch (e) { 303 // Invalid regex — silently abort. 135 304 ticking.current = false; 136 305 return; 137 306 } 138 307 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 // --------------------------------------------------------------------------- 139 319 const found = []; 140 const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, {320 const walker = editorDoc.createTreeWalker(editor, NodeFilter.SHOW_TEXT, { 141 321 acceptNode: (node) => { 142 322 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; 145 338 } 146 339 }); 147 340 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. 148 344 const blockCounters = {}; 149 345 let n; … … 157 353 blockCounters[cid] = (blockCounters[cid] || 0) + 1; 158 354 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. 161 356 if (m.index === regex.lastIndex) regex.lastIndex++; 162 357 } … … 172 367 currentIndexRef.current = nextIdx; 173 368 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. 175 372 const MAX_HIGHLIGHT = 300; 176 177 373 let startIndex, endIndex; 178 374 if (found.length <= MAX_HIGHLIGHT) { … … 188 384 } 189 385 386 // Build Range objects using the iframe's Range constructor so they are 387 // valid within the iframe's document context. 190 388 const matchRanges = found.slice(startIndex, endIndex).map(m => { 191 const r = new Range();389 const r = new editorDoc.defaultView.Range(); 192 390 r.setStart(m.node, m.start); 193 391 r.setEnd(m.node, m.end); 194 392 return r; 195 393 }); 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. 198 397 const active = found[nextIdx - 1]; 199 398 if (active && active.node && active.node.isConnected) { 200 const activeRange = new Range();399 const activeRange = new editorDoc.defaultView.Range(); 201 400 activeRange.setStart(active.node, active.start); 202 401 activeRange.setEnd(active.node, active.end); 203 CSS.highlights.set('esr-current', newHighlight(activeRange));402 editorWin.CSS.highlights.set('esr-current', new editorWin.Highlight(activeRange)); 204 403 205 404 // Smoothly scrolls to the active match and centers it in the viewport. … … 209 408 const canvas = getCanvas(); 210 409 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' }); 212 411 } else if (active.node.parentElement) { 213 412 active.node.parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); … … 224 423 }, [search, useRegex, caseSensitive, editorMode]); 225 424 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. "&") 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 // --------------------------------------------------------------------------- 226 438 const handleReplace = useCallback((all = false, specificMatch = null) => { 227 439 if (editorMode !== 'visual' || matchesRef.current.length === 0) return; … … 239 451 let pattern = useRegex ? search : search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 240 452 241 // Handles mixed content where characters may exist as raw symbols or HTML entities.242 453 if (!useRegex && /^(amp|lt|gt|quot|#39)$/i.test(search)) { 243 // Prevent matching partial entities (e.g. , searching "lt" shouldn't match "<").454 // Prevent matching partial entities (e.g. searching "lt" must not match "<"). 244 455 pattern = `(?<!&)${pattern}(?!;)`; 245 246 456 } else if (!useRegex) { 247 457 // Create a flexible pattern to match both raw characters and their entity versions. … … 265 475 266 476 const encodedReplace = encodeForStorage(replace); 477 // Flatten nested blocks into a single array for easy lookup by clientId. 267 478 const flatten = (bls) => bls.flatMap(b => [b, ...(b.innerBlocks ? flatten(b.innerBlocks) : [])]); 268 479 const allBlocks = flatten(blocks); 269 480 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. 270 484 const performUpdate = (cid, matchToReplace = null) => { 271 485 const block = allBlocks.find(b => b.clientId === cid); … … 282 496 283 497 if (useRegex) { 284 // Treat HTML entities (e.g., &) as a single unit to prevent partial replacement in Regex mode. 498 // Regex mode: treat HTML entities (e.g. &) as a single unit to 499 // prevent partial replacement, then decode before matching. 285 500 processed = original.replace(/&[a-z0-9#]+;|./gi, (m) => { 286 501 let matchText = m; 287 288 // For HTML entities (e.g., &), use the decoded character for matching.289 502 if (m.startsWith('&') && m.endsWith(';')) { 290 503 const doc = new DOMParser().parseFromString(m, 'text/html'); 291 504 matchText = doc.documentElement.textContent; 292 505 } 293 294 506 regex.lastIndex = 0; 295 507 if (regex.test(matchText)) { … … 302 514 return m; 303 515 }); 304 305 516 } else { 306 // Standard mode: Use the existing high-performance replacement logic.517 // Standard mode: use the flexible entity-aware pattern built above. 307 518 processed = original.replace(regex, (m) => { 308 519 counter++; … … 325 536 326 537 if (all) { 538 // Replace All: update every block that contains at least one match, 539 // then immediately clear the match list. 327 540 const clientIds = [...new Set(matchesRef.current.map(m => m.clientId))]; 328 541 clientIds.forEach(cid => performUpdate(cid)); 329 // Clear matches immediately on replace all330 542 matchesRef.current = []; 331 543 } else { 544 // Replace Current: update only the targeted match's block. 332 545 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) {} 333 551 if (window.getSelection) window.getSelection().removeAllRanges(); 334 552 } 335 553 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. 337 557 setTimeout(() => { 558 try { 559 const editorWin = getEditorWindow(); 560 if (editorWin.getSelection) editorWin.getSelection().removeAllRanges(); 561 } catch(e) {} 338 562 if (window.getSelection) window.getSelection().removeAllRanges(); 563 339 564 if (all) { 340 565 setCurrent(0); 341 566 setTotalCount(0); 342 567 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) {} 343 575 if (window.CSS && CSS.highlights) { 344 576 CSS.highlights.delete('esr-match'); … … 347 579 isInternalUpdating.current = false; 348 580 } else { 349 // Force a re-scan to restore highlights for remaining matches.350 581 isInternalUpdating.current = false; 351 // Loop back to start if the last match wasreplaced.582 // Loop back to the first match if the last one was just replaced. 352 583 const nextIdx = currentIdx > prevTotal - 1 ? 1 : currentIdx; 353 584 performScan(nextIdx, true); … … 356 587 }, [blocks, search, replace, useRegex, caseSensitive, editorMode, updateBlockAttributes, performScan]); 357 588 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 // --------------------------------------------------------------------------- 358 601 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'); 360 608 if (!editorEl) return; 361 609 … … 364 612 365 613 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); 368 616 if (pos && pos.offsetNode) { 369 range = document.createRange();617 range = editorDoc.createRange(); 370 618 range.setStart(pos.offsetNode, pos.offset); 371 619 range.collapse(true); 372 620 } 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); 377 624 } 378 625 … … 381 628 const node = range.startContainer || range.offsetNode; 382 629 const offset = range.startOffset; 383 384 630 if (!node) return; 385 631 386 632 const clickedMatchIdx = matchesRef.current.findIndex(m => m.node === node && offset >= m.start && offset <= m.end); 387 633 if (clickedMatchIdx !== -1) { 388 // Double-click: Execute immediate replacement for the clicked match.389 634 if (e.detail === 2) { 635 // Double-click: execute immediate replacement for the clicked match. 390 636 e.preventDefault(); 391 637 handleReplace(false, matchesRef.current[clickedMatchIdx]); 392 // Single-click: Update the current selection (orange highlight) without jumping the screen.393 638 } else { 639 // Single-click: update the active (orange) highlight without scrolling. 394 640 performScan(clickedMatchIdx + 1, false); 395 641 } … … 400 646 }, [handleReplace, performScan]); 401 647 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 // --------------------------------------------------------------------------- 402 659 useEffect(() => { 403 // Triggered on every block change (including Undo/Redo and after Replace).404 660 if (isInternalUpdating.current) return; 405 661 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). 407 663 if (matchesRef.current.length > 0) { 408 664 const isAnyNodeDetached = matchesRef.current.some(m => !m.node || !m.node.isConnected); 409 665 if (isAnyNodeDetached) { 410 666 matchesRef.current = []; 667 try { 668 const editorWin = getEditorWindow(); 669 if (editorWin.CSS && editorWin.CSS.highlights) editorWin.CSS.highlights.clear(); 670 } catch(e) {} 411 671 if (window.CSS && CSS.highlights) CSS.highlights.clear(); 412 672 } 413 673 } 414 674 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). 416 676 performScan(currentIndexRef.current || 1, false); 417 677 }, [blocks, search, useRegex, caseSensitive, performScan]); 418 678 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 // --------------------------------------------------------------------------- 420 684 const handleEnterKey = (e) => { 421 685 if (e.key === 'Enter') { … … 426 690 return; 427 691 } 428 // D etermine if this is a fresh search or a navigation through existing matches.692 // Distinguish between a new search term and navigation within existing results. 429 693 const isNewSearch = search !== lastSearchedRef.current; 430 694 if (isNewSearch) { … … 432 696 performScan(1, true); 433 697 } 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); 435 701 performScan(nextIndex, true); 436 702 } … … 442 708 443 709 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' } }), 446 712 wp.element.createElement('div', { style: { textAlign: 'center', margin: '15px 0', padding: '12px 8px', backgroundColor: '#f8f9fa', borderRadius: '6px', border: '1px solid #e2e8f0' } }, 447 713 totalCount > 0 ? [ … … 460 726 wp.element.createElement(Button, { isSecondary: true, onClick: () => handleReplace(true), disabled: totalCount === 0, style: { width: '100%', marginBottom: '15px', justifyContent: 'center' } }, i18n.replaceAll), 461 727 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 }) 464 730 ), 465 731 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 }) 467 733 ), 468 734 wp.element.createElement('div', { style: { marginTop: '15px', padding: '10px', backgroundColor: '#f1f5f9', borderRadius: '4px' } }, -
livedraft-search-replace/trunk/livedraft-search-replace.php
r3482326 r3485723 3 3 * Plugin Name: LiveDraft Search & Replace 4 4 * Description: A high-performance, real-time Search and Replace tool for the Block Editor sidebar. 5 * Version: 1. 5.65 * Version: 1.6.0 6 6 * Author: Kasuga 7 7 * License: GPLv2 or later -
livedraft-search-replace/trunk/readme.txt
r3482326 r3485723 6 6 Tested up to: 6.9 7 7 Requires PHP: 7.4 8 Stable tag: 1. 5.68 Stable tag: 1.6.0 9 9 License: GPLv2 or later 10 10 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 101 101 == Changelog == 102 102 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 103 107 = 1.5.6 = 104 108 * Fixed: Improved whitespace handling in "Auto-populate from Selection".
Note: See TracChangeset
for help on using the changeset viewer.