网页查找替换增强

Ctrl+F 呼出查找替换面板,支持高亮、逐项替换、全部替换、撤回上一次替换操作(单个或全部)、快捷键Enter替换全部、Ctrl+Enter替换当前、Ctrl+Z撤回

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         网页查找替换增强
// @namespace    http://tampermonkey.local/
// @version      1.6
// @description  Ctrl+F 呼出查找替换面板,支持高亮、逐项替换、全部替换、撤回上一次替换操作(单个或全部)、快捷键Enter替换全部、Ctrl+Enter替换当前、Ctrl+Z撤回
// @author       akers
// @license      MIT
// @match        *://*/*
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  // ---- 配置 ----
  const HIGH_LIGHT_CLASS = 'tm-text-replace-hit';
  const PANEL_ID = 'tmTextReplacePanel';

  // ---- 样式 ----
  const style = document.createElement('style');
  style.textContent = `
    .${HIGH_LIGHT_CLASS} {
      background: yellow;
      color: black;
      border-radius: 2px;
      padding: 0 2px;
    }
    .${HIGH_LIGHT_CLASS}.tr-current {
      outline: 2px solid orange;
      box-shadow: 0 0 6px rgba(255,165,0,0.6);
    }
    #${PANEL_ID} input, #${PANEL_ID} button { font-size:12px; }
  `;
  document.head.appendChild(style);

  // ---- 工具 ----
  function isInEditable(evt) {
    const active = document.activeElement;
    if (!active) return false;
    const tag = active.tagName;
    if (tag === 'INPUT' || tag === 'TEXTAREA') return true;
    if (active.isContentEditable) return true;
    return false;
  }

  // ---- 全局状态 ----
  let hits = [];
  let currentIndex = -1;
  let ignoreObserver = false;
  let undoStack = []; // 每次替换操作存一条记录(单个或全部)

  // ---- UI ----
  function createPanel() {
    if (document.getElementById(PANEL_ID)) return document.getElementById(PANEL_ID);
    const container = document.createElement('div');
    container.id = PANEL_ID;
    container.style = `
      position: fixed;
      top: 1%;
      left: 50%;
      transform: translateX(-50%);
      z-index: 2147483647;
      background: #fff;
      color: #000;
      border: 1px solid #ddd;
      padding: 10px;
      width: 360px;
      font-family: Arial, sans-serif;
      border-radius: 6px;
      box-shadow: 0 4px 12px rgba(0,0,0,0.25);
    `;
    container.innerHTML = `
      <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
        <strong>查找替换</strong>
        <div>
          <label style="font-size:12px;margin-right:6px;"><input type="checkbox" id="trCase"> 区分大小写</label>
          <button id="trClose" title="关闭" style="margin-left:6px;">✖</button>
        </div>
      </div>
      <div style="display:flex;gap:6px;margin-bottom:6px;">
        <input id="trFind" placeholder="查找内容" style="flex:1;padding:6px;">
        <input id="trReplace" placeholder="替换为" style="width:120px;padding:6px;">
      </div>
      <div style="display:flex;gap:6px;justify-content:flex-end;">
        <button id="trPrev">上一个</button>
        <button id="trNext">下一个</button>
        <button id="trReplaceOne">替换</button>
        <button id="trReplaceAll">全部替换</button>
        <button id="trUndo">撤回</button>
      </div>
      <div style="margin-top:6px;font-size:12px;color:#666;display:flex;justify-content:space-between;align-items:center;">
        <span id="trStatus">匹配: 0</span>
      </div>
    `;
    document.body.appendChild(container);

    container.querySelector('#trClose').onclick = () => { container.remove(); removeHighlights(); };

    return container;
  }

  // ---- 清理高亮 ----
  function removeHighlights() {
    if (!hits.length) return;
    ignoreObserver = true;
    for (const el of hits) {
      const parent = el.parentNode;
      if (!parent) continue;
      parent.replaceChild(document.createTextNode(el.textContent), el);
      parent.normalize && parent.normalize();
    }
    hits = [];
    currentIndex = -1;
    ignoreObserver = false;
    updateStatus();
  }

  // ---- 单节点高亮 ----
  function highlightInTextNode(textNode, regex) {
    const text = textNode.nodeValue;
    let match, lastIndex = 0;
    const docFrag = document.createDocumentFragment();
    let any = false;

    while ((match = regex.exec(text)) !== null) {
      any = true;
      const start = match.index;
      const end = start + match[0].length;
      if (start > lastIndex) docFrag.appendChild(document.createTextNode(text.slice(lastIndex, start)));
      const sp = document.createElement('span');
      sp.className = HIGH_LIGHT_CLASS;
      sp.textContent = text.slice(start, end);
      sp.setAttribute('title', '查找匹配');
      docFrag.appendChild(sp);
      lastIndex = end;
      if (regex.lastIndex === match.index) regex.lastIndex++;
    }
    if (!any) return null;
    if (lastIndex < text.length) docFrag.appendChild(document.createTextNode(text.slice(lastIndex)));
    return docFrag;
  }

  // ---- 遍历并高亮 ----
  function doHighlight(findText, caseSensitive = false) {
    removeHighlights();
    if (!findText) return;
    const esc = findText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    const flags = caseSensitive ? 'g' : 'gi';
    const regex = new RegExp(esc, flags);

    const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
      acceptNode(node) {
        if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
        const parent = node.parentNode;
        if (!parent) return NodeFilter.FILTER_REJECT;
        const tag = parent.tagName;
        if (!tag) return NodeFilter.FILTER_REJECT;
        if (['SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME'].includes(tag)) return NodeFilter.FILTER_REJECT;
        if (parent.isContentEditable) return NodeFilter.FILTER_REJECT;
        if (parent.closest && parent.closest('input,textarea')) return NodeFilter.FILTER_REJECT;
        return NodeFilter.FILTER_ACCEPT;
      }
    });

    const nodesToReplace = [];
    while (walker.nextNode()) nodesToReplace.push(walker.currentNode);

    ignoreObserver = true;
    try {
      for (const tnode of nodesToReplace) {
        const frag = highlightInTextNode(tnode, regex);
        if (frag) tnode.parentNode.replaceChild(frag, tnode);
      }
      hits = Array.from(document.querySelectorAll('span.' + HIGH_LIGHT_CLASS));
      hits.forEach((el, idx) => { el.onclick = (e) => { e.stopPropagation(); setCurrent(idx, true); }; });
    } finally { ignoreObserver = false; }

    if (hits.length) setCurrent(0, true);
    updateStatus();
  }

  function updateStatus() {
    const panel = document.getElementById(PANEL_ID);
    if (!panel) return;
    const stat = panel.querySelector('#trStatus');
    stat.textContent = `匹配: ${hits.length}  当前: ${currentIndex >= 0 ? (currentIndex + 1) : 0}`;
  }

  function setCurrent(index, scroll = true) {
    if (!hits.length) { currentIndex = -1; updateStatus(); return; }
    if (index < 0) index = hits.length - 1;
    if (index >= hits.length) index = 0;
    if (currentIndex >= 0 && hits[currentIndex]) hits[currentIndex].classList.remove('tr-current');
    currentIndex = index;
    const el = hits[currentIndex];
    if (!el) return;
    el.classList.add('tr-current');
    if (scroll) try { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch(e){el.scrollIntoView();}
    updateStatus();
  }

  // ---- 替换 ----
  function replaceCurrent(replaceText) {
    if (!hits.length || currentIndex < 0) return;
    const cur = hits[currentIndex];
    const parent = cur.parentNode;
    if (!parent) return;
    ignoreObserver = true;
    undoStack.push({ type:'replaceOne', items:[{ parent, originalText: cur.textContent, index: Array.prototype.indexOf.call(parent.childNodes, cur) }] });
    parent.replaceChild(document.createTextNode(replaceText), cur);
    parent.normalize && parent.normalize();
    ignoreObserver = false;
    doHighlight(document.getElementById(PANEL_ID).querySelector('#trFind').value, document.getElementById(PANEL_ID).querySelector('#trCase').checked);
  }

  function replaceAll(replaceText) {
    if (!hits.length) return;
    ignoreObserver = true;
    const items = hits.map(el => ({ parent: el.parentNode, originalText: el.textContent, index: Array.prototype.indexOf.call(el.parentNode.childNodes, el) }));
    undoStack.push({ type:'replaceAll', items });
    for (let i = hits.length - 1; i >= 0; i--) {
      const el = hits[i];
      const parent = el.parentNode;
      if (!parent) continue;
      parent.replaceChild(document.createTextNode(replaceText), el);
      parent.normalize && parent.normalize();
    }
    ignoreObserver = false;
    hits = [];
    currentIndex = -1;
    updateStatus();
  }

  function undoLast() {
    if (!undoStack.length) return;
    ignoreObserver = true;
    const op = undoStack.pop();
    for (const it of op.items) {
      const textNode = document.createTextNode(it.originalText);
      const parent = it.parent;
      if (parent.childNodes[it.index]) parent.insertBefore(textNode, parent.childNodes[it.index]);
      else parent.appendChild(textNode);
      parent.normalize && parent.normalize();
    }
    ignoreObserver = false;
    const panel = document.getElementById(PANEL_ID);
    if (panel && panel.querySelector('#trFind').value) {
      doHighlight(panel.querySelector('#trFind').value, panel.querySelector('#trCase').checked);
    }
  }

  function bindPanelEvents(panel) {
    const findInput = panel.querySelector('#trFind');
    const replaceInput = panel.querySelector('#trReplace');
    const nextBtn = panel.querySelector('#trNext');
    const prevBtn = panel.querySelector('#trPrev');
    const repOneBtn = panel.querySelector('#trReplaceOne');
    const repAllBtn = panel.querySelector('#trReplaceAll');
    const undoBtn = panel.querySelector('#trUndo');
    const caseChk = panel.querySelector('#trCase');

    let tmr = null;
    findInput.addEventListener('input', () => {
      clearTimeout(tmr);
      tmr = setTimeout(() => doHighlight(findInput.value, caseChk.checked), 250);
    });

    findInput.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') {
        e.preventDefault();
        if (e.ctrlKey) replaceCurrent(replaceInput.value); // Ctrl+Enter 替换当前
        else replaceAll(replaceInput.value);              // Enter 替换全部
      }
    });
    replaceInput.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') {
        e.preventDefault();
        if (e.ctrlKey) replaceCurrent(replaceInput.value);
        else replaceAll(replaceInput.value);
      }
    });

    nextBtn.onclick = () => { if (hits.length) setCurrent(currentIndex+1); };
    prevBtn.onclick = () => { if (hits.length) setCurrent(currentIndex-1); };
    repOneBtn.onclick = () => replaceCurrent(replaceInput.value);
    repAllBtn.onclick = () => replaceAll(replaceInput.value);
    undoBtn.onclick = () => undoLast();
  }

  function openPanelAndFocus() {
    const panel = createPanel();
    const findInput = panel.querySelector('#trFind');
    findInput.focus();
    return panel;
  }

  // ---- 全局快捷键 ----
  window.addEventListener('keydown', function(e){
    const panel = document.getElementById(PANEL_ID);
    // Esc 关闭面板
    if (e.key==='Escape' && panel){
        e.preventDefault();
        panel.remove();
        removeHighlights();
        return;
    }

    // Ctrl+F 打开面板
    if (!isInEditable(e) && e.ctrlKey && e.key.toLowerCase()==='f'){
        e.preventDefault();
        if (panel) panel.querySelector('#trFind').focus();
        else {
            const newPanel = openPanelAndFocus();
            bindPanelEvents(newPanel);
        }
    }

    // Ctrl+Z 撤回
    if (!isInEditable(e) && e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === 'z') {
        e.preventDefault();
        undoLast();
    }
  });

  // ---- 页面变动监控 ----
  const observer = new MutationObserver(mutations => {
    if (ignoreObserver) return;
    const panel = document.getElementById(PANEL_ID);
    if (!panel) return;
    const findInput = panel.querySelector('#trFind');
    if (findInput && findInput.value) {
      if (window.__tm_tr_debounce) clearTimeout(window.__tm_tr_debounce);
      window.__tm_tr_debounce = setTimeout(() => doHighlight(findInput.value, panel.querySelector('#trCase').checked), 300);
    }
  });
  observer.observe(document.body, { childList:true, subtree:true, characterData:true });
  window.addEventListener('beforeunload', ()=>removeHighlights());
})();