// ==UserScript== // @name 极简划词搜索 // @description 超小尺寸悬浮窗,支持划词搜索、复制、打开链接;拖拽链接/图片保存;预定义六组高亮样式(可自定义),动态添加关键词增量高亮,无闪烁;自动高亮默认关闭,加粗样式默认注释;工具栏默认手动隐藏;可配置显示的搜索引擎;可调整按钮顺序;可全局开启/关闭高亮功能;极致性能优化 // @icon https://www.baidu.com/favicon.ico // @namespace http://tampermonkey.net/ // @version 2.4.2 // @author ddrwin // @license MIT // @match *://*/* // @grant GM_setValue // @grant GM_getValue // @note 2026.2.7 V1.0 初始版本 // @note 2026.2.8 V1.1 增加打开按钮,优化样式 // @note 2026.2.9 V1.2 增加拖拽搜索功能,修复状态残留bug // @note 2026.2.10 V1.3 修复点击复制按钮误触拖拽搜索的问题 // @note 2026.2.11 V1.4 增加链接拖拽打开功能 // @note 2026.2.12 V1.5 增加图片拖拽打开功能 // @note 2026.2.13 V1.6 增加小红书搜索引擎 // @note 2026.2.14 V1.7 修复操作后未清除文本选中的问题 // @note 2026.2.15 V1.8 性能优化:缓存元素检测结果,减少DOM操作,调整搜索引擎顺序 // @note 2026.2.16 V1.9 性能大优化:使用Element.closest、raf节流、文档片段构建、快速空选返回 // @note 2026.2.17 V2.0 修复拖拽搜索误触及按钮点击错乱:仅当鼠标按下在选中区域内才触发拖拽,按钮事件改用直接绑定 // @note 2026.2.21 V2.0.2 优化URL识别逻辑,兼容不带协议的域名路径,增强特殊字符支持(基于v2.0.2beta1改进) // @note 2026.2.26 V2.0.3 添加图片拖拽下载保存 // @note 2026.2.26 V2.0.4 改进图片下载兼容性:增加跨域处理和多级回退,控制台输出提示 // @note 2026.2.28 V2.0.5 图片保存时自动以当前时间重命名,时间格式调整为 YYYY.MM.DD~HH·MM·SS,添加详细注释 // @note 2026.3.1 V2.1 事件注册统一管理;搜索引擎列表配置化,方便未来扩展(如 Bing) // @note 2026.3.2 V2.1.1 增强图片下载:增加 CORS 代理重试机制、更完善的请求头,提升跨域图片保存成功率 // @note 2026.3.2 V2.1.2 工具模块拆分(UrlUtils)、使用可选链和空值合并简化代码、代理请求封装为独立函数 // @note 2026.3.3 V2.2.0 新增高亮按钮,支持页面内关键词高亮(最多5词,红/绿/蓝/紫/橙字黄底);增加工具栏消失模式开关(auto/manual);高亮操作异步分批处理,解决卡顿;可配置消失延迟 // @note 2026.3.3 V2.2.1 采用用户提供的五组预定义关键词及样式;高亮更新改为增量模式,已有高亮保持不变,消除闪烁;动态添加关键词按五色循环分配样式 // @note 2026.3.3 V2.2.2 修复预定义关键词未自动高亮的问题;增加 MutationObserver 监听页面变化,动态内容自动高亮 // @note 2026.3.3 V2.2.3 增加自动高亮开关(默认关闭);加粗样式默认注释;工具栏消失模式默认改为手动;优化 MutationObserver 防抖 // @note 2026.3.3 V2.2.4 重新排列高亮组颜色(科普/硬件/人工智能颜色互换);修复自动高亮开关未完全生效的问题;更换高亮图标 // @note 2026.3.3 V2.2.5 再次修复自动高亮开关:仅在开启时加载预定义关键词;清空所有分组关键词数组;更换太阳图标 // @note 2026.3.3 V2.2.6 分组名称改为颜色描述;再次修正太阳图标;点击高亮后工具栏立即消失 // @note 2026.3.3 V2.2.7 更换高亮图标为用户指定版本;交换复制和高亮按钮位置 // @note 2026.3.3 V2.2.8 增加工具栏偏移开关:可设置左移/下移多少个图标位置(基于按钮实际尺寸) // @note 2026.3.3 V2.2.9 修复偏移量在后续划词中失效的问题(强制重排);高亮按钮放回复制左侧 // @note 2026.3.5 V2.3.0 彻底修复偏移量失效问题:计算位置前先设置 display:flex;优化代码结构;重新调整高亮组顺序 // @note 2026.3.7 V2.3.6 移除百度域名特殊逻辑,搜索引擎全局统一配置;重新排列高亮颜色顺序(红/绿/蓝/菊红/紫/深绿);代码全面注释 // @note 2026.3.7 V2.3.7 改用 GM_setValue/GM_getValue 实现跨域配置共享;彻底移除域名过滤 // @note 2026.3.7 V2.3.8 高亮添加防抖;设置面板边界优化;常量分组;代码精简 // @note 2026.3.7 V2.3.9 修复今日头条搜索页设置面板样式丢失问题:使用 Shadow DOM 封装面板,实现样式隔离;同步高亮颜色名称 // @note 2026.3.7 V2.4.0 新增必应、抖音、新浪搜索引擎;调整顺序为必应、百度、新浪、头条、抖音、小红书;设置面板双列布局;保存按钮改为 #008373 白字 // @note 2026.3.7 V2.4.1 调整按钮顺序:当选中文本为 URL 时,“打开”按钮置于最左侧(高亮和复制之前) // @note 2026.3.7 V2.4.2 增加“启用高亮功能”开关;代码优化:合并重复样式、精简函数、缓存正则、减少代码量 // @downloadURL none // ==/UserScript== (function() { 'use strict'; // ==================== 常量定义 ==================== const CONFIG = { PROXY_URL: 'https://cors-anywhere.herokuapp.com/', OPEN_WINDOW_AS_FALLBACK: false, TOOLBAR_HIDE_MODE: 'manual', HIDE_DELAY: 1000, AUTO_HIGHLIGHT_ENABLED: false, OFFSET_LEFT_ICON_COUNT: 0, OFFSET_DOWN_ICON_COUNT: 0 }; const HIGHLIGHT_GROUPS = [ { name: "红底白字", keywords: [], style: "background-color:#DE0422; color:#FFFFFF; /* font-weight:bold; */" }, { name: "绿底白字", keywords: [], style: "background-color:#66CC00; color:#FFFFFF; /* font-weight:bold; */" }, { name: "蓝底白字", keywords: [], style: "background-color:#32A7FF; color:#FFFFFF; /* font-weight:bold; */" }, { name: "橙红底白字", keywords: [], style: "background-color:#FF9933; color:#FFFFFF; /* font-weight:bold; */" }, { name: "紫底白字", keywords: [], style: "background-color:#6633FF; color:#FFFFFF; /* font-weight:bold; */" }, { name: "深绿底白字", keywords: [], style: "background-color:#008373; color:#FFFFFF; /* font-weight:bold; */" } ]; const ALL_SEARCH_ENGINES = [ { name: '必应', icon: '🔍', url: 'https://www.bing.com/search?q=%s' }, { name: '百度', icon: '🔍', url: 'https://www.baidu.com/s?wd=%s' }, { name: 'IT之家', icon: '🔍', url: 'https://www.ithome.com/search/%s.html' }, { name: '头条', icon: '🔍', url: 'https://www.toutiao.com/search/?keyword=%s' }, { name: '抖音', icon: '🔍', url: 'https://www.douyin.com/search/%s' }, { name: '小红书', icon: '🔍', url: 'https://www.xiaohongshu.com/search_result?keyword=%s' } ]; // 从全局存储加载配置 let enabledEngineNames = (() => { const saved = GM_getValue('search_engines_config', null); return saved && Array.isArray(saved) ? saved : ALL_SEARCH_ENGINES.map(e => e.name); })(); let buttonOrder = GM_getValue('button_order_config', 'copy-first'); let highlightEnabled = GM_getValue('highlight_enabled', true); const saveSearchEngineConfig = (v) => GM_setValue('search_engines_config', v); const saveButtonOrderConfig = (v) => GM_setValue('button_order_config', v); const saveHighlightEnabled = (v) => GM_setValue('highlight_enabled', v); const getEnabledEngines = () => ALL_SEARCH_ENGINES.filter(e => enabledEngineNames.includes(e.name)); // ==================== 高亮管理 ==================== let highlights = [], nextStyleIndex = 0, highlightTimer, highlightDebounceTimer; let observer, observerDebounceTimer; const escapeRegExp = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const isNodeHighlighted = node => node.parentElement?.closest('.search-highlight') !== null; function applyHighlightsAsync() { if (highlightTimer) clearTimeout(highlightTimer); if (!highlights.length) return; const textNodes = []; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, { acceptNode: node => { if (node.parentElement && ['SCRIPT', 'STYLE', 'TEXTAREA', 'INPUT'].includes(node.parentElement.tagName)) return NodeFilter.FILTER_REJECT; return isNodeHighlighted(node) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; } }); while (walker.nextNode()) textNodes.push(walker.currentNode); const regex = new RegExp(`(${highlights.map(h => escapeRegExp(h.keyword)).join('|')})`, 'gi'); let idx = 0; const BATCH_SIZE = 50; const process = () => { const end = Math.min(idx + BATCH_SIZE, textNodes.length); for (let i = idx; i < end; i++) { const node = textNodes[i], text = node.textContent; const matches = []; let match; while ((match = regex.exec(text))) matches.push({ index: match.index, keyword: match[0], length: match[0].length }); if (!matches.length) continue; const frag = document.createDocumentFragment(); let lastPos = 0; for (const m of matches) { const h = highlights.find(h => h.keyword.toLowerCase() === m.keyword.toLowerCase()); if (!h) continue; const style = HIGHLIGHT_GROUPS[h.styleIndex % HIGHLIGHT_GROUPS.length]?.style || HIGHLIGHT_GROUPS[0].style; if (m.index > lastPos) frag.appendChild(document.createTextNode(text.substring(lastPos, m.index))); const span = document.createElement('span'); span.className = 'search-highlight'; span.style.cssText = style; span.textContent = m.keyword; frag.appendChild(span); lastPos = m.index + m.length; } if (lastPos < text.length) frag.appendChild(document.createTextNode(text.substring(lastPos))); node.parentNode.replaceChild(frag, node); } idx = end; if (idx < textNodes.length) highlightTimer = setTimeout(process, 0); else highlightTimer = null; }; highlightTimer = setTimeout(process, 0); } const addDynamicHighlight = keyword => { if (!keyword) return; const idx = highlights.findIndex(h => h.keyword === keyword); if (idx !== -1) { highlights.splice(idx, 1); highlights.push({ keyword, styleIndex: highlights[idx]?.styleIndex }); } else { highlights.push({ keyword, styleIndex: nextStyleIndex++ % HIGHLIGHT_GROUPS.length }); } if (highlightDebounceTimer) clearTimeout(highlightDebounceTimer); highlightDebounceTimer = setTimeout(() => { applyHighlightsAsync(); highlightDebounceTimer = null; }, 50); }; if (CONFIG.AUTO_HIGHLIGHT_ENABLED) { HIGHLIGHT_GROUPS.forEach((g, i) => g.keywords.forEach(k => highlights.push({ keyword: k, styleIndex: i }))); applyHighlightsAsync(); } // MutationObserver (() => { observer = new MutationObserver(() => { if (observerDebounceTimer) clearTimeout(observerDebounceTimer); observerDebounceTimer = setTimeout(() => { applyHighlightsAsync(); observerDebounceTimer = null; }, 200); }); observer.observe(document.body, { childList: true, subtree: true, characterData: true }); })(); // ==================== 辅助函数 ==================== const getFormattedDateTime = () => { const d = new Date(); return `${d.getFullYear()}.${(d.getMonth()+1).toString().padStart(2,'0')}.${d.getDate().toString().padStart(2,'0')}~${d.getHours().toString().padStart(2,'0')}·${d.getMinutes().toString().padStart(2,'0')}·${d.getSeconds().toString().padStart(2,'0')}`; }; // ==================== 图标 ==================== const ICONS = { copy: 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJubyI/PjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+PHN2ZyB0PSIxNjU1Nzc5ODc4NDY4IiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjE0MTciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCI+PGRlZnM+PHN0eWxlIHR5cGU9InRleHQvY3NzIj5AZm9udC1mYWNlIHsgZm9udC1mYW1pbHk6IGZlZWRiYWNrLWljb25mb250OyBzcmM6IHVybCgiLy9hdC5hbGljZG4uY29tL3QvZm9udF8xMDMxMTU4X3U2OXc4eWh4ZHUud29mZjI/dD0xNjMwMDMzNzU5OTQ0IikgZm9ybWF0KCJ3b2ZmMiIpLCB1cmwoIi8vYXQuYWxpY2RuLmNvbS90L2ZvbnRfMTAzMTE1OF91Njl3OHloeGR1LndvZmY/dD0xNjMwMDMzNzU5OTQ0IikgZm9ybWF0KCJ3b2ZmIiksIHVybCgiLy9hdC5hbGljZG4uY29tL3QvZm9udF8xMDMxMTU4X3U2OXc4eWh4ZHUudHRmP3Q9MTYzMDAzMzc1OTk0NCIpIGZvcm1hdCgidHJ1ZXR5cGUiKTsgfQ0KPC9zdHlsZT48L2RlZnM+PHBhdGggZD0iTTM3NyA0MzJoMzQ5YTggOCAwIDAgMSA4IDh2NDhhOCA4IDAgMCAxLTggOEgzNzdhOCA4IDAgMCAxLTgtOHYtNDhhOCA4IDAgMCAxIDgtOHogbTAgMTYwaDI1OGE4IDggMCAwIDEgOCA4djQ4YTggOCAwIDAgMS04IDhIMzc3YTggOCAwIDAgMS04LTh2LTQ4YTggOCAwIDAgMSA4LTh6IG0tNjUtMjgwdjU3Nmg0ODBWMzEySDMxMnogbS00MC03Mmg1NjBjMTcuNjczIDAgMzIgMTQuMzI3IDMyIDMydjY1NmMwIDE3LjY3My0xNC4zMjcgMzItMzIgMzJIMjcyYy0xNy42NzMgMC0zMi0xNC4zMjctMzItMzJWMjcyYzAtMTcuNjczIDE0LjMyNy0zMiAzMi0zMnogbS04OC01NnY2NjRhOCA4IDAgMCAxLTggOGgtNTZhOCA4IDAgMCAxLTgtOFYxNDRjMC0xNy42NzMgMTQuMzI3LTMyIDMyLTMyaDYzMmE4IDggMCAwIDEgOCA4djU2YTggOCAwIDAgMS04IDhIMTg0eiIgZmlsbD0iIzMzMzMzMyIgcC1pZD0iMTQxOCI+PC9wYXRoPjwvc3ZnPg==', search: 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJubyI/PjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+PHN2ZyB0PSIxNjUxNTY3NDk1OTczIiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjIwNzciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCI+PGRlZnM+PHN0eWxlIHR5cGU9InRleHQvY3NzIj48L3N0eWxlPjwvZGVmcz48cGF0aCBkPSJNNDQ2LjExMjMyMyAxNzcuNTQ1MDUxYzEzNy41Njc2NzcgMC4yMTk3OTggMjUyLjYxMjUyNSAxMDQuNTk3OTggMjY2LjE2MjQyNCAyNDEuNDkzMzMzIDEzLjU2MjgyOCAxMzYuODk1MzU0LTc4Ljc3ODE4MiAyNjEuODE4MTgyLTIxMy42MTc3NzcgMjg5LjAwODQ4NS0xMzQuODUyNTI1IDI3LjIwMzIzMi0yNjguMzg2MjYzLTUyLjE1Njc2OC0zMDguOTQ1NDU1LTE4My42MDg4ODlzMjUuMDE4MTgyLTI3Mi4yNTIxMjEgMTUxLjczODE4Mi0zMjUuNzc5Mzk0QTI2Ny4yMzU1NTYgMjY3LjIzNTU1NiAwIDAgMSA0NDYuMTEyMzIzIDE3Ny41NDUwNTFtMC02Mi4wNjA2MDdjLTE4Mi43OTQzNDMgMC0zMzAuOTg5ODk5IDE0OC4xOTU1NTYtMzMwLjk4OTg5OSAzMzAuOTg5ODk5czE0OC4xOTU1NTYgMzMwLjk4OTg5OSAzMzAuOTg5ODk5IDMzMC45ODk4OTkgMzMwLjk4OTg5OS0xNDguMTk1NTU2IDMzMC45ODk4OTktMzMwLjk4OTg5OS0xNDguMTk1NTU2LTMzMC45ODk4OTktMzMwLjk4OTg5OS0zMzAuOTg5ODk5eiBtNDMxLjMyMTIxMiA3OTMuMzQxNDE1YTMwLjg0OTI5MyAzMC44NDkyOTMgMCAwIDEtMjEuOTQxMDEtOS4xMDIyMjNsLTE1Ny4yMjAyMDItMTU3LjIyMDIwMmMtMTEuNzUyNzI3LTEyLjE3OTM5NC0xMS41ODQ2NDYtMzEuNTM0NTQ1IDAuMzc0OTUtNDMuNTA3MDcgMTEuOTcyNTI1LTExLjk3MjUyNSAzMS4zMjc2NzctMTIuMTQwNjA2IDQzLjQ5NDE0MS0wLjM3NDk1bDE1Ny4yMjAyMDIgMTU3LjIyMDIwMmEzMS4wMzY3NjggMzEuMDM2NzY4IDAgMCAxIDYuNzIzMjMyIDMzLjgxMDEwMSAzMS4wMDQ0NDQgMzEuMDA0NDQ0IDAgMCAxLTI4LjY1MTMxMyAxOS4xNzQxNDJ6IG0wIDAiIHAtaWQ9IjIwNzgiIGZpbGw9IiMzMzMzMzMiPjwvcGF0aD48L3N2Zz4=', openLink: 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJubyI/PjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+PHN2ZyB0PSIxNjUxNTgwNDU1NTcwIiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9Ijk0MiIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHdpZHRoPSIyMDAiIGhlaWdodD0iMjAwIj48cGF0aCBkPSJNODMyIDEyOEg2NDB2NjRoMTQ2Ljc1Mkw1MjEuMzc2IDQ1Ny4zNzZsNDUuMjQ4IDQ1LjI0OEw4MzIgMjM3LjI0OFYzODRoNjRWMTI4eiIgZmlsbD0iIzMzMzMzMyIgcC1pZD0iOTQzIj48L3BhdGg+PHBhdGggZD0iTTc2OCA4MzJIMTkyVjI1NmgzNTJ2LTY0SDE2MGEzMiAzMiAwIDAgMC0zMiAzMnY2NDBhMzIgMzIgMCAwIDAgMzIgMzJoNjQwYTMyIDMyIDAgMCAwIDMyLTMzVjQ4MGgtNjR2MzUyeiIgZmlsbD0iIzMzMzMzMyIgcC1pZD0iOTQ0Ij48L3BhdGg+PC9zdmc+', save: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"%3E%3Cpath fill="%23333" d="M8 12L4 8h3V4h2v4h3l-4 4zM2 14h12v-2H2v2z"/%3E%3C/svg%3E', highlight: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij48cGF0aCBkPSJNMTIgMjEuMzVsLTEuNDUtMS4zMkM1LjQgMTUuMzYgMiAxMi4yOCAyIDguNSAyIDUuNDIgNC40MiAzIDcuNSAzYzEuNzQgMCAzLjQxLjgxIDQuNSAyLjA5QzEzLjA5IDMuODEgMTQuNzYgMyAxNi41IDMgMTkuNTggMyAyMiA1LjQyIDIyIDguNWMwIDMuNzgtMy40IDYuODYtOC41NSAxMS41NEwxMiAyMS4zNXoiIGZpbGw9IiNlNzRjM2MiLz48L3N2Zz4=', settings: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij48cGF0aCBkPSJNMTkuMTQgMTIuOTRjLjA0LS4zLjA2LS42MS4wNi0uOTQgMC0uMzItLjAyLS42NC0uMDctLjk0bDIuMDMtMS41OGMuMTgtLjE0LjIzLS40MS4xMi0uNjFsLTEuOTItMy4zMmMtLjEyLS4yMi0uMzctLjI5LS41OS0uMjJsLTIuMzkuOTZjLS41LS4zOC0xLjAzLS43LTEuNjItLjk0bC0uMzgtMi41NGMtLjAzLS4yNC0uMjQtLjQyLS40OC0uNDJoLTMuODRjLS4yNCAwLS40NS4xOC0uNDguNDJsLS4zOCAyLjU0Yy0uNTkuMjQtMS4xMy41Ny0xLjYyLjk0bC0yLjM5LS45NmMtLjIyLS4wOC0uNDcgMC0uNTkuMjJMMi43NCA4Ljg3Yy0uMTIuMjEtLjA4LjQ3LjEyLjYxbDIuMDMgMS41OGMtLjA1LjMtLjA5LjYzLS4wOS45NHMwLjAyLjY0LjA3Ljk0bC0yLjAzIDEuNThjLS4xOC4xNC0uMjMuNDEtLjEyLjYxbDEuOTIgMy4zMmMuMTIuMjIuMzcuMjkuNTkuMjJsMi4zOS0uOTZjLjUuMzggMS4wMy43IDEuNjIuOTRsLjM4IDIuNTRjLjAzLjI0LjI0LjQyLjQ4LjQyaDMuODRjLjI0IDAgLjQ1LS4xOC40OC0uNDJsLjM4LTIuNTRjLjU5LS4yNCAxLjEzLS41NiAxLjYyLS45NGwyLjM5Ljk2Yy4yMi4wOC40NyAwIC41OS0uMjJsMS45Mi0zLjMyYy4xMi0uMjIuMDctLjQ3LS4xMi0uNjFsLTIuMDEtMS41OE0xMiAxNS42Yy0xLjk4IDAtMy42LTEuNjItMy42LTMuNnMxLjYyLTMuNiAzLjYtMy42IDMuNiAxLjYyIDMuNiAzLjYtMS42MiAzLjYtMy42IDMuNnoiIGZpbGw9IiMzMzMiLz48L3N2Zz4=' }; ALL_SEARCH_ENGINES.forEach(e => e.icon = ICONS.search); // ==================== 设置面板(Shadow DOM)==================== let settingsPanelHost, settingsPanelRoot; const createSettingsPanel = () => { if (settingsPanelHost) return; settingsPanelHost = document.createElement('div'); settingsPanelHost.id = 'custom-search-settings-host'; settingsPanelHost.style.cssText = 'all:initial;position:fixed;z-index:1000000;left:0;top:0;width:0;height:0;'; settingsPanelRoot = settingsPanelHost.attachShadow({ mode: 'open' }); const panelDiv = document.createElement('div'); panelDiv.style.cssText = ` position:fixed; background:#fff; border-radius:8px; padding:16px; box-shadow:0 4px 12px rgba(0,0,0,0.15); border:1px solid #e0e0e0; font-size:14px; color:#333; display:none; min-width:260px; font-family:system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; `; const title = document.createElement('div'); title.style.cssText = 'font-weight:bold; margin-bottom:12px; border-bottom:1px solid #eee; padding-bottom:4px;'; title.textContent = '搜索引擎设置'; panelDiv.appendChild(title); const listDiv = document.createElement('div'); listDiv.style.cssText = 'margin-bottom:12px; display:grid; grid-template-columns:repeat(2,1fr); gap:6px 12px;'; ALL_SEARCH_ENGINES.forEach(engine => { const label = document.createElement('label'); label.style.cssText = 'display:flex; align-items:center; cursor:pointer; white-space:nowrap;'; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.value = engine.name; cb.checked = enabledEngineNames.includes(engine.name); cb.style.cssText = 'margin-right:4px; vertical-align:middle;'; label.append(cb, document.createTextNode(engine.name)); listDiv.appendChild(label); }); panelDiv.appendChild(listDiv); const addCheckbox = (text, checked) => { const div = document.createElement('div'); div.style.cssText = 'margin-bottom:8px; border-top:1px solid #eee; padding-top:8px;'; const label = document.createElement('label'); label.style.cssText = 'display:flex; align-items:center; cursor:pointer;'; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = checked; cb.style.cssText = 'margin-right:6px; vertical-align:middle;'; label.append(cb, document.createTextNode(text)); div.appendChild(label); panelDiv.appendChild(div); return cb; }; const highlightCb = addCheckbox('启用高亮功能', highlightEnabled); const orderCb = addCheckbox('高亮按钮在前(默认复制在前)', buttonOrder === 'highlight-first'); const btnDiv = document.createElement('div'); btnDiv.style.cssText = 'display:flex; justify-content:flex-end; gap:8px;'; const saveBtn = document.createElement('button'); saveBtn.textContent = '保存'; saveBtn.style.cssText = 'background:#008373; color:#fff; border:none; border-radius:4px; padding:6px 12px; cursor:pointer; font-size:13px;'; saveBtn.addEventListener('click', () => { const checkboxes = panelDiv.querySelectorAll('input[type="checkbox"]'); enabledEngineNames = [...checkboxes].slice(0, ALL_SEARCH_ENGINES.length).filter(cb => cb.checked).map(cb => cb.value); saveSearchEngineConfig(enabledEngineNames); highlightEnabled = checkboxes[ALL_SEARCH_ENGINES.length].checked; saveHighlightEnabled(highlightEnabled); buttonOrder = checkboxes[ALL_SEARCH_ENGINES.length + 1].checked ? 'highlight-first' : 'copy-first'; saveButtonOrderConfig(buttonOrder); panelDiv.style.display = 'none'; }); const cancelBtn = document.createElement('button'); cancelBtn.textContent = '取消'; cancelBtn.style.cssText = 'background:#f0f0f0; color:#333; border:1px solid #ccc; border-radius:4px; padding:6px 12px; cursor:pointer; font-size:13px;'; cancelBtn.addEventListener('click', () => panelDiv.style.display = 'none'); btnDiv.append(saveBtn, cancelBtn); panelDiv.appendChild(btnDiv); settingsPanelRoot.appendChild(panelDiv); document.body.appendChild(settingsPanelHost); }; const showSettingsPanel = () => { createSettingsPanel(); if (!toolbar) return; const panelDiv = settingsPanelRoot.querySelector('div'); if (!panelDiv) return; const cbs = panelDiv.querySelectorAll('input[type="checkbox"]'); ALL_SEARCH_ENGINES.forEach((e, i) => cbs[i].checked = enabledEngineNames.includes(e.name)); cbs[ALL_SEARCH_ENGINES.length].checked = highlightEnabled; cbs[ALL_SEARCH_ENGINES.length + 1].checked = buttonOrder === 'highlight-first'; const rect = toolbar.getBoundingClientRect(); let left = rect.left, top = rect.bottom + 5; const panelWidth = 280; if (left + panelWidth > innerWidth) left = innerWidth - panelWidth - 5; if (top + 200 > innerHeight) top = rect.top - 205; left = Math.max(5, left); top = Math.max(5, top); panelDiv.style.cssText += `;left:${left}px;top:${top}px;display:block;`; }; // ==================== 工具栏样式与变量 ==================== const TOOLBAR_STYLE = `position:fixed; z-index:999999; background:#fff; border-radius:8px; padding:2px 4px; box-shadow:0 2px 6px rgba(0,0,0,0.1); display:flex; gap:2px; font-size:12px; color:#333; pointer-events:auto; border:1px solid #e0e0e0; align-items:center; line-height:1.2; flex-wrap:nowrap; white-space:nowrap;`; const BUTTON_STYLE = `background:transparent; border:none; color:#333; cursor:pointer; padding:4px 4px; border-radius:6px; font-size:12px; display:flex; align-items:center; gap:3px; transition:background 0.1s, color 0.1s; white-space:nowrap;`; const ICON_STYLE = `width:13px; height:13px; display:inline-block; vertical-align:middle; transition:filter 0.1s;`; const SEPARATOR_STYLE = `width:1px; height:18px; background:#ddd; margin:0 2px;`; const GREEN_FILTER = 'invert(48%) sepia(79%) saturate(2476%) hue-rotate(86deg) brightness(118%) contrast(119%)'; let toolbar, hideTimeout; const initToolbar = () => { toolbar = document.createElement('div'); toolbar.id = 'custom-search-toolbar'; toolbar.style.cssText = TOOLBAR_STYLE; toolbar.addEventListener('mousedown', e => e.preventDefault()); toolbar.addEventListener('mouseup', e => e.stopPropagation()); toolbar.addEventListener('click', e => e.target.closest('button')?._handler?.()); toolbar.addEventListener('mouseenter', () => clearTimeout(hideTimeout)); toolbar.addEventListener('mouseleave', () => CONFIG.TOOLBAR_HIDE_MODE === 'auto' && scheduleHide()); document.body.appendChild(toolbar); }; const buildToolbar = (text, mode = 'text') => { if (!toolbar) initToolbar(); toolbar.innerHTML = ''; const frag = document.createDocumentFragment(); let btns = []; if (mode === 'image') { btns.push({ icon: ICONS.save, text: '保存', handler: () => { downloadImage(text); getSelection().empty(); hideToolbar(); } }); } else { const isDomain = UrlUtils.isDomain(text); if (isDomain) btns.push({ icon: ICONS.openLink, text: '打开', handler: () => { open(UrlUtils.makeUrl(text)); getSelection().empty(); hideToolbar(); } }); const copyBtn = { icon: ICONS.copy, text: '复制', handler: () => { copyText(text); getSelection().empty(); hideToolbar(); } }; if (highlightEnabled) { const highlightBtn = { icon: ICONS.highlight, text: '高亮', handler: () => { const kw = getSelection().toString().trim(); if (kw) addDynamicHighlight(kw); hideToolbar(); } }; buttonOrder === 'highlight-first' ? btns.push(highlightBtn, copyBtn) : btns.push(copyBtn, highlightBtn); } else { btns.push(copyBtn); } getEnabledEngines().forEach(e => btns.push({ icon: e.icon, text: e.name, handler: () => { open(e.url.replace('%s', encodeURIComponent(text))); getSelection().empty(); hideToolbar(); } })); btns.push({ icon: ICONS.settings, text: '', title: '设置', handler: showSettingsPanel }); } btns.forEach((btn, i) => { if (i > 0) { const sep = document.createElement('span'); sep.style.cssText = SEPARATOR_STYLE; frag.appendChild(sep); } const el = document.createElement('button'); el.style.cssText = BUTTON_STYLE; if (btn.title) el.title = btn.title; el.innerHTML = `${btn.text}`; el._handler = btn.handler; el.addEventListener('mouseenter', e => { e.currentTarget.style.background = '#f0f0f0'; e.currentTarget.style.color = '#5FE382'; e.currentTarget.querySelector('img').style.filter = GREEN_FILTER; }); el.addEventListener('mouseleave', e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#333'; e.currentTarget.querySelector('img').style.filter = 'none'; }); frag.appendChild(el); }); toolbar.appendChild(frag); }; // ==================== 复制 ==================== const copyText = text => { if (navigator.clipboard) navigator.clipboard.writeText(text).catch(() => fallbackCopy(text)); else fallbackCopy(text); }; const fallbackCopy = text => { const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;opacity:0;'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); }; // ==================== 显示/隐藏工具栏 ==================== const showToolbar = (x, y, text, mode = 'text') => { if (hideTimeout) clearTimeout(hideTimeout); requestAnimationFrame(() => { buildToolbar(text, mode); toolbar.style.display = 'flex'; toolbar.offsetHeight; const toolW = toolbar.offsetWidth, toolH = toolbar.offsetHeight; let left = x + 6, top = y + 10; if (mode === 'text') { const firstBtn = toolbar.querySelector('button'); if (firstBtn) { left += firstBtn.offsetWidth * -CONFIG.OFFSET_LEFT_ICON_COUNT; top += firstBtn.offsetHeight * CONFIG.OFFSET_DOWN_ICON_COUNT; } } if (left + toolW > innerWidth) left = innerWidth - toolW - 6; if (top + toolH > innerHeight) top = y - toolH - 6; left = Math.max(2, left); top = Math.max(2, top); toolbar.style.left = left + 'px'; toolbar.style.top = top + 'px'; }); }; const hideToolbar = () => { if (toolbar) toolbar.style.display = 'none'; }; const scheduleHide = () => { if (hideTimeout) clearTimeout(hideTimeout); hideTimeout = setTimeout(hideToolbar, CONFIG.HIDE_DELAY); }; // ==================== URL工具 ==================== const UrlUtils = { linkCache: new WeakMap(), isDomain: t => t && !t.includes(' ') && (/^https?:\/\/[a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=%]+$/.test(t) || (/^[a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=%]+$/.test(t) && t.includes('.') && t.split('.').pop().length >= 2)), makeUrl: d => d.startsWith('http') ? d : 'https://' + d, isInsideEditable: el => el?.closest('input, textarea, [contenteditable="true"], [contenteditable=""]') !== null, isLinkElement(el) { if (!el) return false; if (this.linkCache.has(el)) return this.linkCache.get(el); const a = el.closest('a[href]'); this.linkCache.set(el, !!a); return !!a; }, getLinkUrl: el => el?.closest('a[href]')?.href || null, isImageElement: el => el?.tagName === 'IMG', getImageUrl: el => el?.tagName === 'IMG' ? el.src : null }; // ==================== 代理与下载 ==================== async function fetchWithProxy(url, headers) { try { const res = await fetch(url, { mode: 'cors', credentials: 'omit', headers }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.blob(); } catch (e) { console.warn('fetch失败,尝试代理', e); if (!CONFIG.PROXY_URL) throw e; const res = await fetch(CONFIG.PROXY_URL + url, { mode: 'cors', credentials: 'omit', headers }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.blob(); } } async function downloadImage(url) { const ext = url.match(/\.([a-zA-Z0-9]+)(?:[?#]|$)/)?.[1] || 'jpg'; const fileName = getFormattedDateTime() + '.' + ext; const headers = { Accept: 'image/webp,image/apng,image/*,*/*;q=0.8', Referer: location.origin, Origin: location.origin, 'User-Agent': navigator.userAgent }; if (window.showSaveFilePicker) { try { const blob = await fetchWithProxy(url, headers); const handle = await window.showSaveFilePicker({ suggestedName: fileName, types: [{ description: 'Image', accept: { [blob.type]: ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'] } }] }); const writable = await handle.createWritable(); await writable.write(blob); await writable.close(); console.log(`✅ 已保存 ${fileName}`); return; } catch (err) { if (err.name !== 'AbortError') console.warn('保存失败', err); } } const a = document.createElement('a'); a.href = url; a.download = fileName; a.style.display = 'none'; document.body.appendChild(a); a.click(); document.body.removeChild(a); console.log(`🔄 尝试下载 ${fileName}`); setTimeout(() => { console.log(`💡 手动保存:${url}`); if (CONFIG.OPEN_WINDOW_AS_FALLBACK) open(url); }, 500); } // ==================== 拖拽 ==================== const DRAG_THRESHOLD = 5; let dragStartX, dragStartY, dragListening = false, dragTriggered = false, dragMode, dragUrl, rafId; const startDragListening = e => { if (e.button !== 0 || toolbar?.contains(e.target) || UrlUtils.isInsideEditable(e.target)) return; if (UrlUtils.isLinkElement(e.target)) { dragMode = 'link'; dragUrl = UrlUtils.getLinkUrl(e.target); if (!dragUrl) return; } else if (UrlUtils.isImageElement(e.target)) { dragMode = 'image'; dragUrl = UrlUtils.getImageUrl(e.target); if (!dragUrl) return; } else { const sel = getSelection(); const text = sel.toString().trim(); if (!text) return; if (sel.rangeCount > 0) { const rect = sel.getRangeAt(0).getBoundingClientRect(); if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom) return; } dragMode = 'search'; dragUrl = null; } dragStartX = e.clientX; dragStartY = e.clientY; dragListening = true; dragTriggered = false; document.addEventListener('mousemove', onDragMoveThrottled, { passive: true }); document.addEventListener('mouseup', onDragEnd); }; const onDragMoveThrottled = e => { if (!dragListening || dragTriggered) return; if (rafId) cancelAnimationFrame(rafId); rafId = requestAnimationFrame(() => { if (Math.hypot(e.clientX - dragStartX, e.clientY - dragStartY) >= DRAG_THRESHOLD) { e.preventDefault?.(); e.stopPropagation?.(); dragTriggered = true; dragListening = false; cleanupDragListeners(); hideToolbar(); if (dragMode === 'link' && dragUrl) open(dragUrl); else if (dragMode === 'image' && dragUrl) downloadImage(dragUrl); else if (dragMode === 'search') { const sel = getSelection().toString().trim(); if (sel) { open(UrlUtils.isDomain(sel) ? UrlUtils.makeUrl(sel) : 'https://www.baidu.com/s?wd=' + encodeURIComponent(sel)); getSelection().empty(); } } dragTriggered = false; dragMode = dragUrl = null; } rafId = null; }); }; const onDragEnd = () => { cleanupDragListeners(); dragTriggered = false; dragMode = dragUrl = null; }; const cleanupDragListeners = () => { dragListening = false; document.removeEventListener('mousemove', onDragMoveThrottled); document.removeEventListener('mouseup', onDragEnd); if (rafId) cancelAnimationFrame(rafId); }; // ==================== 事件监听 ==================== const initEventListeners = () => { addEventListener('mousedown', startDragListening, { passive: true }); addEventListener('mouseup', onMouseUp); addEventListener('mousedown', onDocumentMouseDown, { passive: true }); addEventListener('scroll', hideToolbar, { passive: true }); addEventListener('selectionchange', onSelectionChange); }; const onMouseUp = e => { if (dragTriggered) { dragTriggered = false; dragMode = dragUrl = null; return; } const text = getSelection().toString().trim(); if (!text) { hideToolbar(); return; } if (toolbar?.contains(e.target) || UrlUtils.isInsideEditable(e.target)) { hideToolbar(); return; } showToolbar(e.clientX, e.clientY, text, 'text'); }; const onDocumentMouseDown = e => { if (toolbar && !toolbar.contains(e.target)) hideToolbar(); }; const onSelectionChange = () => { if (!getSelection().toString().trim()) setTimeout(() => { if (!getSelection().toString().trim()) hideToolbar(); }, 100); }; // ==================== 启动 ==================== initEventListeners(); })();