Reduce the number of mouse clicks for users
// ==UserScript==
// @name PicKit
// @name:zh-CN 拾字
// @namespace https://github.com/CodebyGPT/PicKit
// @version 2025.12.12
// @description Reduce the number of mouse clicks for users
// @description:zh-CN 帮你少点一次鼠标
// @author CodebyGPT
// @license GPL-3.0
// @license https://www.gnu.org/licenses/gpl-3.0.txt
// @match *://*/*
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMjQgMjQiPjx0aXRsZSB4bWxucz0iIj50b3VjaC10cmlwbGU8L3RpdGxlPjxwYXRoIGZpbGw9ImN1cnJlbnRDb2xvciIgZD0ibTE3Ljk3NSAzLjRsMS0xLjc1cTEuMTc1LjY1IDEuODUgMS44MjVUMjEuNSA2cTAgLjY3NS0uMTc1IDEuMzEzdC0uNSAxLjE4N2wtMS43MjUtMXEuMi0uMzUuMy0uNzEyVDE5LjUgNnEwLS44LS40MTItMS41dC0xLjExMy0xLjFtLTQgMGwxLTEuNzVxMS4xNzUuNjUgMS44NSAxLjgyNVQxNy41IDZxMCAuNjc1LS4xNzUgMS4zMTN0LS41IDEuMTg3bC0xLjcyNS0xcS4yLS4zNS4zLS43MTJUMTUuNSA2cTAtLjgtLjQxMy0xLjV0LTEuMTEyLTEuMW0tMy41IDE4LjZxLS43IDAtMS4zMTItLjN0LTEuMDM4LS44NWwtNS40NS02LjkyNWwuNDc1LS41cS41LS41MjUgMS4yLS42MjV0MS4zLjI3NUw3LjUgMTQuMlY2cTAtLjQyNS4yODgtLjcxMlQ4LjUgNXQuNzI1LjI4OHQuMy43MTJ2NUgxN3ExLjI1IDAgMi4xMjUuODc1VDIwIDE0djRxMCAxLjY1LTEuMTc1IDIuODI1VDE2IDIyem0tNi4zLTEzLjVxLS4zMjUtLjU1LS41LTEuMTg3VDMuNSA2cTAtMi4wNzUgMS40NjMtMy41MzdUOC41IDF0My41MzggMS40NjNUMTMuNSA2cTAgLjY3NS0uMTc1IDEuMzEzdC0uNSAxLjE4N2wtMS43MjUtMXEuMi0uMzUuMy0uNzEyVDExLjUgNnEwLTEuMjUtLjg3NS0yLjEyNVQ4LjUgM3QtMi4xMjUuODc1VDUuNSA2cTAgLjQyNS4xLjc4OHQuMy43MTJ6Ii8+PC9zdmc+
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_openInTab
// @grant GM_addStyle
// @grant GM_setClipboard
// @sandbox DOM
// @inject-into content
// @run-at document-start
// @supportURL https://github.com/CodebyGPT/PicKit/issues
// ==/UserScript==
/*
* 非原创内容声明:
* 1. Icon 来自 allsvgicons.com 提供的 Material Symbols 图标库。
* 2. 脚本大部分代码参考或直接使用了 Gemini 3 Pro Preview、ChatGPT、Kimi K2、Qwen3-Max 等 LLM 的输出结果。
* 3. 快速粘贴网盘提取码功能参考了 greasyfork.org/zh-CN/scripts/445489-网盘链接识别、greasyfork.org/zh-CN/scripts/439266-网盘有效性检查、github.com/Magiclyan/panAI(forked from syhyz1990/panAI)等脚本。
* 4. 中文文本校正功能的部分语法规则参考了 github.com/sparanoid/chinese-copywriting-guidelines 中的内容。
*
* Non-original content disclaimer:
* 1. The icon is sourced from the Material Symbols icon library provided by allsvgicons.com.
* 2. The script primarily references or directly utilizes the output results from large language models (LLMs) such as Gemini 3 Pro Preview, ChatGPT, Kimi K2, and Qwen3-Max.
* 3. The quick paste function for cloud storage extraction codes draws inspiration from scripts such as greasyfork.org/zh-CN/scripts/445489-网盘链接识别, greasyfork.org/zh-CN/scripts/439266-网盘有效性检查, and github.com/Magiclyan/panAI (forked from syhyz1990/panAI).
* 4. The grammatical rules for the Chinese text correction feature are partially referenced from the content on github.com/sparanoid/chinese-copywriting-guidelines.
*
* Заявление о неоригинальном контенте:
* 1. Иконка взята из библиотеки Material Symbols, предоставленной сайтом allsvgicons.com.
* 2. Большая часть кода скрипта заимствована или использована напрямую из Gemini 3 Pro Preview, ChatGPT и Kimi.
* 3. Функция быстрого вставления кода извлечения облачного хранилища вдохновлена скриптами, такими как greasyfork.org/zh-CN/scripts/445489-网盘链接识别, greasyfork.org/zh-CN/scripts/439266-网盘有效性检查, github.com/Magiclyan/panAI (forked from syhyz1990/panAI).
* 4. Некоторые грамматические правила функции коррекции китайского текста частично основаны на материале с github.com/sparanoid/chinese-copywriting-guidelines.
*/
(function () {
'use strict';
// 0. 异步兼容层 (Async Compatibility Layer)
// 优先使用 GM.getValue (标准异步),降级使用 GM_getValue (Tampermonkey同步)
const safeGetValue = (key, def) => {
if (typeof GM !== 'undefined' && GM.getValue) {
return GM.getValue(key, def);
} else {
return Promise.resolve(GM_getValue(key, def));
}
};
const safeSetValue = (key, val) => {
if (typeof GM !== 'undefined' && GM.setValue) {
return GM.setValue(key, val);
} else {
return Promise.resolve(GM_setValue(key, val));
}
};
const safeOpenTab = (url, options) => {
if (typeof GM !== 'undefined' && GM.openInTab) {
// 现代异步标准 (GM.openInTab)
GM.openInTab(url, options);
} else {
// 旧版同步标准 (GM_openInTab)
GM_openInTab(url, options);
}
};
// =========================================================================
// 1. 配置与状态管理 (Configuration & State)
// =========================================================================
const DEFAULT_CONFIG = {
language: 'auto', // 'auto'(默认) | 'zh-CN' | 'en' | 'ru'
positionMode: 'endchar', // 'endchar' | 'mouse'
offset: 12, // px
timeout: 2800, // ms, 0 = infinite
buttonStyle: 'row', // 'row' (capsule) | 'col' (rounded rect)
forceWhiteBlack: false, // true = force white bg/black text
searchEngine: 'google', // key or custom url
enableToast: true,
enableCache: true,
unlockHotkey: 'ControlLeft',
enablePaste: true,
inputRecoveryMode: 'off', // 'off' | 'loose' (default, ignore tracking params) | 'strict'
enableDragPreview: false,
};
const PASTE_MODE_THREE_BTNS = 'copy-search-paste'; // 闪电粘贴三按钮模式标记
const SEARCH_ENGINES = {
google: { name: 'Google', url: 'https://www.google.com/search?q=%s' },
baidu: { name: 'Baidu', url: 'https://www.baidu.com/s?wd=%s' },
bing: { name: 'Bing', url: 'https://www.bing.com/search?q=%s' },
brave: { name: 'Brave', url: 'https://search.brave.com/search?q=%s' },
};
// [新增] 网盘域名匹配规则 (用于闪电粘贴密码提取)
const PAN_DOMAINS = [
'pan.baidu.com', 'lanzou', 'weiyun.com', 'cloud.189.cn',
'aliyundrive.com', 'alipan.com', '123pan.com', 'pan.quark.cn',
'pan.xunlei.com', '115.com', 'drive.uc.cn', 'fast.uc.cn', 'ctfile.com'
];
// [新增] 网盘密码提取正则
const PAN_CODE_REGEX = /(?:提取码|密码|访问码|分享码|口令)\s*[::]?\s*([a-zA-Z0-9]{4})(?![a-zA-Z0-9])/;
// [新增] 仅在当前Tab有效的网盘密码缓存(用于新标签页接收)
let sessionPanCode = null;
// 运行时状态
let cachedSelection = { text: '', html: '' };
let uiTimer = null;
let toastTimer = null;
let isScrolling = false;
let scrollTimeout = null;
let shadowRoot = null;
let hostElement = null;
// 获取配置
// 1. 配置缓存对象 (初始化为默认值)
let configCache = { ...DEFAULT_CONFIG };
// 新的同步读取 (直接读内存,速度最快,不阻塞UI)
const getConfig = (key) => {
return configCache[key];
};
// 新的异步写入 (更新内存 + 保存到存储)
const setConfig = async (key, val) => {
configCache[key] = val; // 立即更新内存,保证交互响应
await safeSetValue(key, val); // 异步写入持久化存储
};
// 多语言支持系统 (I18N System)
const I18N = {
'zh-CN': {
lang_name: '简体中文',
menu_lang: '🌐 语言/Language',
menu_pos: '📍 定位模式',
val_endchar: '字符末尾',
val_mouse: '鼠标位置',
menu_offset: '📏 弹出偏移量',
prompt_offset: '请输入按钮距离选区的偏移量 (px):',
menu_timeout: '⏱️ 停留时长',
val_infinite: '不消失',
prompt_timeout: '请输入停留时长 (ms, 0表示不自动消失):',
menu_style: '🎨 按钮布局',
val_row: '横排胶囊',
val_col: '纵排矩形',
menu_theme: '🌓 配色方案',
val_light: '强制浅色',
val_auto: '自动反色',
menu_search: '🔍 搜索引擎',
prompt_search: '请输入搜索引擎代码 (google, baidu, bing, brave) 或完整URL (%s 代替关键词):',
err_search: '无效输入。自定义URL需包含 %s',
menu_cache: '💾 选中即缓存',
val_on: '开启',
val_off: '关闭',
menu_toast: '🔔 复制反馈',
menu_hotkey: '🔑 超级划词键',
val_disabled: '已禁用',
prompt_hotkey: '请按下快捷键 (如 Ctrl, Alt, Shift) 或输入 "NONE" 禁用:',
menu_paste: '⚡ 闪电粘贴',
menu_block: '🚫 屏蔽页面自带划词条',
menu_clear: '🗑️ 清除当前域名屏蔽规则',
confirm_clear: '确定要清除 %s 下所有屏蔽规则吗?',
alert_cleared: '规则已清除,请刷新。',
alert_no_rules: '当前域名无已保存规则。',
menu_reset: '⚙️ 重置所有设置',
confirm_reset: '确定要重置所有设置吗?',
toast_unlock: '🔓 超级划词已激活',
toast_copied: '已复制',
toast_pasted: '已粘贴',
toast_paste_compat: '已粘贴 (兼容模式)',
toast_paste_fail: '粘贴失败',
picker_active: '进入拾取模式;按 ESC 退出',
picker_cant_block_self: '不能屏蔽脚本自身的按钮!',
picker_confirm: '确定屏蔽该元素吗?(按Esc退出)\n\n选择器: %s',
picker_saved: '元素已屏蔽并保存规则',
picker_exit: '已退出拾取模式',
btn_copy: '复制',
btn_search: '搜索',
btn_paste: '粘贴',
festival_cny: '🏮已复制🏮',
festival_xmas: '🎄已复制🎄',
btn_open_link: '打开链接',
toast_password_pasted: '已粘贴提取码',
menu_drag_preview: '🔗 拖拽预览',
btn_cut: '剪切',
menu_edit: '✏️ 编辑网页',
menu_exit_edit: '已退出编辑',
btn_delete: '删除',
btn_bold: '加粗',
btn_highlight: '标记',
disclaimer_text: '此网页内容已经过 <SCRIPT_NAME> 编辑,仅出于简化网页便于浏览之目的,不用于其他用途。'
},
'en': {
lang_name: 'English',
menu_lang: '🌐 Language',
menu_pos: '📍 Position',
val_endchar: 'End of Text',
val_mouse: 'Mouse Cursor',
menu_offset: '📏 Offset',
prompt_offset: 'Enter offset distance (px):',
menu_timeout: '⏱️ Timeout',
val_infinite: 'Infinite',
prompt_timeout: 'Enter timeout (ms, 0 = infinite):',
menu_style: '🎨 Layout',
val_row: 'Row (Capsule)',
val_col: 'Column (Rect)',
menu_theme: '🌓 Theme',
val_light: 'Force Light',
val_auto: 'Auto Contrast',
menu_search: '🔍 Engine',
prompt_search: 'Enter engine code (google, bing...) or URL with %s:',
err_search: 'Invalid input. Custom URL must contain %s',
menu_cache: '💾 Cache Selection',
val_on: 'On',
val_off: 'Off',
menu_toast: '🔔 Toast Notification',
menu_hotkey: '🔑 Unlock Hotkey',
val_disabled: 'Disabled',
prompt_hotkey: 'Press a key (Ctrl, Alt...) or type "NONE" to disable:',
menu_paste: '⚡ Smart Paste',
menu_block: '🚫 Block Page Element',
menu_clear: '🗑️ Clear Block Rules',
confirm_clear: 'Clear all rules for %s?',
alert_cleared: 'Rules cleared. Please refresh.',
alert_no_rules: 'No rules found for this domain.',
menu_reset: '⚙️ Reset Settings',
confirm_reset: 'Reset all settings?',
toast_unlock: '🔓 Unlock Mode Active',
toast_copied: 'Copied',
toast_pasted: 'Pasted',
toast_paste_compat: 'Pasted (Compat)',
toast_paste_fail: 'Paste Failed',
picker_active: 'Picker Mode Active (ESC to exit)',
picker_cant_block_self: 'Cannot block script UI!',
picker_confirm: 'Block this element? (ESC to cancel)\n\nSelector: %s',
picker_saved: 'Element blocked & saved.',
picker_exit: 'Picker Mode Exited',
btn_copy: 'Copy',
btn_search: 'Search',
btn_paste: 'Paste',
festival_cny: '🏮 Copied 🏮',
festival_xmas: '🎄 Copied 🎄',
btn_open_link: 'Open Link',
toast_password_pasted: 'Code Pasted',
menu_drag_preview: '🔗 Drag Link Preview',
btn_cut: 'Cut',
menu_edit: '✏️ Edit Page',
menu_exit_edit: 'Exit Edit Mode',
btn_delete: 'Delete',
btn_bold: 'Bold',
btn_highlight: 'Highlight',
disclaimer_text: 'Content edited by <SCRIPT_NAME> for simplification purposes only.'
},
'ru': {
lang_name: 'Русский',
menu_lang: '🌐 Язык/Language',
menu_pos: '📍 Позиция',
val_endchar: 'Конец текста',
val_mouse: 'Курсор мыши',
menu_offset: '📏 Отступ',
prompt_offset: 'Введите отступ (px):',
menu_timeout: '⏱️ Задержка',
val_infinite: 'Бесконечно',
prompt_timeout: 'Введите задержку (мс, 0 = бесконечно):',
menu_style: '🎨 Стиль кнопок',
val_row: 'Строка',
val_col: 'Колонка',
menu_theme: '🌓 Тема',
val_light: 'Светлая',
val_auto: 'Авто',
menu_search: '🔍 Поисковик',
prompt_search: 'Код (google, yandex...) или URL с %s:',
err_search: 'Ошибка. URL должен содержать %s',
menu_cache: '💾 Кэш выделения',
val_on: 'Вкл',
val_off: 'Выкл',
menu_toast: '🔔 Уведомления',
menu_hotkey: '🔑 Горячая клавиша',
val_disabled: 'Откл',
prompt_hotkey: 'Нажмите клавишу (Ctrl, Alt...) или "NONE":',
menu_paste: '⚡ Быстрая вставка',
menu_block: '🚫 Блокировка элементов',
menu_clear: '🗑️ Сброс блокировок',
confirm_clear: 'Удалить правила для %s?',
alert_cleared: 'Правила удалены. Обновите страницу.',
alert_no_rules: 'Нет правил для этого домена.',
menu_reset: '⚙️ Сброс настроек',
confirm_reset: 'Сбросить все настройки?',
toast_unlock: '🔓 Режим разблокировки',
toast_copied: 'Скопировано',
toast_pasted: 'Вставлено',
toast_paste_compat: 'Вставлено (совм.)',
toast_paste_fail: 'Ошибка вставки',
picker_active: 'Режим выбора (ESC для выхода)',
picker_cant_block_self: 'Нельзя блокировать кнопки скрипта!',
picker_confirm: 'Блокировать элемент? (ESC - отмена)\n\nСелектор: %s',
picker_saved: 'Заблокировано и сохранено.',
picker_exit: 'Режим выбора отключен',
btn_copy: 'Копировать',
btn_search: 'Поиск',
btn_paste: 'Вставить',
festival_cny: '🏮 Скопировано 🏮',
festival_xmas: '🎄 Скопировано 🎄',
btn_open_link: 'Открыть ссылку',
toast_password_pasted: 'Код вставлен',
menu_drag_preview: '🔗 Предпросмотр ссылки',
btn_cut: 'Вырезать',
menu_edit: '✏️ Редактировать',
menu_exit_edit: 'Выход из редактора',
btn_delete: 'Удалить',
btn_bold: 'Жирный',
btn_highlight: 'Маркер',
disclaimer_text: 'Контент отредактирован <SCRIPT_NAME> только для упрощения просмотра.'
}
};
const t = (key, ...args) => {
let lang = getConfig('language');
if (lang === 'auto') {
const nav = navigator.language.toLowerCase();
if (nav.startsWith('zh')) lang = 'zh-CN';
else if (nav.startsWith('ru')) lang = 'ru';
else lang = 'en';
}
const dict = I18N[lang] || I18N['en'];
let str = dict[key] || key;
args.forEach(arg => str = str.replace('%s', arg));
return str;
};
// --- 新增:编辑模式与合规声明状态 ---
let isEditMode = false;
let hasEditSessionStarted = false; // 标记本次会话是否启用过编辑模式
let complianceObserver = null;
let currentBannerId = null;
// 生成随机ID (防拦截)
const generateRandomId = () => 'tm-sc-' + Math.random().toString(36).slice(2, 9);
// 创建/重建合规声明
function ensureComplianceBanner() {
if (!hasEditSessionStarted) return; // 如果从未启动过编辑模式,不生成
// 1. 检查是否已存在
const existing = currentBannerId ? document.getElementById(currentBannerId) : null;
if (existing && existing.offsetParent !== null) return;// 如果存在且看起来正常(display不是none),则跳过
if (existing) existing.remove();// 如果存在但被隐藏了,或者不存在,则继续重建逻辑
// 2. 如果之前有Observer,先断开,避免重新插入时死循环
if (complianceObserver) {
complianceObserver.disconnect();
}
// 3. 创建元素
const scriptName = GM_info.script.name;
const banner = document.createElement('div');
currentBannerId = generateRandomId();
banner.id = currentBannerId;
banner.setAttribute('data-tm-policy', 'protected'); // [关键] 添加特殊策略标记,用于 CSS 排除
banner.setAttribute('contenteditable', 'false');
// 样式:高层级、半透明白底、浅灰字、底部居中、禁止选中、穿透点击(防Picker)
banner.style.cssText = `
position: fixed !important;
bottom: 50px !important;
left: 50% !important;
transform: translateX(-50%) !important;
z-index: 2147483647 !important;
background: rgba(255, 255, 255, 0.85) !important;
padding: 6px 14px !important;
border-radius: 6px !important;
box-shadow: 0 2px 10px rgba(0,0,0,0.08) !important;
pointer-events: none !important; /* 让鼠标穿透,既不影响浏览,也防止被拾取器选中 */
user-select: none !important;
-webkit-user-select: none !important;
display: flex !important;
align-items: center !important;
gap: 8px !important;
visibility: visible !important;
opacity: 1 !important;
width: auto !important;
height: auto !important;
border: 1px solid rgba(0,0,0,0.05) !important;
`;
// SVG 图标 (Info)
const iconContainer = document.createElement('div');
iconContainer.style.cssText = 'display:flex;align-items:center;color:#888;pointer-events:none;';
iconContainer.innerHTML = `<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" style="display:block;"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>`;
banner.appendChild(iconContainer);
// 2. 文本 (使用 Canvas 绘制,防篡改)
const textStr = t('disclaimer_text').replace('<SCRIPT_NAME>', scriptName);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const fontSize = 12;
const fontFamily = 'sans-serif';
// 测量文本宽度
ctx.font = `${fontSize}px ${fontFamily}`;
const metrics = ctx.measureText(textStr);
const textWidth = Math.ceil(metrics.width);
const textHeight = Math.ceil(fontSize * 1.2); // 留一点行高
// 设置 Canvas 尺寸 (考虑高分屏清晰度,使用 2x 缩放)
const dpr = window.devicePixelRatio || 1;
canvas.width = textWidth * dpr;
canvas.height = textHeight * dpr;
canvas.style.width = `${textWidth}px`;
canvas.style.height = `${textHeight}px`;
canvas.style.pointerEvents = 'none';
// 绘制
ctx.scale(dpr, dpr);
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.fillStyle = '#999';
ctx.textBaseline = 'middle';
ctx.fillText(textStr, 0, textHeight / 2 + 1); // +1 微调垂直居中
banner.appendChild(canvas);
document.body.appendChild(banner);
// 4. 启动被动监视 (MutationObserver)
complianceObserver = new MutationObserver((mutations) => {
let needsRebuild = false;
mutations.forEach(m => {
// 如果节点被移除
if (m.removedNodes.length) {
m.removedNodes.forEach(node => {
if (node.id === currentBannerId) needsRebuild = true;
});
}
// 如果属性被篡改 (如 style set to none)
if (m.target.id === currentBannerId) {
needsRebuild = true;
}
// 子节点变化 (例如 Canvas 被删除了)
if (m.target.id === currentBannerId && m.type === 'childList') needsRebuild = true;
});
if (needsRebuild) { // 异步重建防止死锁
// 只要检测到针对Banner的任何改动,立即销毁旧的并重建
setTimeout(() => { // 使用 setTimeout 避免在Observer回调中同步操作DOM
const old = document.getElementById(currentBannerId); // 销毁旧的引用(如果还在DOM里但被改了)
if (old) old.remove();
// 立即重建
ensureComplianceBanner();
}, 0);
}
});
complianceObserver.observe(document.body, { childList: true, subtree: false }); // 监控 body 子节点删除
// 监视 banner 自身的属性变化 (防止通过 style="display:none" 隐藏)
setTimeout(() => { // 注意:这里需要再次获取最新的 banner 引用
const b = document.getElementById(currentBannerId);
if(b && complianceObserver) {
complianceObserver.observe(b, { attributes: true, attributeFilter: ['style', 'class', 'hidden', 'id', 'data-tm-policy', 'contenteditable'], childList: true, subtree: true });
}
}, 0);
}
// 切换编辑模式
function toggleEditMode(enable) {
if (isEditMode === enable) return;
isEditMode = enable;
if (isEditMode) {
hasEditSessionStarted = true; // 标记会话已开始,此后 Banner 即使退出编辑模式也会常驻
document.designMode = 'on';
ensureComplianceBanner();
showToast(t('menu_edit') + ': ' + t('val_on'));
} else {
document.designMode = 'off';
showToast(t('menu_exit_edit'));
hideUI(); // 隐藏可能残留的按钮
ensureComplianceBanner(); // 确保 Banner 依然存在 (防止在切换瞬间被误删)
}
}
// ===============
// 2. 菜单系统 (GM Menu System)
// ===============
// 启动时一次性加载所有配置
async function initConfiguration() {
const keys = Object.keys(DEFAULT_CONFIG);
// 并行读取所有配置,提高速度
const values = await Promise.all(
keys.map(key => safeGetValue(key, DEFAULT_CONFIG[key]))
);
// 将读取到的值写入缓存
keys.forEach((key, index) => {
configCache[key] = values[index];
});
// 额外加载屏蔽规则 (blocked_elements)
const blockedRules = await safeGetValue('blocked_elements', {});
// 专门处理 blocked_elements 的缓存
configCache['blocked_elements'] = blockedRules;
}
function registerMenus() {
// 这种做法在某些管理器中可能需要刷新页面才能更新菜单文字,但在现代Tampermonkey中通常有效
// 为保证响应性,点击后我们弹窗提示或重刷菜单
// 1. 语言设置 (Language)
const curLang = getConfig('language');
const langLabel = curLang === 'auto' ? 'Auto' : (I18N[curLang] ? I18N[curLang].lang_name : curLang);
GM_registerMenuCommand(`${t('menu_lang')}: ${langLabel}`, () => {
const nextMap = { 'auto': 'zh-CN', 'zh-CN': 'en', 'en': 'ru', 'ru': 'auto' };
setConfig('language', nextMap[curLang] || 'auto');
location.reload();
});
// 2.1 定位模式
const posMode = getConfig('positionMode');
GM_registerMenuCommand(`${t('menu_pos')}: ${posMode === 'endchar' ? t('val_endchar') : t('val_mouse')}`, () => {
setConfig('positionMode', posMode === 'endchar' ? 'mouse' : 'endchar');
location.reload(); // 刷新以更新菜单状态
});
// 2.2 偏移量
GM_registerMenuCommand(`${t('menu_offset')}: ${getConfig('offset')}px`, () => {
const val = prompt(t('prompt_offset'), getConfig('offset'));
if (val !== null && !isNaN(val)) {
setConfig('offset', parseInt(val, 10));
location.reload();
}
});
// 2.3 停留时长
const timeout = getConfig('timeout');
GM_registerMenuCommand(`${t('menu_timeout')}: ${timeout === 0 ? t('val_infinite') : timeout + 'ms'}`, () => {
const val = prompt(t('prompt_timeout'), timeout);
if (val !== null && !isNaN(val)) {
setConfig('timeout', parseInt(val, 10));
location.reload();
}
});
// 2.4 按钮样式
const btnStyle = getConfig('buttonStyle');
GM_registerMenuCommand(`${t('menu_style')}: ${btnStyle === 'row' ? t('val_row') : t('val_col')}`, () => {
setConfig('buttonStyle', btnStyle === 'row' ? 'col' : 'row');
location.reload();
});
// 2.5 配色方案
const forceWB = getConfig('forceWhiteBlack');
GM_registerMenuCommand(`${t('menu_theme')}: ${forceWB ? t('val_light') : t('val_auto')}`, () => {
setConfig('forceWhiteBlack', !forceWB);
location.reload();
});
// 2.6 搜索引擎
const currentEngineKey = getConfig('searchEngine');
const engineName = SEARCH_ENGINES[currentEngineKey] ? SEARCH_ENGINES[currentEngineKey].name : 'Custom';
GM_registerMenuCommand(`${t('menu_search')}: ${engineName}`, () => {
const choice = prompt(t('prompt_search'), currentEngineKey);
if (choice) {
if (SEARCH_ENGINES[choice] || choice.includes('%s')) {
setConfig('searchEngine', choice);
location.reload();
} else {
alert(t('err_search'));
}
}
});
// 2.7 缓存功能
GM_registerMenuCommand(`${t('menu_cache')}: ${getConfig('enableCache') ? t('val_on') : t('val_off')}`, () => {
setConfig('enableCache', !getConfig('enableCache'));
location.reload();
});
// 2.8 Toast通知
GM_registerMenuCommand(`${t('menu_toast')}: ${getConfig('enableToast') ? t('val_on') : t('val_off')}`, () => {
setConfig('enableToast', !getConfig('enableToast'));
location.reload();
});
// 2.9 超级划词模式快捷键
const currentKey = getConfig('unlockHotkey');
GM_registerMenuCommand(`${t('menu_hotkey')}: ${currentKey || t('val_disabled')}`, () => {
const val = prompt(t('prompt_hotkey'));
if (val === null) return;
// 简单的输入清洗,如果用户按了键,浏览器事件可以捕获,但在prompt里只能输入
// 这里我们让用户手动输入,或者输入简单的 'ctrl' 映射一下
let finalKey = val.trim();
// 简单映射常用键
if (finalKey.toLowerCase() === 'ctrl') finalKey = 'ControlLeft';
if (finalKey.toLowerCase() === 'alt') finalKey = 'AltLeft';
if (finalKey.toLowerCase() === 'shift') finalKey = 'ShiftLeft';
if (finalKey === '' || finalKey.toUpperCase() === 'NONE') finalKey = '';
setConfig('unlockHotkey', finalKey);
location.reload();
});
// 2.10 闪电粘贴
GM_registerMenuCommand(`${t('menu_paste')}: ${getConfig('enablePaste') ? t('val_on') : t('val_off')}`, () => {
setConfig('enablePaste', !getConfig('enablePaste'));
location.reload();
});
// [新增] 拖拽预览开关
GM_registerMenuCommand(`${t('menu_drag_preview')}: ${getConfig('enableDragPreview') ? t('val_on') : t('val_off')}`, () => {
setConfig('enableDragPreview', !getConfig('enableDragPreview'));
location.reload();
});
// 2.11 码字防丢设置(功能不稳定目前不对用户展示,暂时注释掉菜单选项)
const recMode = getConfig('inputRecoveryMode');
const recModeText = { 'off': '已关闭', 'loose': '宽松 (默认)', 'strict': '严格 (完全匹配URL)' };
//GM_registerMenuCommand(`🛡️ 码字防丢: ${recModeText[recMode] || '宽松'}`, () => {
// const map = ['off', 'loose', 'strict'];
// const next = map[(map.indexOf(recMode) + 1) % map.length];
// setConfig('inputRecoveryMode', next);
// alert(`码字防丢模式已切换为:${recModeText[next]}\n\n说明:\n宽松:忽略 ?utm_source 等跟踪参数 (推荐)\n严格:必须 URL 完全一致才恢复\n关闭:不缓存输入内容`);
// location.reload();
//});
// 2.12 屏蔽元素工具
GM_registerMenuCommand(t('menu_block'), () => {
activateElementPicker();
});
GM_registerMenuCommand(t('menu_clear'), async () => {
const domain = location.hostname;
if (confirm(t('confirm_clear', domain))) {
const rules = await safeGetValue('blocked_elements', {});
if (rules[domain]) {delete rules[domain];
await safeSetValue('blocked_elements', rules);if (typeof configCache !== 'undefined') {configCache['blocked_elements'] = rules;}
alert(t('alert_cleared'));
location.reload();} else {alert(t('alert_no_rules'));
}
}});
// 新增:编辑网页
GM_registerMenuCommand(t('menu_edit'), () => {
toggleEditMode(!isEditMode);
});
// 2.13 重置
GM_registerMenuCommand(t('menu_reset'), async () => {
if (confirm(t('confirm_reset'))) {
const keys = Object.keys(DEFAULT_CONFIG);await Promise.all(keys.map(k => setConfig(k, DEFAULT_CONFIG[k])));
location.reload();
}
});
}
// =======================
// 3. 核心逻辑 (Core Logic)
// =======================
// [新增] 智能链接提取器
function extractLinkFromText(rawText) {
// 1. 快速预筛选 (性能优化)
if (!rawText || (!rawText.includes('.') && !rawText.includes('://'))) return null;
// 2. 清洗中文混淆 (处理 "pa删n.baid中u.co文m" 这种情况)
// 仅移除中文字符,保留其他所有字符以便正则匹配
const cleanText = rawText.replace(/[\u4e00-\u9fa5]/g, '');
// 3. 正则提取
// 匹配协议头(可选) + 域名/IP + 路径/参数
// 排除末尾的标点符号: ) ] 】 ) 以及常见的句号逗号
const urlPattern = /((?:https?:\/\/)?(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(?::\d{1,5})?(?:\/[^\s\u4e00-\u9fa5)\]】)]*)?)/gi;
const matches = cleanText.match(urlPattern);
// 4. 必须有且仅有一个完整的链接
if (!matches || matches.length !== 1) return null;
let url = matches[0];
// 5. 特殊清洗:如果URL末尾包含了非URL字符(如被正则误吸入的符号),做Trim
// 由于上面正则排除了特定结束符,这里主要处理可能遗漏的边缘情况
url = url.replace(/[.,;:]+$/, '');
// 6. 域名/IP 规则校验
// 提取Host部分
let host = url.replace(/^https?:\/\//, '').split('/')[0];
// 6.1 排除以纯IP 10. 或 172. 开头的
if (/^10\./.test(host) || /^172\./.test(host)) return null;
// 6.2 必须包含顶级域名分隔符 '.' (regex已保证,但防止demo/这种情况被误判,虽regex也处理了)
if (!host.includes('.')) return null;
// 7. 补全协议 (用于 safeOpenTab)
let fullUrl = url;
if (!url.startsWith('http')) {
fullUrl = 'http://' + url;
}
return { display: url, url: fullUrl, host: host };
}
// [新增] 网盘密码提取器
function extractPanCode(text) {
if (!getConfig('enablePaste')) return null;
const match = text.match(PAN_CODE_REGEX);
return match ? match[1] : null;
}
// [修改] 高效智能选区定位计算器 (三级降级策略:智能Rect -> 整体包围盒 -> 鼠标位置)
function getSmartSelectionState(selection, mouseEvent) {
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
// 1. 尝试获取精细的矩形列表 (可能为空,特别是在 Input/Textarea 或 框架更新DOM时)
let rects = range.getClientRects();
let targetRect = null;
let isBackward = false;
let isVertical = false;
// --- 阶段 A: 智能精确定位 (Smart Directional) ---
if (rects.length > 0) {
const anchor = selection.anchorNode;
const focus = selection.focusNode;
// 判定选区方向
if (anchor === focus) {
isBackward = selection.anchorOffset > selection.focusOffset;
} else {
// 使用位掩码判定节点位置
const pos = anchor.compareDocumentPosition(focus);
if (pos & Node.DOCUMENT_POSITION_PRECEDING) isBackward = true;
}
// 判定垂直排版 (仅检查 focusNode)
let focusEl = focus.nodeType === 1 ? focus : focus.parentElement;
if (focusEl) {
const style = window.getComputedStyle(focusEl);
const writingMode = style.writingMode || 'horizontal-tb';
isVertical = writingMode.startsWith('vertical');
}
// 根据方向获取头或尾的 Rect
// 注意:如果是 detached 节点,这里虽然有 rects 但可能全是 0,下一阶段会检测
targetRect = isBackward ? rects[0] : rects[rects.length - 1];
}
// 辅助函数:检测 Rect 是否无效 (0x0 且位于 0,0 通常意味着节点已脱离文档流)
const isInvalidRect = (r) => {
return !r || (r.width === 0 && r.height === 0 && r.top === 0 && r.left === 0);
};
// --- 阶段 B: 经典包围盒兜底 (Classic Bounding Box) ---
// 如果没有 rects,或者获取到的 rect 是无效的 (0x0)
if (isInvalidRect(targetRect)) {
const bounding = range.getBoundingClientRect();
// 只有当 bounding 也是有效的时候才使用
if (!isInvalidRect(bounding)) {
targetRect = bounding;
// 包围盒丢失了方向细节,默认视为正向水平
isBackward = false;
isVertical = false;
}
}
// --- 阶段 C: 鼠标坐标兜底 (Mouse Position Fallback) ---
// 如果连包围盒都是 0x0 (常见于 Vue 销毁了节点但选区对象还在内存中),直接使用鼠标位置模拟一个 Rect
if (isInvalidRect(targetRect) && mouseEvent) {
const size = 20; // 模拟一个光标高度
targetRect = {
// 构造一个符合 DOMRect 接口的对象
top: mouseEvent.clientY - size,
bottom: mouseEvent.clientY,
left: mouseEvent.clientX,
right: mouseEvent.clientX,
width: 0,
height: size,
x: mouseEvent.clientX,
y: mouseEvent.clientY - size
};
isBackward = false;
isVertical = false;
}
// 如果所有尝试都失败(极罕见),返回 null 让外部处理
if (isInvalidRect(targetRect)) return null;
return {
rect: targetRect,
isBackward: isBackward,
isVertical: isVertical
};
}
// [修改] 初始化 Shadow DOM 容器 (针对 SPA/AJAX 优化)
function initContainer() {
// 1. 检查 hostElement 是否存在且仍然连接在文档中 (isConnected)
if (hostElement && hostElement.isConnected) return;
// 2. 如果 hostElement 存在但已从 DOM 脱落(被网页脚本清除),清理旧引用
if (hostElement) {
hostElement = null;
shadowRoot = null;
}
// 3. 重新创建容器
hostElement = document.createElement('div');
hostElement.id = 'tm-smart-copy-host';
hostElement.style.all = 'initial';
hostElement.style.position = 'fixed';
hostElement.style.zIndex = '2147483647'; // Max Z-Index
hostElement.style.top = '0';
hostElement.style.left = '0';
hostElement.style.width = '0';
hostElement.style.height = '0';
hostElement.style.overflow = 'visible';
hostElement.style.pointerEvents = 'none';
// [重要修改] 挂载到 documentElement (html) 而不是 body
// 这样即使 body 被 SPA 框架重写,挂在 html 上的元素通常能幸存,或者至少能保证层级正确
// 如果必须确保层级,挂载前再次检查
(document.documentElement || document.body).appendChild(hostElement);
shadowRoot = hostElement.attachShadow({ mode: 'open' });
// 重新注入样式
const style = document.createElement('style');
style.textContent = getStyles();
shadowRoot.appendChild(style);
}
// 获取样式表字符串
function getStyles() {
const isCol = getConfig('buttonStyle') === 'col';
const padRow = '10px 13.1415926px'; // 胶囊:上下略小,左右略大
const padCol = '10px'; // 纵向:正方形,四边一致
return `
:host { all: initial; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
.sc-container {
position: fixed;
display: flex;
flex-direction: ${isCol ? 'column' : 'row'};
background: rgba(255, 255, 255, 0.15); /* 调整背景透明度 */
border: 1px solid transparent; /* 透明边框 */
box-shadow:
/* 发光边框效果 */
0 0 0 1px rgba(255, 255, 255, 0.3),
0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 4px rgba(0, 0, 0, 0.06),
0 0 10px rgba(255, 255, 255, 0.1); /* 发光效果 */
color: #000;
border-radius: ${isCol ? '12px' : '20px'};
font-size: 16px;
z-index: 9999;
cursor: pointer;
user-select: none;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
opacity: 0;
transform: scale(0.95);
transition: opacity 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; /* 添加box-shadow过渡 */
pointer-events: auto;
overflow: hidden;
white-space: nowrap;
}
.sc-container.visible {
opacity: 1;
transform: scale(1);
}
.sc-btn {
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, transform 0.1s;
color: #000;
/* 方向不同,padding 不同 */
padding: ${isCol ? padCol : padRow};
}
.sc-container[data-btn-count="1"] .sc-btn {
padding: 10px;
aspect-ratio: 1 / 1;
}
.sc-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.03);
}
.sc-btn:active {
transform: scale(0.98);
background: rgba(255, 255, 255, 0.2);
}
/* 深色模式覆盖 */
.theme-dark-ui {
/* --- 深色模式背景样式 --- */
background: rgba(30, 30, 30, 0.3); /* 调整深色模式背景透明度 */
border: 1px solid transparent; /* 深色模式也需要透明边框 */
box-shadow:
/* 发光边框效果 (深色模式专属颜色) */
0 0 0 1px rgba(255, 255, 255, 0.15),
0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 4px rgba(0, 0, 0, 0.06),
0 0 10px rgba(0, 0, 0, 0.1); /* 深色模式下用较暗的发光 */
color: #fff;
}
.theme-dark-ui .sc-btn {
color: #fff;
}
.theme-dark-ui .sc-btn:hover {
background: rgba(255, 255, 255, 0.15);
}
.theme-dark-ui .sc-btn:active {
background: rgba(255, 255, 255, 0.1);
}
/* 分割线 */
.divider {
background: rgba(255, 255, 255, 0.25);
}
.theme-dark-ui .divider {
background: rgba(255, 255, 255, 0.12);
}
.divider-v { width: 1px; height: 1.6em; align-self: center; }
.divider-h { height: 1px; width: 100%; }
/* Toast 通知 */
.sc-toast {
position: fixed;
left: 50%;
bottom: 20px;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.6);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
z-index: 10000;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
}
.sc-toast.show { opacity: 1; }
/* ===== Liquid Glass + HDR Glow ===== */
/* 以下均为额外增强玻璃质感补丁,全部删除也不会影响正常显示:涉及模拟玻璃扭曲(使用多层 background、微弱的 background-blend-mode、低透明度彩色噪声)、轮廓边缘反光(利用 box-shadow 叠加 1~3 层白色/彩色外发光)、HDR hover glow(在 hover 时提亮、加入更强的外扩光、加一点 scale) */
.sc-container {
position: fixed;
display: flex;
backdrop-filter: blur(14px) saturate(180%);
-webkit-backdrop-filter: blur(14px) saturate(180%);
background:
/* 轻微彩色折射层 */
linear-gradient(135deg, rgba(255,255,255,0.20), rgba(255,255,255,0.05)),
/* 噪声纹理模拟扭曲 */
url("data:image/svg+xml;utf8,\
<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'>\
<filter id='n'>\
<feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/>\
<feColorMatrix type='saturate' values='0'/>\
<feComponentTransfer><feFuncA type='linear' slope='0.08'/></feComponentTransfer>\
</filter>\
<rect width='40' height='40' filter='url(#n)'/>\
</svg>"),
rgba(255,255,255,0.10);
background-blend-mode: overlay;
/* 轮廓反光效果(外圈) */
box-shadow:
0 0 0 1px rgba(255,255,255,0.35),
0 0 12px rgba(255,255,255,0.15),
0 8px 30px rgba(0,0,0,0.22);
transition:
box-shadow .25s ease,
transform .25s ease,
opacity .2s ease;
}
/* 鼠标悬停按钮 HDR 高亮 */
.sc-btn:hover {
background: rgba(255,255,255,0.28);
transform: scale(1.05);
box-shadow:
0 0 6px rgba(255,255,255,0.8),
0 0 16px rgba(255,255,255,0.6),
0 0 26px rgba(255,255,255,0.4);
filter: brightness(1.25); /* HDR 感 */
}
/* 深色模式增强 */
.theme-dark-ui {
background:
linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02)),
url("data:image/svg+xml;utf8,\
<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'>\
<filter id='n'>\
<feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/>\
<feColorMatrix type='saturate' values='0'/>\
<feComponentTransfer><feFuncA type='linear' slope='0.06'/></feComponentTransfer>\
</filter>\
<rect width='40' height='40' filter='url(#n)'/>\
</svg>"),
rgba(0,0,0,0.25);
background-blend-mode: soft-light;
box-shadow:
0 0 0 1px rgba(255,255,255,0.18),
0 0 12px rgba(255,255,255,0.06),
0 8px 26px rgba(0,0,0,0.32);
}
/* 深色模式 hover 发光更亮 */
.theme-dark-ui .sc-btn:hover {
background: rgba(255,255,255,0.12);
filter: brightness(1.35);
box-shadow:
0 0 6px rgba(255,255,255,0.5),
0 0 22px rgba(255,255,255,0.25),
0 0 36px rgba(255,255,255,0.15);
}
/* =============================
浅色按钮(theme-light-ui)左上角 + 右下角出现 黑色反光边模拟透明玻璃的折射深边,深色按钮(theme-dark-ui)左上角 + 右下角出现 白色反光边模拟深色玻璃的折射亮边
============================= */
/* 浅色按钮(白底黑字)玻璃边:黑色内阴影 */
.theme-light-ui.sc-container {
/* 提高整体透明度:背景更透、阴影更亮 */
background:
linear-gradient(135deg, rgba(255,255,255,0.25), rgba(255,255,255,0.08)),
url("data:image/svg+xml;utf8,\
<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'>\
<filter id='n'>\
<feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/>\
<feColorMatrix type='saturate' values='0'/>\
<feComponentTransfer><feFuncA type='linear' slope='0.06'/></feComponentTransfer>\
</filter>\
<rect width='40' height='40' filter='url(#n)'/>\
</svg>"),
rgba(255,255,255,0.18);
background-blend-mode: overlay;
/* 不改变原本背景,仅增加内侧反光 */
box-shadow:
inset 2px 2px 3px rgba(0,0,0,0.20), /* 左上角黑色内反光 */
inset -2px -2px 3px rgba(0,0,0,0.18), /* 右下角黑色内反光 */
0 0 0 1px rgba(255,255,255,0.45),
0 0 12px rgba(255,255,255,0.25),
0 8px 30px rgba(0,0,0,0.18);
/* 分割线改为黑色,透明度 0.18 */
--divider-color: rgba(0,0,0,0.18);
}
/* 浅色模式 hover 更亮 */
.theme-light-ui .sc-btn:hover {
background: rgba(255,255,255,0.35);
filter: brightness(1.3);
box-shadow:
0 0 6px rgba(255,255,255,0.9),
0 0 16px rgba(255,255,255,0.7),
0 0 26px rgba(255,255,255,0.5);
}
/* 统一分割线颜色(浅色模式黑色,深色模式白色) */
.divider {
background: var(--divider-color, rgba(255,255,255,0.25));
}
/* 深色按钮(黑底白字)玻璃边:白色内反光 */
.theme-dark-ui.sc-container {
box-shadow:
inset 2px 2px 3px rgba(255,255,255,0.32), /* 左上角亮边 */
inset -2px -2px 3px rgba(255,255,255,0.28), /* 右下角亮边 */
0 0 0 1px rgba(255,255,255,0.18),
0 0 12px rgba(255,255,255,0.06),
0 8px 26px rgba(0,0,0,0.32);
}
`;
}
// =======================
// [新增] 拖拽链接预览子系统 (Drag Preview Subsystem)
// =======================
let dragStartData = null; // 临时存储拖拽起点数据
const PREVIEW_WIN_NAME = 'PicKitPreviewWindow';
// 1. 处理拖拽开始
function handleLinkDragStart(e) {
if (!getConfig('enableDragPreview')) return;
// 精确判断:必须是左键拖拽,且目标是超链接(或在超链接内部)
// closest 向上查找,避免拖拽链接内的文字或图片时不触发
const link = e.target.closest('a[href]');
// 排除无效链接(如 javascript:void(0) 或锚点)
if (!link || !link.href || link.href.startsWith('javascript:') || link.href.startsWith('#')) {
dragStartData = null;
return;
}
dragStartData = {
url: link.href,
x: e.clientX,
y: e.clientY,
timestamp: Date.now()
};
}
// 2. 处理拖拽结束
function handleLinkDragEnd(e) {
if (!dragStartData) return;
const { x: startX, y: startY, url } = dragStartData;
const endX = e.clientX;
const endY = e.clientY;
/* ---------- 1. 视口外松开直接放弃 ---------- */
if (
endX < 0 || endY < 0 ||
endX > window.innerWidth || endY > window.innerHeight
) {
dragStartData = null;
return;
}
/* ---------- 2. 输入区 / 富文本 / 拖放容器 过滤 ---------- */
const target = document.elementFromPoint(endX, endY); // 松手时最顶层的元素
if (target) {
// 2-1 输入框
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
dragStartData = null;
return;
}
// 2-2 富文本编辑
if (target.closest('[contenteditable="true"]')) {
dragStartData = null;
return;
}
// 2-3 具有 dragover / drop 事件的容器
const dropZone = target.closest('[ondragover],[ondrop]');
if (dropZone) {
dragStartData = null;
return;
}
}
/* ---------- 3. 距离阈值判断 ---------- */
const dist = Math.hypot(endX - startX, endY - startY);
if (dist > 30) openPreviewWindow(url); // 距离阈值:30px (防止点击时的微小抖动被误判为拖拽)
// 清理数据
dragStartData = null;
}
// 3. 打开预览窗口
async function openPreviewWindow(url) {
const screen = window.screen;
// 获取屏幕可用区域尺寸
const screenW = screen.availWidth;
const screenH = screen.availHeight;
// 兼容多显示器坐标 (如果有 availLeft 则使用,否则默认为 0)
const screenLeft = screen.availLeft || 0;
const screenTop = screen.availTop || 0;
// 黄金分割比
const GOLDEN_RATIO = 0.618;
// 计算目标尺寸:保持屏幕宽高比,长宽缩放至 61.8%
const width = Math.round(screenW * GOLDEN_RATIO);
const height = Math.round(screenH * GOLDEN_RATIO);
// 计算居中位置
const left = screenLeft + (screenW - width) / 2;
const top = screenTop + (screenH - height) / 2;
const features = `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes,status=yes`;
window.open(url, PREVIEW_WIN_NAME, features);
}
// =======================
// [新增] 强效解锁模式 (Unlock Mode)
// ===================
let isUnlockMode = false;
let unlockStyleEl = null;
let startPos = { x: 0, y: 0 };
// 1. 动态CSS:强制文本可选,屏蔽拖拽,屏蔽指针事件限制等
function getUnlockCSS() {
return `
/* --- 1. 全局强制可选 (排除受保护元素) --- */
/* 权重: 0,1,1 (html/body) 或 0,1,0 (*:not) */
html, body, *:not([data-tm-policy="protected"]), [unselectable] {
user-select: text !important;
-webkit-user-select: text !important;
-moz-user-select: text !important;
-ms-user-select: text !important;
cursor: text !important;
}
/* 强制高亮颜色 */
::selection {background-color: #3390FF !important;color: #ffffff !important;text-shadow: none !important;}
::-moz-selection {background-color: #3390FF !important;color: #ffffff !important;text-shadow: none !important;}
/* 让链接看起来像普通文本,且禁止图片/链接被拖拽(干扰划词) */
a:not([data-tm-policy="protected"]),
a *:not([data-tm-policy="protected"]),
img:not([data-tm-policy="protected"]){
pointer-events: auto !important; /* 必须允许点击才能触发我们的拦截逻辑,否则无法划词 */
user-drag: none !important;
-webkit-user-drag: none !important;
text-decoration: none !important; /* 视觉上更像文本 */
}
/* 禁用常见的透明遮罩层交互,让鼠标穿透到下方文字 */
/* 注意:这不会影响文字本身,因为文字会继承通配符的 pointer-events: auto */
div[style*="z-index"][style*="fixed"]:not([data-tm-policy="protected"]),
div[style*="z-index"][style*="absolute"]:not([data-tm-policy="protected"]) {pointer-events: none !important;}
/* 重新把文字元素的交互打开,防止被上面的规则误杀 */
/* 权重计算: div(1) + [style](10) + :not(10) = 21 */
div[style*="z-index"] *:not([data-tm-policy="protected"]),
p:not([data-tm-policy="protected"]),
span:not([data-tm-policy="protected"]),
h1:not([data-tm-policy="protected"]), h2:not([data-tm-policy="protected"]),
h3:not([data-tm-policy="protected"]), h4:not([data-tm-policy="protected"]),
h5:not([data-tm-policy="protected"]), h6:not([data-tm-policy="protected"]),
em:not([data-tm-policy="protected"]), strong:not([data-tm-policy="protected"]),
i:not([data-tm-policy="protected"]), b:not([data-tm-policy="protected"]),
td:not([data-tm-policy="protected"]), li:not([data-tm-policy="protected"]),
code:not([data-tm-policy="protected"]), pre:not([data-tm-policy="protected"]) {
pointer-events: auto !important;
}
/* 针对被截断文本展开后的样式:隐藏滚动条但保留滚动功能 */
.tm-sc-expanded {
scrollbar-width: none !important; /* Firefox */
-ms-overflow-style: none !important; /* IE或Edge */
}
.tm-sc-expanded::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
}
/* [新增补丁] 某些网页为了实现"卡片整体可点击",使用了绝对定位的透明链接层覆盖在文本上方,导致鼠标事件被拦截无法穿透,以下补丁专门针对覆盖文本的透明链接层(如 Tailwind 的 absolute inset-0) */
a.absolute, a[style*="position: absolute"] { pointer-events: none !important; }
/* 放在最后,确保权重覆盖所有上方规则 */
/*
技巧:重复属性选择器三次,权重叠加。
权重计算: [attr](10) * 3 = 30。
30 > 21 (Rule 5)。
这将彻底覆盖上方任何针对其子元素的 pointer-events: auto 设置。
*/
[data-tm-policy="protected"][data-tm-policy="protected"][data-tm-policy="protected"],
[data-tm-policy="protected"][data-tm-policy="protected"][data-tm-policy="protected"] * {
user-select: none !important;
-webkit-user-select: none !important;
-moz-user-select: none !important;
pointer-events: none !important; /* 强制穿透,不给鼠标任何机会 */
cursor: default !important;
z-index: 2147483647 !important;
}
`;
}
// 检查是否为受保护元素
function isProtectedElement(target) {
return target && target.closest && target.closest('[data-tm-policy="protected"]');
}
function handleCaptureSelectStart(e) {
if (!isUnlockMode) return;
// 如果目标是合规声明,立即阻止一切操作
if (isProtectedElement(e.target)) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
return;
}
// 阻止网页脚本获知“选区开始”事件,从而无法取消它
// 注意:不要 preventDefault,否则浏览器自己也不会开始选区了
// 我们只阻止冒泡给网页代码
e.stopPropagation();
e.stopImmediatePropagation();
}
// 2. 拦截点击事件:如果是拖拽操作或点击链接,则阻止
function handleCaptureClick(e) {
if (!isUnlockMode) return;
// 针对合规声明的拦截
if (isProtectedElement(e.target)) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
return;
}
const dx = Math.abs(e.clientX - startPos.x);
const dy = Math.abs(e.clientY - startPos.y);
const isDrag = dx > 3 || dy > 3; // 位移超过3px视为拖拽
// 判断是否点击了链接(向上查找a标签)
let target = e.target;
let isLink = false;
while (target && target !== document) {
if (target.tagName === 'A') {
isLink = true;
break;
}
target = target.parentNode;
}
// 如果是拖拽选区操作,或者点击的是链接,则拦截
if (isDrag || isLink) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
// console.log('Blocked click by Smart Copy');
}
}
// 鼠标按下时按需处理当前元素
function handleCaptureMouseDown(e) {
if (!isUnlockMode) return;
// 针对合规声明的拦截
if (isProtectedElement(e.target)) {
e.preventDefault(); // 阻止聚焦和放置光标
e.stopPropagation();
e.stopImmediatePropagation();
return;
}
// 1. 处理被点击的元素 (懒加载逻辑)
const el = e.target;
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) {
try {
// 处理 Password -> Text
if (el.type === 'password') {
el.dataset.scOriginalType = 'password';
el.type = 'text';
modifiedElements.add(el); // 加入待恢复列表
}
// 处理 Disabled / ReadOnly
if (el.disabled) {
el.disabled = false;
el.dataset.scWasDisabled = 'true';
modifiedElements.add(el);
}
if (el.readOnly) {
el.readOnly = false;
el.dataset.scWasReadOnly = 'true';
modifiedElements.add(el);
}
} catch (err) {
// 忽略跨域或受保护元素的错误
}
}
// 2. 阻止网页在这个位置触发自定义逻辑
startPos = { x: e.clientX, y: e.clientY };
e.stopPropagation();
e.stopImmediatePropagation();
}
function handleCaptureDragStart(e) {
if (!isUnlockMode) return;
// 禁止原生拖拽,保证划词顺畅
e.preventDefault();
e.stopPropagation();
}
function handleCaptureCopy(e) {
if (!isUnlockMode) return;
// 允许复制,但阻止网页监听(防止网页通过监听copy事件来篡改剪贴板或弹出付费提示)
// 注意:这不会阻止 navigator.clipboard.write,但会阻止 document.execCommand('copy') 触发的网页脚本
e.stopImmediatePropagation();
}
// 新增:防止网页通过 selectionchange 监听器清空选区
// 注意:这个事件在 document 上触发频率很高,需要轻量处理
function handleCaptureSelectionChange(e) {
if (!isUnlockMode) return;
// 同样,阻止网页感知到选区变化
e.stopPropagation();
e.stopImmediatePropagation();
}
function cleanInlineEvents() {
// 仅处理 document.body 和 document.documentElement,极低消耗
// 只有当用户确实遇到极难缠的页面时,才需要遍历更多元素,但通常 body 足够了
const targets = [document.documentElement, document.body];
const events = ['onselectstart', 'onmousedown', 'oncontextmenu', 'oncopy'];
targets.forEach(el => {
if (!el) return;
events.forEach(evt => {
if (el.hasAttribute(evt)) {
el.removeAttribute(evt);
}
// 同时也置空 DOM 属性
if (el[evt]) {
el[evt] = null;
}
});
});
}
const modifiedElements = new Set(); //追踪受影响元素的集合
// 鼠标悬停时智能展开截断文本
function handleExpandHover(e) {
if (!isUnlockMode) return;
let target = e.target;
// 优化性能:忽略已处理元素或非元素节点
if (target.nodeType !== 1 || target.classList.contains('tm-sc-expanded')) return;
// 获取计算样式
const style = window.getComputedStyle(target);
// 检测单行截断 (text-overflow: ellipsis)
const isEllipsis = style.textOverflow === 'ellipsis';
// 检测多行截断 (-webkit-line-clamp)
// 注意:getComputedStyle 获取的 webkitLineClamp 可能是 'none' 或数字字符串
const isLineClamp = style.webkitLineClamp && style.webkitLineClamp !== 'none';
if (isEllipsis || isLineClamp) {
// 1. 锁定当前尺寸,防止布局抖动 (Reflow)
const rect = target.getBoundingClientRect();
// 必须使用 important 覆盖原有样式
target.style.setProperty('height', rect.height + 'px', 'important');
target.style.setProperty('width', rect.width + 'px', 'important');
// 2. 标记已处理
target.classList.add('tm-sc-expanded');
// 3. 应用展开策略
if (isLineClamp) {
// 多行截断处理策略:
// 保持高度不变,移除行数限制,允许垂直滚动
target.style.setProperty('-webkit-line-clamp', 'none', 'important');
target.style.setProperty('overflow-y', 'auto', 'important');
// 某些使用 -webkit-box 的布局在移除 clamp 后行为不可控,
// 如果需要更激进的显示,可能需要 display: block,但这里为了兼容性优先只动 overflow
} else {
// 单行截断处理策略:
// 保持不换行,移除省略号,允许水平滚动
target.style.setProperty('text-overflow', 'clip', 'important');
target.style.setProperty('overflow-x', 'auto', 'important');
// 强制不换行 (防止某些 flex 布局在 overflow 变动后尝试换行)
target.style.setProperty('white-space', 'nowrap', 'important');
}
}
}
// 退出模式时清理所有展开的元素
function cleanupExpandedElements() {
const elements = document.querySelectorAll('.tm-sc-expanded');
elements.forEach(el => {
// 在恢复样式前,强制滚动回顶部和最左侧,使内容停留在起始位置,视觉上与操作前完全一致
el.scrollTop = 0;
el.scrollLeft = 0;
el.classList.remove('tm-sc-expanded');
// 移除我们注入的内联样式,恢复网页原貌
// 注意:这会移除所有同名内联样式。如果网页本身就有内联 height,这里可能会误伤。
// 但考虑到这只是个临时交互,且针对的是截断文本(通常由 CSS 类控制),直接 removeProperty 风险可控。
el.style.removeProperty('height');
el.style.removeProperty('width');
el.style.removeProperty('-webkit-line-clamp');
el.style.removeProperty('overflow-y');
el.style.removeProperty('overflow-x');
el.style.removeProperty('text-overflow');
el.style.removeProperty('white-space');
});
}
// 3. 开启/关闭模式
function toggleUnlockMode(active) {
if (active === isUnlockMode) return;
isUnlockMode = active;
if (active) {
// 注入CSS
if (!unlockStyleEl) {
unlockStyleEl = document.createElement('style');
unlockStyleEl.textContent = getUnlockCSS();
unlockStyleEl.id = 'tm-smart-copy-unlock-style';
}
(document.documentElement || document.body).appendChild(unlockStyleEl);
// 清理内联事件 (只做一次,极低开销)
cleanInlineEvents();
// 挂载拦截监听器 (使用Capture模式优先拦截)
// 优先级:最高 (Capture + StopImmediatePropagation)
window.addEventListener('selectstart', handleCaptureSelectStart, true);
window.addEventListener('click', handleCaptureClick, true);
window.addEventListener('mousedown', handleCaptureMouseDown, true);
window.addEventListener('dragstart', handleCaptureDragStart, true);
window.addEventListener('copy', handleCaptureCopy, true);
window.addEventListener('contextmenu', handleCaptureCopy, true); // 顺便解右键
// selectionchange 通常在 document 上触发
document.addEventListener('selectionchange', handleCaptureSelectionChange, true);
document.addEventListener('mouseover', handleExpandHover, true); // [新增] 挂载文本展开监听器 (使用 mouseover 即可,性能优于 mousemove)
showToast(t('toast_unlock'));
} else {
// 移除CSS
if (unlockStyleEl && unlockStyleEl.parentNode) {
unlockStyleEl.parentNode.removeChild(unlockStyleEl);
}
// >>>>>> 遍历恢复 <<<<<<
modifiedElements.forEach(el => {
try {
// 恢复 Password
if (el.dataset.scOriginalType === 'password') {
el.type = 'password';
delete el.dataset.scOriginalType;
}
// 恢复 Disabled / ReadOnly
if (el.dataset.scWasDisabled === 'true') { el.disabled = true; delete el.dataset.scWasDisabled; }
if (el.dataset.scWasReadOnly === 'true') { el.readOnly = true; delete el.dataset.scWasReadOnly; }
} catch(e) {}// 即使某个属性恢复失败,也不应中断循环
});
modifiedElements.clear(); // 清空集合
// 移除监听器
window.removeEventListener('selectstart', handleCaptureSelectStart, true);
window.removeEventListener('mousedown', handleCaptureMouseDown, true);
window.removeEventListener('click', handleCaptureClick, true);
window.removeEventListener('dragstart', handleCaptureDragStart, true);
window.removeEventListener('copy', handleCaptureCopy, true);
window.removeEventListener('contextmenu', handleCaptureCopy, true);
document.removeEventListener('selectionchange', handleCaptureSelectionChange, true);
// 移除文本展开监听器 并 还原DOM
document.removeEventListener('mouseover', handleExpandHover, true);
cleanupExpandedElements();
// 清除当前选区的高亮
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
sel.removeAllRanges(); // 彻底清掉选区
//sel.collapseToStart(); // 把选区折叠到起点,强制浏览器立即重绘使高亮消失(两种方法二选一)
}
// 移除Toast (如果不希望提示“已关闭”可以删掉下面这行)
// showToast('🔒 超级划词已关闭');
// 为了用户体验,松开按键时让Toast自然消失即可,不必特意提示关闭
const toast = shadowRoot && shadowRoot.querySelector('.sc-toast');
if(toast) toast.classList.remove('show');
}
}
// 4. 键盘监听
document.addEventListener('keydown', (e) => {
// 新增:ESC 退出编辑模式
if (e.key === 'Escape' && isEditMode) {
toggleEditMode(false);
return;
}
const hotkey = getConfig('unlockHotkey');
if (!hotkey) return;
// e.code 对应物理按键位置,如 ControlLeft, AltLeft, KeyA
// e.key 对应字符,如 Control, Alt, a
if (e.code === hotkey || e.key === hotkey) {
if (!isUnlockMode) toggleUnlockMode(true);
}
});
document.addEventListener('keyup', (e) => {
const hotkey = getConfig('unlockHotkey');
if (!hotkey) return;
if (e.code === hotkey || e.key === hotkey) {
if (isUnlockMode) toggleUnlockMode(false);
}
});
window.addEventListener('blur', () => {if (isUnlockMode) toggleUnlockMode(false);});// 窗口失焦时自动关闭,防止卡在开启状态
// 4. 文本处理与复制
async function copyToClipboard(text, html) {
try {
// 优先尝试构建 ClipboardItem 以保留样式 (如果不是纯文本)
if (html && typeof ClipboardItem !== 'undefined') {
// 简单的HTML包装
const htmlBlob = new Blob([html], { type: 'text/html' });
const textBlob = new Blob([text], { type: 'text/plain' });
const data = [new ClipboardItem({ 'text/html': htmlBlob, 'text/plain': textBlob })];
await navigator.clipboard.write(data);
} else {
// 回退到纯文本
await navigator.clipboard.writeText(text);
}
} catch (e) {
// 有些网页 JS 会执行 delete navigator.clipboard 或类似操作,或者抢夺焦点导致浏览器判定当前没有 User Activation,从而引发标准异步剪贴板 API(Clipboard API)failed,此时降级使用 GM 特权 API_GM_setClipboard
// GM_setClipboard 虽然在某些脚本管理器中一次只支持多种类型,但这里为了兼容更多脚本管理器一次只使用单个参数指定 mimetype
if (typeof GM_setClipboard === 'function') {
if (text) {
// 尝试写入纯文本(稳定)
GM_setClipboard(text, 'text');
} else {
GM_setClipboard(html, 'html');
}
}
}
}
// 显示 Toast
function showToast(msg) {
if (!getConfig('enableToast')) return;
let toast = shadowRoot.querySelector('.sc-toast');
if (!toast) {
toast = document.createElement('div');
toast.className = 'sc-toast';
shadowRoot.appendChild(toast);
}
toast.textContent = msg;
toast.classList.add('show');
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toast.classList.remove('show');
}, 1200);
}
// 智能获取网页背景亮度,返回 'light' 或 'dark' 以决定 UI 主题
// 逻辑:网页背景深 -> 返回 'light' (浅色UI);网页背景浅 -> 返回 'dark' (深色UI)
function getBestContrastTheme() {
const getBgColor = (el) => {
if (!el) return null;
const style = window.getComputedStyle(el);
return style.backgroundColor;
};
const getBrightness = (colorStr) => {
// 处理无效值或完全透明
if (!colorStr || colorStr === 'transparent' || colorStr === 'rgba(0, 0, 0, 0)') return null;
// 提取 RGB
const match = colorStr.match(/(\d+),\s*(\d+),\s*(\d+)/);
if (!match) return null;
const [r, g, b] = [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])];
// 计算亮度 (YIQ公式)
// 结果 0~255,越小越暗
return (r * 299 + g * 587 + b * 114) / 1000;
};
// 1. 优先检测 body 背景
let brightness = getBrightness(getBgColor(document.body));
// 2. 如果 body 透明,检测 html (documentElement) 背景
if (brightness === null) {
brightness = getBrightness(getBgColor(document.documentElement));
}
// 3. 如果 html 也透明,这通常意味着网页使用浏览器默认背景(通常是白色,但在深色模式插件下可能是黑色)
// 这里作为一个兜底,如果实在读不到背景色,则回退到读取系统/浏览器原本的深色模式偏好
if (brightness === null) {
const sysIsDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
// 系统暗 -> 网页可能暗 -> 用浅色UI
return sysIsDark ? 'theme-light-ui' : 'theme-dark-ui';
}
// 4. 根据亮度判断:亮度 < 128 (深色背景) -> 用 'theme-light-ui' (浅色按钮)
// 否则 -> 用 'theme-dark-ui' (深色按钮)
return brightness < 128 ? 'theme-light-ui' : 'theme-dark-ui';
}
// 渲染按钮 (支持 Copy/Search 模式 和 Paste 模式)
function renderButton(rect, mouseX, mouseY, text, html, mode = 'default', targetInput = null, isEditable = false) {
// 清理旧的
const oldBtn = shadowRoot.querySelector('.sc-container');
if (oldBtn) oldBtn.remove();
const container = document.createElement('div');
container.className = 'sc-container';
// 智能背景色检测与主题应用
const forceWB = getConfig('forceWhiteBlack');
if (forceWB) {
// 如果用户强制开启"强制浅色",则无视网页背景,始终应用浅色 UI
container.classList.add('theme-light-ui');
} else {
// 否则,根据网页实际背景色,自动应用高对比度的主题
const contrastTheme = getBestContrastTheme();
container.classList.add(contrastTheme);
}
const isCol = getConfig('buttonStyle') === 'col';
// ================
// 模式: 编辑模式 (Edit Mode)
// ================
if (isEditMode) {
// 1. 删除按钮
const delBtn = document.createElement('div');
delBtn.className = 'sc-btn';
delBtn.innerHTML = `<svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>`;
delBtn.title = t('btn_delete');
delBtn.onmousedown = (e) => { e.preventDefault(); e.stopPropagation(); };
delBtn.onclick = (e) => {
e.stopPropagation();
document.execCommand('delete');
hideUI();
};
container.appendChild(delBtn);
// 分割线
const div1 = document.createElement('div');
div1.className = isCol ? 'divider divider-h' : 'divider divider-v';
container.appendChild(div1);
// 2. 加粗按钮
const boldBtn = document.createElement('div');
boldBtn.className = 'sc-btn';
boldBtn.innerHTML = `<svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path></svg>`;
boldBtn.title = t('btn_bold');
boldBtn.onmousedown = (e) => { e.preventDefault(); e.stopPropagation(); };
boldBtn.onclick = (e) => {
e.stopPropagation();
document.execCommand('bold');
// 加粗通常想保留选区继续操作,这里不立即隐藏,或者延迟隐藏
// hideUI();
};
container.appendChild(boldBtn);
// 分割线
const div2 = document.createElement('div');
div2.className = isCol ? 'divider divider-h' : 'divider divider-v';
container.appendChild(div2);
// 3. 标记按钮 (黄色背景)
const highlightBtn = document.createElement('div');
highlightBtn.className = 'sc-btn';
highlightBtn.innerHTML = `<svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><path d="M15 3a3 3 0 0 1 3 3v6h-1"></path><path d="M10 6l4-3a3 3 0 1 1 3 3L7.5 15.5 6 18l2.5-1.5L18 7"></path></svg>`;
highlightBtn.title = t('btn_highlight');
highlightBtn.onmousedown = (e) => { e.preventDefault(); e.stopPropagation(); };
highlightBtn.onclick = (e) => {
e.stopPropagation();
// 使用 hiliteColor (部分浏览器用 backColor)
if (!document.execCommand('hiliteColor', false, 'yellow')) {
document.execCommand('backColor', false, 'yellow');
}
hideUI();
};
container.appendChild(highlightBtn);
}
else
// ================
// 模式 A: 默认模式
// ================
if (mode === 'default' || mode === PASTE_MODE_THREE_BTNS) {
// 1. 创建复制按钮
const copyBtn = document.createElement('div');
copyBtn.className = 'sc-btn';
copyBtn.innerHTML = `<svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`;
copyBtn.title = t('btn_copy');
copyBtn.onmousedown = (e) => { e.preventDefault(); e.stopPropagation(); };
copyBtn.onclick = async (e) => {
e.stopPropagation();
triggerSpringFestivalEffect(e.clientX, e.clientY, shadowRoot); // 触发春节特效 (传入鼠标点击坐标和shadowRoot)
const contentToCopy = getConfig('enableCache') ? (cachedSelection.text || text) : text;
const htmlToCopy = getConfig('enableCache') ? (cachedSelection.html || html) : html;
await copyToClipboard(contentToCopy, htmlToCopy);
// [新增] 写入闪电粘贴缓存 (8秒有效)
if (getConfig('enablePaste')) {
// 这里用 await 确保写入完成
await safeSetValue('smart_paste_cache', {
text: contentToCopy,
timestamp: Date.now()
});
}
showToast(getSpringFestivalToastText());
// 延迟50ms消失,人为增加视觉残影,避免立即消失让用户以为没有点到
setTimeout(hideUI, 50);
};
container.appendChild(copyBtn);
const isInInput = targetInput !== null; // 已由调用方传进来
// 2. 创建剪切按钮 (仅在编辑区显示)
if (isInInput && !isEditMode) {
const div = document.createElement('div');
div.className = isCol ? 'divider divider-h' : 'divider divider-v';
container.appendChild(div);
const cutBtn = document.createElement('div');
cutBtn.className = 'sc-btn';
// 剪刀 SVG 图标
cutBtn.innerHTML = `<svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><line x1="20" y1="4" x2="8.12" y2="15.88"></line><line x1="14.47" y1="14.48" x2="20" y2="20"></line><line x1="8.12" y1="8.12" x2="12" y2="12"></line></svg>`;
cutBtn.title = t('btn_cut');
cutBtn.onmousedown = (e) => { e.preventDefault(); e.stopPropagation(); };
cutBtn.onclick = async (e) => {
e.stopPropagation();
triggerSpringFestivalEffect(e.clientX, e.clientY, shadowRoot);
const contentToCopy = getConfig('enableCache') ? (cachedSelection.text || text) : text;
const htmlToCopy = getConfig('enableCache') ? (cachedSelection.html || html) : html;
// 尝试执行原生剪切,这样可以保留浏览器的撤销(Ctrl+Z)历史
try {
const success = document.execCommand('cut');
if (!success) {
throw new Error('execCommand failed');
}
} catch (err) {
// 如果原生剪切失败(极少见),则回退到:复制 -> 删除选区
await copyToClipboard(contentToCopy, htmlToCopy);
// 删除选区内容
const selection = window.getSelection();
if (selection.rangeCount > 0) {
selection.getRangeAt(0).deleteContents();
}
}
// 写入闪电粘贴缓存
if (getConfig('enablePaste')) {
await safeSetValue('smart_paste_cache', {
text: contentToCopy,
timestamp: Date.now()
});
}
setTimeout(hideUI, 35);
};
container.appendChild(cutBtn);
}
// 搜索按钮 (仅在非编辑区且字数较少时显示)
else if (!isInInput && !isEditMode && text.trim().length <= 32) {
const div = document.createElement('div');
div.className = isCol ? 'divider divider-h' : 'divider divider-v';
container.appendChild(div);
const searchBtn = document.createElement('div');
searchBtn.className = 'sc-btn';
searchBtn.innerHTML = `<svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>`;
searchBtn.title = t('btn_search');
searchBtn.onmousedown = (e) => { e.preventDefault(); e.stopPropagation(); };
searchBtn.onclick = (e) => {
e.stopPropagation();
const query = getConfig('enableCache') ? (cachedSelection.text || text) : text;
let engine = getConfig('searchEngine');
let url = SEARCH_ENGINES[engine] ? SEARCH_ENGINES[engine].url : (engine.includes('%s') ? engine : SEARCH_ENGINES['google'].url);
safeOpenTab(url.replace('%s', encodeURIComponent(query.trim())), { active: true });
setTimeout(hideUI, 50);
};
container.appendChild(searchBtn);
}
//锁链按钮逻辑
// 判定当前是否处于编辑状态 (输入框、文本域、富文本)
const activeEl = document.activeElement;
const isUserEditing = activeEl && (
(['INPUT', 'TEXTAREA'].includes(activeEl.tagName) && !activeEl.readOnly) ||
activeEl.isContentEditable ||
document.designMode === 'on'
);
// 只有在非输入框环境下才进行链接检测
if (!isUserEditing && !targetInput && mode !== PASTE_MODE_THREE_BTNS) {
const linkData = extractLinkFromText(text);
if (linkData) {
const div = document.createElement('div');
div.className = isCol ? 'divider divider-h' : 'divider divider-v';
container.appendChild(div);
const chainBtn = document.createElement('div');
chainBtn.className = 'sc-btn';
// CSS绘制锁链图标 (SVG Path)
chainBtn.innerHTML = `<svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`;
chainBtn.title = t('btn_open_link');
chainBtn.onmousedown = (e) => { e.preventDefault(); e.stopPropagation(); };
chainBtn.onclick = async (e) => {
e.stopPropagation();
// 网盘密码逻辑
let panPassword = null;
if (getConfig('enablePaste')) {
// 使用上一轮提供的 PAN_DOMAINS
const isPan = PAN_DOMAINS.some(d => linkData.host.includes(d));
if (isPan) {
panPassword = extractPanCode(text);
}
}
if (panPassword) {
// 存储交接数据,5秒内打开新页面有效
await safeSetValue('pan_paste_handover', {
url: linkData.url,
code: panPassword,
timestamp: Date.now()
});
showToast(`Password: ${panPassword}`); // 提示用户已提取到密码
}
safeOpenTab(linkData.url, { active: true });
hideUI();
};
container.appendChild(chainBtn);
}
}
// 检测是否需要显示“校正”按钮
// 检查当前语言是否为中文
const curLang = getConfig('language');
const isChineseEnv = curLang === 'zh-CN' || (curLang === 'auto' && navigator.language.startsWith('zh'));
// 检查是否有目标输入框
if (isChineseEnv && targetInput) {
// 预计算是否需要校正 (避免显示无效按钮)
const isInputType = targetInput.tagName === 'INPUT';
if (smartCorrectText(text, isInputType) !== null) {
const div = document.createElement('div');
div.className = isCol ? 'divider divider-h' : 'divider divider-v';
container.appendChild(div);
const correctBtn = document.createElement('div');
correctBtn.className = 'sc-btn';
// 使用 SVG 绘制
correctBtn.innerHTML = `<svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><path d="M9 15l2 2 4-4"></path></svg>`;
correctBtn.title = "校正"; // 由于校正功能只面向中文用户,所以这里直接硬编码title
correctBtn.onmousedown = (e) => { e.preventDefault(); e.stopPropagation(); };
correctBtn.onclick = (e) => {
e.stopPropagation();
handleTextCorrection(targetInput, text);
};
container.appendChild(correctBtn);
}
}
// 3. 若处于闪电粘贴三按钮模式,则再在复制和剪切按钮旁追加一个粘贴按钮
if (mode === PASTE_MODE_THREE_BTNS) {
const div = document.createElement('div');
div.className = isCol ? 'divider divider-h' : 'divider divider-v';
container.appendChild(div);
const pasteBtn = document.createElement('div');
pasteBtn.className = 'sc-btn';
pasteBtn.innerHTML = `<svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg>`;
pasteBtn.title = t('btn_paste');
pasteBtn.onmousedown = e => { e.preventDefault(); e.stopPropagation(); };
pasteBtn.onclick = async (e) => {
e.stopPropagation();
// [修改] 异步再次读取(防止缓存刚过期)或者直接传参
// 为了稳妥,重新读一次
const cache = await safeGetValue('smart_paste_cache', null);
if (cache && cache.text) {
// 由于划词时焦点仍在输入框,这里把 target 设成 document.activeElement 即可
performPaste(document.activeElement, cache.text);
await safeSetValue('smart_paste_cache', null);// 这样下次点击输入框就不会再出现粘贴按钮,直到你再次通过脚本复制新内容
}
hideUI();
};
container.appendChild(pasteBtn);
}
}
// ===============
// 模式 B: 粘贴模式 (闪电粘贴)
// ===============
else if (mode === 'paste') {
const pasteBtn = document.createElement('div');
pasteBtn.className = 'sc-btn';
// 使用相同风格的粘贴图标
pasteBtn.innerHTML = `<svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg>`;
pasteBtn.title = t('btn_paste');
pasteBtn.onmousedown = (e) => { e.preventDefault(); e.stopPropagation(); };
pasteBtn.onclick = async (e) => {
e.stopPropagation();
// [新增] 优先粘贴网盘密码
if (typeof sessionPanCode !== 'undefined' && sessionPanCode) {
performPaste(targetInput || document.activeElement, sessionPanCode);
showToast(t('toast_password_pasted'));
// 粘贴后是否销毁?需求说"缓存仅在当前标签页有效",未明确说粘贴一次就废弃
// 但为了体验,通常保留直到刷新,或者手动不销毁。
// 需求:"即使销毁缓存的密码" -> 意味着粘贴一次后销毁?
// "点击即可将提取到的...并且即使销毁缓存的密码" -> 应该是即时销毁
sessionPanCode = null;
hideUI();
return;
}
// 执行粘贴逻辑
performPaste(targetInput, text); // 这里的 text 参数其实已经是传进来的 cache.text 了,可以直接用
// 粘贴后异步清除缓存,防止重复出现粘贴按钮
await safeSetValue('smart_paste_cache', null);
hideUI();
};
container.appendChild(pasteBtn);
}
const btnCount = container.children.length;
container.setAttribute('data-btn-count', btnCount);
shadowRoot.appendChild(container);
// 计算位置 (通用逻辑)
container.style.left = '-9999px';
requestAnimationFrame(() => {
const btnRect = container.getBoundingClientRect();
const btnW = btnRect.width;
const btnH = btnRect.height;
const offset = getConfig('offset');
const viewportW = window.innerWidth;
const viewportH = window.innerHeight;
let targetX, targetY;
// 如果有 rect (Endchar模式 或 ContentEditable光标),优先跟随 rect
if (rect) {
// 如果 rect 对象包含了我们注入的方向信息 (来自 getSmartSelectionState)
const isBackward = rect.isBackward || false;
const isVertical = rect.isVertical || false;
if (isVertical) {
// === 垂直排版处理 (vertical-rl / vertical-lr) ===
// 简单处理:正向(下/左)放左侧,反向(上/右)放右侧
if (isBackward) {
targetX = rect.right + offset;
targetY = rect.top;
} else {
targetX = rect.left - btnW - offset;
targetY = rect.bottom - btnH;
}
} else {
// === 水平排版处理 (默认) ===
if (isBackward) {
// 反向选区 (光标在左/上):按钮显示在 Rect 的【正上方】
targetX = rect.left - (btnW / 2);
targetY = rect.top - btnH - offset;
} else {
// 正向选区 (光标在右/下):按钮显示在 Rect 的【正下方】
targetX = rect.right - (btnW / 2);
// 智能避让:如果底部空间不足,自动放到上方 (仅针对正向)
const spaceBelow = viewportH - rect.bottom;
if (spaceBelow < (btnH + offset + 20)) {
targetY = rect.top - btnH - offset;
} else {
targetY = rect.bottom + offset;
}
}
}
} else {
// Mouse 模式 或 Input 无 Rect 时的兜底
// [修改] 纵向逻辑: 锚点位于视口纵向中线之下,按钮显示在上方;否则显示在下方
if (mouseY > viewportH / 2) {
targetY = mouseY - btnH - offset;
} else {
targetY = mouseY + offset;
}
// [新增] 横向逻辑: 锚点位于视口横向中线右侧,按钮显示在左侧;否则(左侧或重合)显示在右侧
if (mouseX > viewportW / 2) {
targetX = mouseX - btnW - offset;
} else {
targetX = mouseX + offset;
}
}
// 边缘检测
const margin = 10;
targetX = Math.max(margin, Math.min(targetX, viewportW - btnW - margin));
targetY = Math.max(margin, Math.min(targetY, viewportH - btnH - margin));
container.style.left = `${targetX}px`;
container.style.top = `${targetY}px`;
container.classList.add('visible');
// 设置自动消失
const timeout = getConfig('timeout');
if (timeout > 0) {
if (uiTimer) clearTimeout(uiTimer);
uiTimer = setTimeout(hideUI, timeout);
}
});
}
function hideUI() {
const btn = shadowRoot && shadowRoot.querySelector('.sc-container');
if (btn) {
btn.classList.remove('visible');
setTimeout(() => {
if (btn && btn.parentNode) btn.remove();
}, 200);
}
cachedSelection = { text: '', html: '' };
}
// ===========
// 6. 事件监听 (Event Listeners)
// ===========
function handleSelectionMouseUp(e) {
if (hostElement && e.composedPath().includes(hostElement)) {return;} //如果点击的是脚本自身的UI内部,直接忽略,以防点击按钮时全局mouseup事件再次触发按钮重绘
if (!hostElement) initContainer();
if (isScrolling) return;
setTimeout(async () => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
hideUI();
return;
}
const text = selection.toString();
if (!text || text.trim().length === 0) {
hideUI();
return;
}
const range = selection.getRangeAt(0);
if (getConfig('enableCache')) {
const container = document.createElement('div');
container.appendChild(range.cloneContents());
cachedSelection = {
text: text,
html: container.innerHTML
};
}
let rect = null;
if (getConfig('positionMode') === 'endchar') {
const smartState = getSmartSelectionState(selection, e);// 使用新算法获取智能 Rect,传入 e (MouseEvent) 以便在框架销毁DOM节点时进行第三级降级定位
if (smartState) {
rect = smartState.rect;
// 将方向信息挂载到 rect 对象上,传递给 renderButton
// 这样做是为了避免修改 renderButton 的参数签名,保持兼容
if (rect) {
rect.isBackward = smartState.isBackward;
rect.isVertical = smartState.isVertical;
}
}
}
initContainer();
let cache = null;
if (getConfig('enablePaste')) {
cache = await safeGetValue('smart_paste_cache', null);
}
const cacheValid = cache && (Date.now() - cache.timestamp < 8000);
const target = document.activeElement;
const isInput = target && (
(['INPUT', 'TEXTAREA'].includes(target.tagName) && !target.disabled && !target.readOnly) ||
target.isContentEditable
);
const mode = (cacheValid && isInput) ? PASTE_MODE_THREE_BTNS : 'default';
renderButton(rect, e.clientX, e.clientY, text, cachedSelection.html || '', mode, isInput ? target : null, isInput);
}, 10);
}
// 监听 mousedown,如果点击非按钮区域,取消UI的timeout(准备隐藏)
function handleGlobalMouseDown(e) {
if (hostElement && e.composedPath().includes(hostElement)) {
// 点击了按钮内部,保持
} else {
// 点击了页面其他位置,虽然mouseup会触发hideUI,
// 但这里可以做一个预判,或者清空timer让其立即生效
const btn = shadowRoot && shadowRoot.querySelector('.sc-container');
if (btn) btn.classList.remove('visible'); // 视觉上立即消失
}
}
// 滚动与调整大小处理
const handleResizeOrScroll = () => {
if (!hostElement) return;
// 隐藏按钮
const btn = shadowRoot.querySelector('.sc-container');
if (btn && btn.classList.contains('visible')) {
btn.classList.remove('visible'); // 临时隐藏
isScrolling = true;
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
isScrolling = false;
// 停止滚动后,检查选区是否还存在
const selection = window.getSelection();
if (selection && selection.toString().trim().length > 0) {
// 重新定位
// 为了简化,这里触发一次模拟的逻辑,或者简单地重新获取位置
// 由于丢失了 mouseX/Y,如果原先是Mouse定位可能会有问题
// 所以这里强制尝试用 endchar 重新定位,如果不行则保持隐藏
// 实际上 Req 4 要求重新定位。
const range = selection.getRangeAt(0);
const rects = range.getClientRects();
if (rects.length > 0) {
const rect = rects[rects.length - 1];
// 传入模拟的 mouseX Y (rect center) 作为 fallback
renderButton(rect, rect.right, rect.top, selection.toString(), getConfig('enableCache')?cachedSelection.html:'');
}
}
}, 300); // debounce 300ms
}
};
function handleContextMenu(e) {
hideUI(); // 右键立即清除按钮
// 右键清除缓存
if (getConfig('enablePaste')) {
// 清除本地缓存
safeSetValue('smart_paste_cache', null);
// 清除网盘密码缓存
sessionPanCode = null;
// 清除网盘密码交接缓存
safeSetValue('pan_paste_handover', null);
// 可选:显示提示(可根据需要决定是否显示)
// showToast('粘贴缓存已清除');
}
}
function handleKeydownHideUI(e) {if (isUnlockMode) return;hideUI();} // 任意键按下立即无条件隐藏按钮,但超级划词模式下不隐藏按钮
// [新增] 智能文本校正核心算法
function smartCorrectText(text, isInputType) {
// 0. 基础判定
const hasHanzi = /[\u4e00-\u9fa5]/.test(text);
const hasCNPunct = /[,。:;?!“”‘’()【】《》]/.test(text);
const hasNum = /\d/.test(text);
// 判定生效条件
let activeRules = {
basic: hasHanzi, // 规范 1, 2, 5, 9 (依赖汉字)
punct: hasHanzi || hasCNPunct, // 规范 3, 8
unit: hasHanzi || hasCNPunct || hasNum, // 规范 4, 7
pureCN: hasHanzi && !/[a-zA-Z]/.test(text.replace(/[a-zA-Z]+(?=[%℃$])/, '')) // 规范 6 (排除单位后无英文字母)
};
if (!activeRules.basic && !activeRules.punct && !activeRules.unit) return null;
// 辅助:正则替换,跳过引号内的内容 ("..." 或 “...”)
// 使用 split 分割法:偶数索引为引号外,奇数索引为引号内
const applyRule = (txt, regex, replacement) => {
const parts = txt.split(/(".*?"|“.*?”)/g);
return parts.map((part, i) => {
if (i % 2 === 1) return part; // 引号内,保持原样
return part.replace(regex, replacement);
}).join('');
};
let result = text;
// --- 规范 9: 换行/删空判定 (优先级最高,先处理结构) ---
// 模式:汉字/句号 + 2空格 + 汉字/数字
if (activeRules.basic) {
const rule9Regex = /([\u4e00-\u9fa5。])(\s{2,})(?=[\u4e00-\u9fa5]|\d{1,3}(?:[、.]|\s))/g;
result = applyRule(result, rule9Regex, (match, p1, p2) => {
return p1 + (isInputType ? '' : '\n'); // Input删空格,Textarea换行
});
}
// --- 规范 6: 纯中文环境下的英文标点转中文 ---
if (activeRules.pureCN) {
// 句号特殊处理
const parts = result.split(/(".*?"|“.*?”)/g);
result = parts.map((part, i) => {
if (i % 2 === 1) return part;
let p = part;
// 3个及以上点 -> ……
p = p.replace(/\.{3,}/g, '……');
// 2个点 -> 。
p = p.replace(/\.{2}/g, '。');
// 单个点:两边是数字不改,否则改
p = p.replace(/(?<!\d)\.(?!\d)|(?<=\d)\.(?!\d)|(?<!\d)\.(?=\d)/g, '。');
// 其他标点映射
const map = {',':',', '?':'?', '!':'!', ':':':', ';':';', '(':'(', ')':')'};
p = p.replace(/[,?!:;()]/g, m => map[m]);
// 引号简单的成对替换逻辑比较复杂,这里仅处理明显情况,复杂情况交由后续规范
return p;
}).join('');
}
// --- 规范 1: 中英之间加空格 ---
if (activeRules.basic) {
result = applyRule(result, /([\u4e00-\u9fa5])([a-zA-Z])/g, '$1 $2');
result = applyRule(result, /([a-zA-Z])([\u4e00-\u9fa5])/g, '$1 $2');
}
// --- 规范 2: 中文与数字(含运算)加空格 ---
// 只有当选区包含明确的数学运算符 (+, *, /, =) 或 "等于" 时,才将 "-" 视为减号并加空格;否则将其视为连词符,不加空格。
if (activeRules.basic) {
// 1. 检查是否存在数学语境
const isMathContext = /[+*/=]|等于/.test(text);
// 2. 构建字符集
// 如果是数学语境,匹配 [\d+\-*/=] (注意 - 需要转义)
// 如果非数学语境,仅匹配 [\d] (数字)
const charSet = isMathContext ? '[\\d+\\-*/=]' : '[\\d]';
// 构造正则:中文前看/后看
// 解释:new RegExp 需要双重转义 \\
const regex1 = new RegExp(`([\\u4e00-\\u9fa5])(?=${charSet})`, 'g'); // 中文 + [数字/符号]
const regex2 = new RegExp(`(${charSet})(?=[\\u4e00-\\u9fa5])`, 'g'); // [数字/符号] + 中文
result = applyRule(result, regex1, '$1 ');
result = applyRule(result, regex2, '$1 ');
}
// --- 规范 3: 字符/数字与后方标点去空格 ---
if (activeRules.punct) {
result = applyRule(result, /([a-zA-Z0-9\u4e00-\u9fa5])\s+([,.:;?!,。:;?!、\])}()】【《》[({“^”‘^’"'])/g, '$1$2');
}
// --- 规范 4: 数字/字符与单位 (%, ℃, $) ---
if (activeRules.unit) {
// 数字 + 空格 + 单位 -> 去空格
result = applyRule(result, /(\d)\s+([%℃$])/g, '$1$2');
// 非空非数字 + 无空格 + 单位 -> 加空格
result = applyRule(result, /([^\s\d])([%℃$])/g, '$1 $2');
}
// --- 规范 5: 中文句号去重 ---
if (activeRules.basic) {
const parts = result.split(/(".*?"|“.*?”)/g);
result = parts.map((part, i) => {
if (i % 2 === 1) return part;
// >8个: 不改 (忽略)
// 3-8个: ……
part = part.replace(/。{3,8}/g, '……');
// 2个: 。
part = part.replace(/。{2}/g, '。');
return part;
}).join('');
}
// --- 规范 7: 数字间中文冒号转英文 ---
if (activeRules.unit) {
result = applyRule(result, /(\d)\s*:\s*(\d)/g, '$1:$2');
}
// --- 规范 8: 双引号修正 (仅当只有一对时) ---
if (activeRules.punct) {
const quoteCount = (result.match(/[“”]/g) || []).length;
if (quoteCount === 2) {
let qIndex = 0;
result = result.replace(/[“”]/g, () => {
qIndex++;
return qIndex === 1 ? '“' : '”';
});
}
}
return result === text ? null : result;
}
// [新增] 执行校正操作
async function handleTextCorrection(target, originalText) {
const isInput = target.tagName === 'INPUT';
const newText = smartCorrectText(originalText, isInput);
if (!newText) {
showToast('无需校正');
return;
}
// 尝试写入
if (document.execCommand && typeof document.execCommand === 'function') {
try {
target.focus();
// 选中当前选区 (因为点击按钮可能丢失了部分焦点状态,或者需要全选替换选中部分)
// 实际上 PicKit 的逻辑是基于 selection 的,这里直接执行 insertText 会替换选区
document.execCommand('insertText', false, newText);
} catch (e) {
performPaste(target, newText); // 降级使用 paste 逻辑
}
} else {
performPaste(target, newText);
}
showToast('文本已校正');
hideUI();
}
// [新增] 执行粘贴的核心逻辑
function performPaste(target, text) {
if (!target) return;
target.focus();
// 策略 1: document.execCommand (保留撤销能力,最稳妥)
try {
const success = document.execCommand('insertText', false, text);
if (success) {
showToast(t('toast_pasted'));
return;
}
} catch (e) {
// 策略 2: 直接赋值 + 触发事件 (兼容 Vue/React)
try {
// 针对 ContentEditable
if (target.isContentEditable) {
// 简单的 HTML 插入或 Text 插入
const sel = window.getSelection();
if (sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
range.deleteContents();
range.insertNode(document.createTextNode(text));
range.collapse(false); // 光标后移
} else {
target.innerText += text;
}
} else {
// 针对 Input / Textarea
// 拼接字符串 (防止覆盖原有内容)
const start = target.selectionStart || 0;
const end = target.selectionEnd || 0;
const oldVal = target.value;
const newVal = oldVal.slice(0, start) + text + oldVal.slice(end);
// 获取 Prototype Setter
// 必须区分 Input 和 TextArea
let proto = window.HTMLInputElement.prototype;
if (target.tagName === 'TEXTAREA') {
proto = window.HTMLTextAreaElement.prototype;
}
const nativeValueSetter = Object.getOwnPropertyDescriptor(proto, "value").set;
// 执行赋值
if (nativeValueSetter && nativeValueSetter.call) {
nativeValueSetter.call(target, newVal);
} else {
target.value = newVal;
}
// 触发事件通知框架
target.dispatchEvent(new Event('input', { bubbles: true }));
target.dispatchEvent(new Event('change', { bubbles: true }));
// 恢复光标(移动到粘贴内容之后)
const newCursorPos = start + text.length;
target.setSelectionRange(newCursorPos, newCursorPos);
}
showToast(t('toast_paste_compat'));
} catch (e) {
// console.error('Paste failed', e);
showToast(t('toast_paste_fail'));
}
}
}
function handleInputPasteMouseUp(e) {
if (!getConfig('enablePaste')) return;
const target = e.target;
const isInput = (['INPUT', 'TEXTAREA'].includes(target.tagName) && !target.disabled && !target.readOnly) || target.isContentEditable;
if (!isInput) return;
setTimeout(async () => { // 延迟执行,确保在 handleSelectionMouseUp 之后运行 (后者通常延迟10ms,我们用20ms覆盖它)
// 如果有网盘提取的密码 (优先级高于普通缓存)
if (sessionPanCode) {
initContainer(); // 确保容器存在
// 计算输入框位置
const rect = target.getBoundingClientRect();
// 渲染特殊的粘贴按钮
renderButton(rect, e.clientX, e.clientY, '', '', 'paste');
// 劫持该按钮的点击事件 (通过修改 renderButton 里的逻辑比较复杂,
// 建议在这里通过 DOM 操作覆盖 onclick,或者在 renderButton 的 'paste' 模式里处理)
// 更简单的做法是:让 renderButton 的 paste 模式优先读取 sessionPanCode
return;
}
// 1. 获取剪贴板缓存
const cache = await safeGetValue('smart_paste_cache', null);
if (!cache || !cache.text) return; // 如果没有有效缓存,通常不做处理(交由主划词逻辑),但由于主逻辑对 input 支持不佳,这里可以不做任何操作,或者仅仅依靠缓存存在才触发
if (Date.now() - cache.timestamp > 8000) { // 缓存过期检查 (8秒)
await safeSetValue('smart_paste_cache', null);
return;
}
// 2. 核心修正:检测输入框内是否真正选中了文本
let selectedText = '';
let hasSelection = false;
if (['INPUT', 'TEXTAREA'].includes(target.tagName)) {
const start = target.selectionStart;
const end = target.selectionEnd;
if (typeof start === 'number' && typeof end === 'number' && start !== end) {
selectedText = target.value.substring(start, end);
hasSelection = true;
}
} else if (target.isContentEditable) {
// 对于 contentEditable,window.getSelection 通常有效
const sel = window.getSelection();
if (sel && sel.rangeCount > 0 && !sel.isCollapsed) {
selectedText = sel.toString();
hasSelection = true;
}
}
// 3. 决定模式
// 有选区,如果选中了无意义字符(单个空格或中文逗号),视作用户想要“替换”该字符,仍使用单按钮粘贴模式,否则使用三按钮模式 (复制当前选区文本 + 搜索当前选区文本 + 粘贴缓存文本)
// 无选区 (仅仅是点击)则直接使用单按钮模式 (粘贴缓存文本)
const isReplaceIntent = selectedText === ' ' || selectedText === ',';
const mode = (hasSelection && !isReplaceIntent) ? PASTE_MODE_THREE_BTNS : 'paste';
// 4. 准备参数
// renderButton 的 text 参数:
// - 在 'paste' 模式下,它代表"要粘贴的内容" (即 cache.text)
// - 在 'copy-search-paste' 模式下,它代表"要复制/搜索的内容" (即 selectedText)
const textArg = (mode === 'paste') ? cache.text : selectedText;
// 5. 计算位置 (输入框内通常无法获取精确光标 Rect,降级使用鼠标位置)
let rect = null;
if (target.isContentEditable && hasSelection) {
const sel = window.getSelection();
if (sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
const rects = range.getClientRects();
if (rects.length > 0) {
rect = rects[rects.length - 1];
}
}
}
if (!hostElement) initContainer();
renderButton(rect, e.clientX, e.clientY, textArg, '', mode, target);
}, 20);
}
// ====================
// 7. 元素屏蔽子系统 (Element Blocker Subsystem)
// ================
let pickerOverlay = null;
let pickerHandler = null;
let pickerClickHandler = null;
let pickerEscHandler = null;
let pickerRightClickHandler = null;
// 自动应用已保存的规则
function applySavedBlockingRules() {
const rules = configCache['blocked_elements'] || {};
const domain = location.hostname;
if (rules[domain] && Array.isArray(rules[domain])) {
// 将选择器合并为一条CSS规则
const cssText = rules[domain].join(', ') + ' { display: none !important; visibility: hidden !important; opacity: 0 !important; pointer-events: none !important; }';
GM_addStyle(cssText);
// console.log('Smart Copy: Applied blocking rules for', rules[domain]);
}
}
// 激活拾取模式
function activateElementPicker() {
// 如果已经在运行,先清理
if (pickerOverlay) disablePicker();
showToast(t('picker_active'));
// 创建高亮遮罩
pickerOverlay = document.createElement('div');
pickerOverlay.style.all = 'initial';
pickerOverlay.style.position = 'fixed';
pickerOverlay.style.pointerEvents = 'none';
pickerOverlay.style.border = '2px solid #ff0000';
pickerOverlay.style.background = 'rgba(255, 0, 0, 0.1)';
pickerOverlay.style.zIndex = '2147483646'; // 略低于脚本主容器
pickerOverlay.style.transition = 'all 0.1s ease';
pickerOverlay.style.display = 'none';
document.body.appendChild(pickerOverlay);
// 鼠标移动处理
pickerHandler = (e) => {
const target = e.target;
// 忽略脚本自身UI 和 遮罩本身
if (target === hostElement || hostElement.contains(target) || target === pickerOverlay) return;
const rect = target.getBoundingClientRect();
pickerOverlay.style.display = 'block';
pickerOverlay.style.top = rect.top + 'px';
pickerOverlay.style.left = rect.left + 'px';
pickerOverlay.style.width = rect.width + 'px';
pickerOverlay.style.height = rect.height + 'px';
};
// 点击处理
pickerClickHandler = (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const target = e.target;
// 防自杀
if (target === hostElement || hostElement.contains(target)) {
showToast(t('picker_cant_block_self'));
return;
}
const selector = generateCssSelector(target);
if (confirm(t('picker_confirm', selector) + `\n(Domain: ${location.hostname})`)) {
saveBlockRule(selector);
// 立即隐藏
target.style.display = 'none';
showToast(t('picker_saved'));
disablePicker();
}
};
// ESC 退出
pickerEscHandler = (e) => {
if (e.key === 'Escape') {
disablePicker();
showToast(t('picker_exit'));
}
};
// 右键 取消
pickerRightClickHandler = (e) => {
e.preventDefault();
e.stopPropagation();
disablePicker();
};
document.addEventListener('contextmenu', pickerRightClickHandler, true);
document.addEventListener('mousemove', pickerHandler, true);
document.addEventListener('click', pickerClickHandler, true);
document.addEventListener('keydown', pickerEscHandler, true);
}
// 退出拾取模式
function disablePicker() {
if (pickerOverlay) {
pickerOverlay.remove();
pickerOverlay = null;
}
document.removeEventListener('mousemove', pickerHandler, true);
document.removeEventListener('click', pickerClickHandler, true);
document.removeEventListener('keydown', pickerEscHandler, true);
document.removeEventListener('contextmenu', pickerRightClickHandler, true);
pickerRightClickHandler = null;
}
// 生成尽可能短且唯一的 CSS 选择器
function generateCssSelector(el) {
if (el.id) return '#' + CSS.escape(el.id);
const tagName = el.tagName.toLowerCase();
let selector = tagName;
// 尝试使用 class
if (el.className && typeof el.className === 'string' && el.className.trim().length > 0) {
// 过滤掉可能动态变化的随机类名(简单启发式:过长的通常是乱码,这里暂不过滤,全取)
const classes = el.className.trim().split(/\s+/);
// 拼接前两个类名通常够用了,避免选择器太长
classes.slice(0, 3).forEach(c => {
selector += '.' + CSS.escape(c);
});
}
// 如果没有ID也没有Class,或者只有TagName,为了避免误伤全站标签,尝试加父级
if (selector === tagName) {
if (el.parentElement && el.parentElement !== document.body) {
return generateCssSelector(el.parentElement) + ' > ' + tagName;
}
}
return selector;
}
// 保存规则到 GM 存储
function saveBlockRule(selector) {
const rules = configCache['blocked_elements'] || {};
const domain = location.hostname;
if (!rules[domain]) rules[domain] = [];
if (!rules[domain].includes(selector)) {
rules[domain].push(selector);
// 更新缓存
configCache['blocked_elements'] = rules;
// 异步保存
safeSetValue('blocked_elements', rules);
}
}
// 启动时应用规则
applySavedBlockingRules();
// ===============
// 烟花粒子特效模块
// ===============
// 春节/圣诞 彩蛋逻辑判断
function getFestivalType() {
const now = new Date();
// 1. 尝试检测农历 (Chinese Lunar)
try {
const formatter = new Intl.DateTimeFormat("zh-CN-u-ca-chinese", { month: "numeric", day: "numeric" });
// 关键:检查浏览器是否真的支持并使用了农历,否则 Intl 会静默回退到公历
if (formatter.resolvedOptions().calendar === 'chinese') {
const parts = formatter.formatToParts(now);
const monthPart = parts.find(p => p.type === 'month').value;
const dayPart = parts.find(p => p.type === 'day').value;
// 宽松解析月份:兼容 "正月"、"1月"、"1"
const isLunarJan = monthPart.includes('正') || monthPart.replace(/[^\d]/g, '') === '1';
// 解析日期
const day = parseInt(dayPart.replace(/[^\d]/g, ''));
// 如果能读到农历,则规则为:仅在农历正月初一生效
if (isLunarJan && day === 1) return 'CNY';
return 'NONE'; // 既然支持农历但不是初一,则强制不生效(不回退到圣诞判断)
}
} catch (e) {
// 忽略错误,进入下方回退逻辑
}
// 2. 回退逻辑:如果不支持农历,则判断是否为公历 12月25日
if (now.getMonth() === 11 && now.getDate() === 25) {
return 'XMAS';
}
return 'NONE';
}
// 触发烟花特效
function triggerSpringFestivalEffect(x, y, shadowRoot) {
const festival = getFestivalType();
if (festival === 'NONE') return;
// 根据节日配置颜色
let colors = [];
if (festival === 'CNY') {
// 春节:红、金、橙、紫红
colors = ['#FF0000', '#FFD700', '#FF4500', '#DC143C', '#FFFF00'];
} else if (festival === 'XMAS') {
// 圣诞:红、绿、金、白
colors = ['#FF0000', '#228B22', '#FFD700', '#FFFFFF', '#006400'];
}
const activeColors = [];
for (let i = 0; i < 3; i++) {
activeColors.push(colors[Math.floor(Math.random() * colors.length)]);
}
const particleCount = 20 + Math.floor(Math.random() * 21);
const fragment = document.createDocumentFragment();
for (let i = 0; i < particleCount; i++) {
const p = document.createElement('div');
const size = 4 + Math.random() * 3;
const color = activeColors[Math.floor(Math.random() * activeColors.length)];
p.style.cssText = `
position: fixed;
left: ${x}px;
top: ${y}px;
width: ${size}px;
height: ${size}px;
background-color: ${color};
border-radius: 50%;
pointer-events: none;
z-index: 2147483647;
box-shadow: 0 0 6px ${color};
will-change: transform, opacity;
`;
const angle = Math.random() * Math.PI * 2;
const speed = 2 + Math.random() * 5;
let vx = Math.cos(angle) * speed;
let vy = Math.sin(angle) * speed;
let opacity = 1.0;
const gravity = 0.2 + Math.random() * 0.1;
const friction = 0.96;
const decay = 0.01 + Math.random() * 0.02;
let posX = x;
let posY = y;
const animate = () => {
if (opacity <= 0) {
p.remove();
return;
}
vx *= friction;
vy *= friction;
vy += gravity;
posX += vx;
posY += vy;
opacity -= decay;
p.style.transform = `translate(${posX - x}px, ${posY - y}px)`;
p.style.opacity = opacity;
requestAnimationFrame(animate);
};
fragment.appendChild(p);
requestAnimationFrame(animate);
}
shadowRoot.appendChild(fragment);
}
// 获取 Toast 提示文案
function getSpringFestivalToastText() {
const festival = getFestivalType();
if (festival === 'CNY') {
return t('festival_cny');
} else if (festival === 'XMAS') {
return t('festival_xmas');
}
return t('toast_copied');
}
// 9. 码字防丢子系统 (Input Recovery Subsystem)
let inputDebounceTimer = null;
// 获取用于缓存的 URL Key
function getRecoveryUrlKey() {
const mode = getConfig('inputRecoveryMode');
// 禁用功能
if (mode === 'off') return null;
// 启用为严格模式:整段 URL 完全匹配
if (mode === 'strict') return location.href;
// 启用为宽松模式:只要出现 ? 就把后面的内容全部扔掉(粗略地剔除跟踪参数)
const raw = location.href;
const qMark = raw.indexOf('?');
return qMark === -1 ? raw : raw.slice(0, qMark);
}
// 生成元素的唯一标识符 (这是复用或简化版本)
function getRecoverySelector(el) {
if (el.id) return '#' + CSS.escape(el.id);
if (el.name) return el.tagName.toLowerCase() + `[name="${CSS.escape(el.name)}"]`;
// 生成基于路径的简易选择器
let path = [];
let curr = el;
while (curr && curr !== document.body && curr !== document.documentElement) {
let tag = curr.tagName.toLowerCase();
let index = 1;
let sibling = curr.previousElementSibling;
while (sibling) {
if (sibling.tagName === curr.tagName) index++;
sibling = sibling.previousElementSibling;
}
path.unshift(`${tag}:nth-of-type(${index})`);
curr = curr.parentElement;
}
return path.join(' > ');
}
// 执行保存逻辑
async function handleInputSave(e) {
const target = e.target;
if (target.dataset.tmScRestoring === 'true') return;// [新增] 如果该元素正在被脚本恢复数据,则忽略此次 Input 事件,避免将刚恢复的文本再次存入缓存
const mode = getConfig('inputRecoveryMode');
if (mode === 'off') return;
// 仅针对文本类输入框
if (!['TEXTAREA', 'INPUT'].includes(target.tagName)) return;
if (target.tagName === 'INPUT' && !['text', 'search', 'email', 'url', 'tel', 'number'].includes(target.type)) return;
// 这里的 value 是用户当前的输入
const val = target.value;
const selector = getRecoverySelector(target);
const urlKey = getRecoveryUrlKey();
if (!urlKey) return;
if (inputDebounceTimer) clearTimeout(inputDebounceTimer);
inputDebounceTimer = setTimeout(async () => {
const cache = await safeGetValue('tm_input_recovery_cache', {});
if (!cache[urlKey]) cache[urlKey] = {};
if (!val || val.trim() === '') {
// 如果值为空(用户主动删除或JS清空),则从缓存移除
delete cache[urlKey][selector];
// 如果该URL下没数据了,清理URL Key
if (Object.keys(cache[urlKey]).length === 0) delete cache[urlKey];
} else {
// 更新缓存
cache[urlKey][selector] = {
text: val,
ts: Date.now()
};
}
// 写入存储
await safeSetValue('tm_input_recovery_cache', cache);
}, 500); // 500ms 防抖
}
// 表单提交时主动清除缓存
async function handleFormSubmit(e) {
const mode = getConfig('inputRecoveryMode');
if (mode === 'off') return;
// 尝试找到被提交表单内的所有输入框并清除其缓存
const form = e.target;
if (!form || form.tagName !== 'FORM') return;
const inputs = form.querySelectorAll('input, textarea');
if (inputs.length === 0) return;
const cache = await safeGetValue('tm_input_recovery_cache', {});
const urlKey = getRecoveryUrlKey();
if (!cache[urlKey]) return;
let modified = false;
inputs.forEach(el => {
const sel = getRecoverySelector(el);
if (cache[urlKey][sel]) {
delete cache[urlKey][sel];
modified = true;
}
});
if (modified) {
if (Object.keys(cache[urlKey]).length === 0) delete cache[urlKey];
await safeSetValue('tm_input_recovery_cache', cache);
}
}
// 恢复文本逻辑
async function restoreInputData() {
const mode = getConfig('inputRecoveryMode');
if (mode === 'off') return;
const urlKey = getRecoveryUrlKey();
const cache = await safeGetValue('tm_input_recovery_cache', {});
const pageData = cache[urlKey];
if (!pageData) return;
// 遍历缓存的选择器
Object.keys(pageData).forEach(selector => {
const entry = pageData[selector];
// 缓存有效期 24小时,超过则忽略
if (Date.now() - entry.ts > 24 * 60 * 60 * 1000) return;
const el = document.querySelector(selector);
// 只有当元素存在,且当前值为空(防止覆盖用户刚输入的内容或浏览器自带填充)时才恢复
if (el && (!el.value || el.value.trim() === '')) {
// 模拟 React/Vue 的原生 Setter 逻辑 (复用 performPaste 的一部分逻辑)
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value"
).set;
const nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value"
).set;
const setter = el.tagName === 'INPUT' ? nativeInputValueSetter : nativeTextAreaValueSetter;
if (setter && setter.call) {
setter.call(el, entry.text);
} else {
el.value = entry.text;
}
// 触发事件以通知框架
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
// 视觉反馈(可选:背景微闪一下表示已恢复)
const originalBg = el.style.backgroundColor;
el.style.transition = 'background-color 0.5s';
el.style.backgroundColor = 'rgba(0, 255, 0, 0.1)';
setTimeout(() => {
el.style.backgroundColor = originalBg;
}, 1000);
}
});
}
// ============
// 8. 启动引导 (Bootstrap)
// ============
(async function main() {
try {
// 1. 等待配置加载
await initConfiguration();
// 2. 配置加载完后,再注册菜单 (这样菜单里的 getConfig 才能读到正确的值)
registerMenus();
// 3. 应用屏蔽规则
applySavedBlockingRules();
// 4. unlock mode 下,智能拦截 Ctrl+滚轮
const handleWheelZoom = (e) => {
// 核心优化:
// 只有当 e.ctrlKey 为真(按下了Ctrl) 且 脚本判定已进入解锁模式(isUnlockMode为true,即按下了指定的 ControlLeft/Right)时才拦截
// 如果你按的是另一侧的 Ctrl,isUnlockMode 会是 false,代码将在此处 return,从而保留原生缩放功能
if (!e.ctrlKey || !isUnlockMode) return;
const hotkey = getConfig('unlockHotkey') || '';
// 双重校验:确保当前配置的解锁键确实是 Control 系列 (防止配置为 Alt 时误拦截 Ctrl 滚轮)
const isCtrlConfigured = hotkey.includes('Control') || hotkey.toLowerCase() === 'ctrl';
if (isCtrlConfigured) {
// 阻止原生缩放
e.preventDefault();
e.stopPropagation();
// 手动执行垂直滚动
window.scrollBy({
top: e.deltaY,
behavior: 'auto'
});
}
};
// 必须设置 passive: false 才能阻止默认行为,capture: true 保证最先捕获
window.addEventListener('wheel', handleWheelZoom, { passive: false, capture: true });
// 5. 统一注册所有事件监听器 (防止配置没读完就触发划词)
document.addEventListener('mouseup', handleSelectionMouseUp, false);
document.addEventListener('mouseup', handleInputPasteMouseUp, true); // capture 阶段
document.addEventListener('mousedown', handleGlobalMouseDown, false);
document.addEventListener('contextmenu', handleContextMenu, true);
window.addEventListener('scroll', handleResizeOrScroll, { passive: true });
window.addEventListener('resize', handleResizeOrScroll, { passive: true });
document.addEventListener('keydown', handleKeydownHideUI, true);
// 6.拖拽预览事件监听
// 仅在主窗口生效,防止预览弹窗内部递归触发
if (window.name !== PREVIEW_WIN_NAME) {
document.addEventListener('dragstart', handleLinkDragStart, false);
document.addEventListener('dragend', handleLinkDragEnd, false);
}
// 7.启动码字防丢监听
document.addEventListener('input', handleInputSave, true);
document.addEventListener('submit', handleFormSubmit, true); // 监听表单提交
// 延迟一点时间恢复数据,确保页面框架(如Vue/React)已挂载 DOM
if (document.readyState === 'complete') {
setTimeout(restoreInputData, 500);
} else {
window.addEventListener('load', () => setTimeout(restoreInputData, 500));
}
// 8.检查是否有来自网盘链接的密码交接
// 放在 main 函数的 try-catch 块内部靠后的位置,或者 restoreInputData 附近
const checkPanHandover = async () => {
if (!getConfig('enablePaste')) return;
const handover = await safeGetValue('pan_paste_handover', null);
if (handover && handover.code) {
// 检查时间戳(15秒内有效,防止旧数据干扰)
if (Date.now() - handover.timestamp < 15000) {
// 简单的URL匹配:如果当前URL包含了提取时的URL关键部分
// 由于跳转等原因,只比对 host 或部分 path
// 这里做宽松匹配:如果当前页面的URL包含 handover.url 的 host 部分
try {
const currentUrl = window.location.href;
const targetUrlObj = new URL(handover.url); // 如果 handover.url 不规范可能会报错,try-catch捕获
if (currentUrl.includes(targetUrlObj.host)) {
sessionPanCode = handover.code;
// 销毁存储中的密码,保证一次性使用且不污染剪贴板
safeSetValue('pan_paste_handover', null);
// 显示提示
showToast(`${t('btn_paste') || 'Paste'} Code: ${sessionPanCode}`);
}
} catch(e) {}
}
}
};
// 页面加载完成后稍微延迟执行检查
setTimeout(checkPanHandover, 300);
// 注意:原本你的代码里事件监听是散落在各处的。
// 为了安全起见,你可以把原本的 document.addEventListener('mouseup', ...)
// 包裹在一个 function startEventListeners() {} 中,然后在这里调用它。
} catch (e) {
//console.error('Smart Copy 启动失败:', e);
}
})();
})();