Greasy Fork

Greasy Fork is available in English.

网页查找替换增强

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

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

You will need to install an extension such as Tampermonkey to install this script.

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

您需要先安装一个扩展,例如 篡改猴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());
})();