Last active
July 23, 2025 06:54
-
-
Save lxl66566/c314e5eeb03f4f2504bd1c09561a12cc to your computer and use it in GitHub Desktop.
划词高亮页面内相同文字(效果类似划词自动 ctrl + f 在页面上搜索)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name 划词高亮页面内相同文字 | |
| // @namespace http://tampermonkey.net/ | |
| // @version 2.1 | |
| // @description 当在网页上选中一段文字后,自动高亮所有其他相同文字。点击或选择新内容后自动清除旧高亮。当选中的文本是在输入框或可编辑区域内的时候,不会触发高亮。 | |
| // @author lxl66566 (Gemini 2.5 pro) | |
| // @match *://*/* | |
| // @grant GM_addStyle | |
| // @license MIT | |
| // ==/UserScript== | |
| (function() { | |
| 'use strict'; | |
| // --- 配置区域 --- | |
| const HIGHLIGHT_BACKGROUND_COLOR = 'yellow'; | |
| const HIGHLIGHT_TEXT_COLOR = 'black'; | |
| const HIGHLIGHT_CLASS = 'gemini-text-highlighter-e4f5g6'; // 使用一个独特的类名以避免冲突 | |
| // 注入高亮样式 | |
| GM_addStyle(` | |
| .${HIGHLIGHT_CLASS} { | |
| background-color: ${HIGHLIGHT_BACKGROUND_COLOR} !important; | |
| color: ${HIGHLIGHT_TEXT_COLOR} !important; | |
| padding: 1px 0; | |
| border-radius: 3px; | |
| box-shadow: 0 0 3px rgba(255, 255, 0, 0.5); | |
| font-style: normal; | |
| } | |
| `); | |
| let lastSelectionText = ''; | |
| // 移除所有由本脚本添加的高亮 | |
| function removeHighlights() { | |
| // 使用 Array.from 将 NodeList 转换为数组,以避免在遍历时修改集合导致的问题 | |
| const highlights = Array.from(document.querySelectorAll(`.${HIGHLIGHT_CLASS}`)); | |
| highlights.forEach(highlightNode => { | |
| const parent = highlightNode.parentNode; | |
| if (parent) { | |
| // 将高亮节点的内容(文本)替换掉高亮节点本身 | |
| parent.replaceChild(document.createTextNode(highlightNode.textContent), highlightNode); | |
| // 合并相邻的文本节点,保持DOM整洁 | |
| parent.normalize(); | |
| } | |
| }); | |
| } | |
| // 高亮指定文本,并跳过原始选择区域 | |
| function highlightText(text, selection) { | |
| if (!text || !selection) { | |
| return; | |
| } | |
| // 获取选区的起始和结束节点,用于后续判断和跳过 | |
| const anchorNode = selection.anchorNode; | |
| const focusNode = selection.focusNode; | |
| const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); | |
| const nodesToProcess = []; | |
| // 步骤1:先收集所有相关的文本节点 | |
| let node; | |
| while (node = treeWalker.nextNode()) { | |
| const parentName = node.parentNode.nodeName.toLowerCase(); | |
| // 排除脚本、样式、已高亮的和不可见元素内的文本 | |
| if (parentName === 'script' || parentName === 'style' || parentName === 'noscript' || node.parentNode.classList.contains(HIGHLIGHT_CLASS) || node.textContent.trim() === '') { | |
| continue; | |
| } | |
| nodesToProcess.push(node); | |
| } | |
| // 步骤2:遍历收集到的节点并进行高亮处理 | |
| nodesToProcess.forEach(textNode => { | |
| // **核心优化点**:如果当前文本节点就是用户选择的节点,则跳过,以保留原始选择 | |
| if (textNode === anchorNode || textNode === focusNode) { | |
| return; | |
| } | |
| const textContent = textNode.nodeValue; | |
| // 使用不区分大小写的全局正则表达式进行匹配 | |
| const regex = new RegExp(escapeRegExp(text), 'gi'); | |
| let match; | |
| let lastIndex = 0; | |
| const fragment = document.createDocumentFragment(); | |
| let hasMatch = false; | |
| while ((match = regex.exec(textContent)) !== null) { | |
| hasMatch = true; | |
| // 添加匹配前的文本 | |
| if (match.index > lastIndex) { | |
| fragment.appendChild(document.createTextNode(textContent.substring(lastIndex, match.index))); | |
| } | |
| // 创建并添加高亮元素 | |
| const mark = document.createElement('span'); | |
| mark.className = HIGHLIGHT_CLASS; | |
| mark.textContent = match[0]; | |
| fragment.appendChild(mark); | |
| lastIndex = regex.lastIndex; | |
| } | |
| // 如果确实发生了匹配,才用新的文档片段替换旧的文本节点 | |
| if (hasMatch) { | |
| // 添加最后一次匹配后的剩余文本 | |
| if (lastIndex < textContent.length) { | |
| fragment.appendChild(document.createTextNode(textContent.substring(lastIndex))); | |
| } | |
| textNode.parentNode.replaceChild(fragment, textNode); | |
| } | |
| }); | |
| } | |
| // 用于转义正则表达式中的特殊字符,防止用户选择的文本中包含特殊字符而导致正则错误 | |
| function escapeRegExp(string) { | |
| return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& 表示整个被匹配的字符串 | |
| } | |
| // 监听鼠标抬起事件(文本选择结束时触发) | |
| document.addEventListener('mouseup', () => { | |
| // 使用微小的延迟来确保浏览器完成选择操作 | |
| setTimeout(() => { | |
| // --- 新增需求:检查当前激活的元素 --- | |
| const activeElement = document.activeElement; | |
| if (activeElement) { | |
| const nodeName = activeElement.nodeName.toUpperCase(); | |
| // 如果焦点在输入框、文本域或任何可编辑元素内,则不执行任何操作 | |
| if (nodeName === 'INPUT' || nodeName === 'TEXTAREA' || activeElement.isContentEditable) { | |
| return; // 直接退出,不做任何处理 | |
| } | |
| } | |
| // --- 新增需求结束 --- | |
| const selectionObj = window.getSelection(); | |
| const selectedText = selectionObj ? selectionObj.toString().trim() : ''; | |
| // 仅在选择内容发生变化时才执行操作 | |
| if (selectedText !== lastSelectionText) { | |
| removeHighlights(); // 先移除旧的高亮 | |
| if (selectedText.length > 0) { | |
| // 传入选择的文本和整个 selection 对象 | |
| highlightText(selectedText, selectionObj); | |
| } | |
| lastSelectionText = selectedText; | |
| } | |
| }, 10); | |
| }); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment