Greasy Fork

来自缓存

Greasy Fork is available in English.

网页内容搜索增强

Ctrl+F 呼出自定义搜索面板,支持上下键导航,Esc 关闭,搜索框固定顶部,自动监听新内容并保持当前位置

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         网页内容搜索增强
// @namespace    http://tampermonkey.net/
// @version      3.1
// @description  Ctrl+F 呼出自定义搜索面板,支持上下键导航,Esc 关闭,搜索框固定顶部,自动监听新内容并保持当前位置
// @match        *://*/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // 创建侧边栏
    const panel = document.createElement("div");
    panel.style.cssText = `
        position: fixed; top: 0; right: 0; width: 340px; height: 100%;
        background: #f9f9f9; border-left: 1px solid #ccc; overflow-y: auto;
        z-index: 2147483647; font-size: 14px; padding: 10px;
        display: none; box-sizing: border-box;
    `;
    panel.innerHTML = `
        <input id="searchBox" type="text" placeholder="输入关键词..."
               style="width:100%;padding:6px 8px;box-sizing:border-box;
                      border:1px solid #ccc;border-radius:4px;
                      position:sticky; top:0; background:#f9f9f9; z-index:1;">
        <div id="count" style="margin:6px 0;color:#333;font-weight:bold;"></div>
        <div id="results"></div>
        <style>
            .__wl-item { cursor:pointer; margin:6px 0; padding:4px 6px; border-radius:4px; }
            .__wl-item.__active { background:#e6f0ff; outline:1px solid #99c2ff; }
            .__wl-item b { color:red; }
        </style>
    `;
    document.body.appendChild(panel);

    const searchBox = panel.querySelector("#searchBox");
    const resultsDiv = panel.querySelector("#results");
    const countDiv = panel.querySelector("#count");

    let matches = [];
    let items = [];
    let currentIndex = -1;

    // 搜索并渲染结果
    function search(keyword) {
        resultsDiv.innerHTML = "";
        countDiv.textContent = "";
        matches = [];
        items = [];

        if (!keyword) {
            currentIndex = -1;
            return;
        }

        const regex = new RegExp(`(.{0,20})(${keyword})(.{0,20})`, "gi");
        const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);

        let node, index = 0;
        while (node = walker.nextNode()) {
            if (panel.contains(node.parentNode)) continue; // 跳过侧边栏自身
            const text = node.textContent;
            let match;
            while ((match = regex.exec(text)) !== null) {
                index++;
                const matchObj = {
                    node,
                    start: match.index + match[1].length,
                    end: match.index + match[1].length + match[2].length,
                    context: match
                };
                matches.push(matchObj);

                const item = document.createElement("div");
                item.className = "__wl-item";
                item.dataset.index = String(index - 1);

                const beforeSpan = document.createElement("span");
                beforeSpan.textContent = match[1];
                const keywordSpan = document.createElement("b");
                keywordSpan.textContent = match[2];
                const afterSpan = document.createElement("span");
                afterSpan.textContent = match[3];

                item.append(`${index}. ...`);
                item.appendChild(beforeSpan);
                item.appendChild(keywordSpan);
                item.appendChild(afterSpan);
                item.append("...");

                item.onclick = (() => {
                    const m = matchObj;
                    return () => locateMatch(m, item);
                })();

                resultsDiv.appendChild(item);
                items.push(item);
            }
        }

        countDiv.textContent = `共找到 ${index} 个结果`;

        // 保持当前位置
        if (items.length > 0) {
            let restoreIndex = currentIndex;
            if (restoreIndex < 0) restoreIndex = 0;
            if (restoreIndex >= items.length) restoreIndex = items.length - 1;
            setActiveIndex(restoreIndex, false); // false 表示只高亮,不强制滚动定位
        } else {
            currentIndex = -1;
        }
    }

    // 用 Selection 高亮并滚动定位
    function locateMatch(m, itemEl) {
        const range = document.createRange();
        range.setStart(m.node, m.start);
        range.setEnd(m.node, m.end);

        const sel = window.getSelection();
        sel.removeAllRanges();
        sel.addRange(range);

        const rect = range.getBoundingClientRect();
        window.scrollTo({
            top: window.scrollY + rect.top - window.innerHeight / 2,
            behavior: "smooth"
        });

        const idx = Number(itemEl.dataset.index || -1);
        if (idx >= 0) setActiveIndex(idx, false);
    }

    function setActiveIndex(idx, optionallyLocate = true) {
        if (items.length === 0) return;
        if (idx < 0) idx = 0;
        if (idx >= items.length) idx = items.length - 1;

        if (currentIndex >= 0 && items[currentIndex]) {
            items[currentIndex].classList.remove("__active");
        }
        currentIndex = idx;
        const itemEl = items[currentIndex];
        itemEl.classList.add("__active");
        itemEl.scrollIntoView({ behavior: "smooth", block: "nearest" });

        if (optionallyLocate) {
            const m = matches[currentIndex];
            locateMatch(m, itemEl);
        }
    }

    function debounce(fn, delay) {
        let timer;
        return function(...args) {
            clearTimeout(timer);
            timer = setTimeout(() => fn.apply(this, args), delay);
        };
    }
    searchBox.addEventListener("input", debounce(e => {
        search(e.target.value.trim());
    }, 250));

    function togglePanel(show) {
        if (show === undefined) {
            panel.style.display = (panel.style.display === "none") ? "block" : "none";
        } else {
            panel.style.display = show ? "block" : "none";
        }
        if (panel.style.display === "block") {
            setTimeout(() => searchBox.focus(), 50);
        } else {
            window.getSelection().removeAllRanges();
        }
    }

    // 全局快捷键
    document.addEventListener("keydown", e => {
        const active = document.activeElement;
        const isInput = active && (
            active.tagName === "INPUT" ||
            active.tagName === "TEXTAREA" ||
            active.isContentEditable
        );

        // 焦点在搜索框时,Esc 仍然能关闭;上下键也能切换
        if (isInput && panel.contains(active)) {
            if (e.key === "Escape" && panel.style.display === "block") {
                togglePanel(false);
                e.preventDefault();
            }
            if (e.key === "ArrowDown") {
                e.preventDefault();
                if (items.length > 0) setActiveIndex(currentIndex + 1, true);
            } else if (e.key === "ArrowUp") {
                e.preventDefault();
                if (items.length > 0) setActiveIndex(currentIndex - 1, true);
            }
            return;
        }

        // Ctrl+F 打开/关闭(覆盖浏览器默认)
        if (e.ctrlKey && e.key.toLowerCase() === "f") {
            e.preventDefault();
            togglePanel();
        }

        // Esc 关闭
        if (e.key === "Escape" && panel.style.display === "block") {
            togglePanel(false);
        }

        // 上下键全局拦截(点击结果后也能用)
        if (panel.style.display === "block") {
            if (e.key === "ArrowDown") {
                e.preventDefault();
                if (items.length > 0) setActiveIndex(currentIndex + 1, true);
            } else if (e.key === "ArrowUp") {
                e.preventDefault();
                if (items.length > 0) setActiveIndex(currentIndex - 1, true);
            }
        }
    }, true);

    // 监听网页内容变化,自动更新搜索结果(忽略侧边栏自身,防止死循环)
    const observer = new MutationObserver(mutations => {
        if (mutations.some(m => panel.contains(m.target))) return; // 忽略侧边栏
        const keyword = searchBox.value.trim();
        if (keyword) {
            clearTimeout(observer.timer);
            observer.timer = setTimeout(() => search(keyword), 300); // 防抖
        }
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true
    });

})();