|
4718 | 4718 | return { start, stop }; |
4719 | 4719 | })(); |
4720 | 4720 |
|
| 4721 | + /** |
| 4722 | + * CSS 选择器生成工具 |
| 4723 | + * 提供简易模式和全路径模式 |
| 4724 | + */ |
| 4725 | + |
| 4726 | + /** |
| 4727 | + * 检查选择器在文档中是否唯一 |
| 4728 | + * @param {string} selector |
| 4729 | + * @returns {boolean} |
| 4730 | + */ |
| 4731 | + function isUnique(selector) { |
| 4732 | + try { |
| 4733 | + return document.querySelectorAll(selector).length === 1; |
| 4734 | + } catch (e) { |
| 4735 | + return false; |
| 4736 | + } |
| 4737 | + } |
| 4738 | + |
| 4739 | + /** |
| 4740 | + * 转义 CSS 特殊字符 |
| 4741 | + * @param {string} str |
| 4742 | + * @returns {string} |
| 4743 | + */ |
| 4744 | + function escape(str) { |
| 4745 | + if (typeof CSS !== 'undefined' && CSS.escape) { |
| 4746 | + return CSS.escape(str); |
| 4747 | + } |
| 4748 | + return str.replace(/([!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~])/g, '\\$1'); |
| 4749 | + } |
| 4750 | + |
| 4751 | + /** |
| 4752 | + * 生成全路径选择器 (原有逻辑优化) |
| 4753 | + * 倾向于使用层级结构: div > div:nth-of-type(2) > span |
| 4754 | + * @param {HTMLElement} el |
| 4755 | + * @returns {string} |
| 4756 | + */ |
| 4757 | + function generateFullPathSelector(el) { |
| 4758 | + if (el.id) return `#${escape(el.id)}`; |
| 4759 | + |
| 4760 | + const path = []; |
| 4761 | + let current = el; |
| 4762 | + |
| 4763 | + while (current && current.nodeType === Node.ELEMENT_NODE) { |
| 4764 | + let selector = current.nodeName.toLowerCase(); |
| 4765 | + |
| 4766 | + if (current.id) { |
| 4767 | + selector = '#' + escape(current.id); |
| 4768 | + path.unshift(selector); |
| 4769 | + break; |
| 4770 | + } else { |
| 4771 | + let sib = current, nth = 1; |
| 4772 | + while (sib = sib.previousElementSibling) { |
| 4773 | + if (sib.nodeName.toLowerCase() == selector) |
| 4774 | + nth++; |
| 4775 | + } |
| 4776 | + if (nth != 1) |
| 4777 | + selector += ":nth-of-type("+nth+")"; |
| 4778 | + } |
| 4779 | + path.unshift(selector); |
| 4780 | + current = current.parentNode; |
| 4781 | + } |
| 4782 | + return path.join(" > "); |
| 4783 | + } |
| 4784 | + |
| 4785 | + /** |
| 4786 | + * 生成简易选择器 |
| 4787 | + * 优先使用 ID > 唯一Class > 唯一Tag > Tag+Class > 组合Class > 属性 > 短路径 |
| 4788 | + * @param {HTMLElement} el |
| 4789 | + * @returns {string} |
| 4790 | + */ |
| 4791 | + function generateSimpleSelector(el) { |
| 4792 | + const tagName = el.tagName.toLowerCase(); |
| 4793 | + |
| 4794 | + // 1. ID |
| 4795 | + if (el.id) { |
| 4796 | + const selector = `#${escape(el.id)}`; |
| 4797 | + if (isUnique(selector)) return selector; |
| 4798 | + } |
| 4799 | + |
| 4800 | + // 2. Class handling (improved for SVG support and combinations) |
| 4801 | + const classAttr = el.getAttribute('class'); |
| 4802 | + if (classAttr) { |
| 4803 | + const classes = classAttr.split(/\s+/).filter(c => c.trim()); |
| 4804 | + |
| 4805 | + // 2.1 Single Class |
| 4806 | + for (const cls of classes) { |
| 4807 | + const selector = `.${escape(cls)}`; |
| 4808 | + if (isUnique(selector)) return selector; |
| 4809 | + |
| 4810 | + // Tag + Single Class |
| 4811 | + const tagSelector = `${tagName}${selector}`; |
| 4812 | + if (isUnique(tagSelector)) return tagSelector; |
| 4813 | + } |
| 4814 | + |
| 4815 | + // 2.2 Two Classes Combination |
| 4816 | + if (classes.length >= 2) { |
| 4817 | + for (let i = 0; i < classes.length; i++) { |
| 4818 | + for (let j = i + 1; j < classes.length; j++) { |
| 4819 | + const selector = `.${escape(classes[i])}.${escape(classes[j])}`; |
| 4820 | + if (isUnique(selector)) return selector; |
| 4821 | + |
| 4822 | + const tagSelector = `${tagName}${selector}`; |
| 4823 | + if (isUnique(tagSelector)) return tagSelector; |
| 4824 | + } |
| 4825 | + } |
| 4826 | + } |
| 4827 | + |
| 4828 | + // 2.3 All Classes (if more than 2) |
| 4829 | + if (classes.length > 2) { |
| 4830 | + const comboSelector = `.${classes.map(escape).join('.')}`; |
| 4831 | + if (isUnique(comboSelector)) return comboSelector; |
| 4832 | + const tagComboSelector = `${tagName}${comboSelector}`; |
| 4833 | + if (isUnique(tagComboSelector)) return tagComboSelector; |
| 4834 | + } |
| 4835 | + } |
| 4836 | + |
| 4837 | + // 3. 唯一 Tag |
| 4838 | + if (isUnique(tagName)) return tagName; |
| 4839 | + |
| 4840 | + // 4. 常见属性 (name, type, alt, title, aria-label) |
| 4841 | + const attrs = ['name', 'type', 'alt', 'title', 'aria-label', 'placeholder', 'data-id', 'data-test-id']; |
| 4842 | + for (const attr of attrs) { |
| 4843 | + const val = el.getAttribute(attr); |
| 4844 | + if (val) { |
| 4845 | + const selector = `${tagName}[${attr}="${escape(val)}"]`; |
| 4846 | + if (isUnique(selector)) return selector; |
| 4847 | + } |
| 4848 | + } |
| 4849 | + |
| 4850 | + // 5. 如果是链接,尝试 href |
| 4851 | + if (tagName === 'a' && el.href) { |
| 4852 | + const href = el.getAttribute('href'); |
| 4853 | + if (href) { |
| 4854 | + const selector = `a[href="${escape(href)}"]`; |
| 4855 | + if (isUnique(selector)) return selector; |
| 4856 | + } |
| 4857 | + } |
| 4858 | + |
| 4859 | + // 6. 降级到全路径 |
| 4860 | + return generateFullPathSelector(el); |
| 4861 | + } |
| 4862 | + |
4721 | 4863 | const ElementPicker = (() => { |
4722 | 4864 | let active = false; |
4723 | 4865 | let overlay = null; |
|
4841 | 4983 |
|
4842 | 4984 | // 生成唯一的 CSS 选择器 |
4843 | 4985 | function generateSelector(el) { |
4844 | | - if (el.id) return `#${el.id}`; |
4845 | | - |
4846 | | - const path = []; |
4847 | | - let current = el; |
4848 | | - |
4849 | | - while (current && current.nodeType === Node.ELEMENT_NODE) { |
4850 | | - let selector = current.nodeName.toLowerCase(); |
4851 | | - |
4852 | | - if (current.id) { |
4853 | | - selector = '#' + current.id; |
4854 | | - path.unshift(selector); |
4855 | | - break; |
4856 | | - } else { |
4857 | | - let sib = current, nth = 1; |
4858 | | - while (sib = sib.previousElementSibling) { |
4859 | | - if (sib.nodeName.toLowerCase() == selector) |
4860 | | - nth++; |
4861 | | - } |
4862 | | - if (nth != 1) |
4863 | | - selector += ":nth-of-type("+nth+")"; |
4864 | | - } |
4865 | | - path.unshift(selector); |
4866 | | - current = current.parentNode; |
4867 | | - |
4868 | | - // 限制长度,避免生成的选择器过长 |
4869 | | - if (path.length >= 4) break; |
| 4986 | + // 0: simple (default), 1: full |
| 4987 | + const mode = store.get('element_picker_mode', 0); |
| 4988 | + if (mode === 0) { |
| 4989 | + return generateSimpleSelector(el); |
| 4990 | + } else { |
| 4991 | + return generateFullPathSelector(el); |
4870 | 4992 | } |
4871 | | - return path.join(" > "); |
4872 | 4993 | } |
4873 | 4994 |
|
4874 | 4995 | return { start, stop }; |
|
4898 | 5019 | setGrayMode(enabled); |
4899 | 5020 | } |
4900 | 5021 |
|
| 5022 | + let styleEl = null; |
| 5023 | + |
| 5024 | + // 针对特定事件的处理器,确保允许默认行为 |
| 5025 | + const allowHandler = (e) => { |
| 5026 | + e.stopImmediatePropagation(); // 阻止其他监听器执行 |
| 5027 | + return true; |
| 5028 | + }; |
| 5029 | + |
| 5030 | + function setUnlockCopy(enable) { |
| 5031 | + // 定义需要拦截的事件列表 |
| 5032 | + const events = ['copy', 'cut', 'paste', 'selectstart', 'contextmenu', 'dragstart', 'mousedown', 'mouseup', 'keydown', 'keyup']; |
| 5033 | + |
| 5034 | + if (enable) { |
| 5035 | + // 1. 注入 CSS |
| 5036 | + if (!styleEl) { |
| 5037 | + styleEl = document.createElement('style'); |
| 5038 | + styleEl.innerHTML = ` |
| 5039 | + * { |
| 5040 | + -webkit-user-select: text !important; |
| 5041 | + -moz-user-select: text !important; |
| 5042 | + -ms-user-select: text !important; |
| 5043 | + user-select: text !important; |
| 5044 | + } |
| 5045 | + `; |
| 5046 | + document.head.appendChild(styleEl); |
| 5047 | + } |
| 5048 | + |
| 5049 | + // 2. 拦截事件 |
| 5050 | + // 使用捕获阶段,阻止事件向下一级传播,防止网页JS拦截 |
| 5051 | + events.forEach(evt => { |
| 5052 | + window.addEventListener(evt, allowHandler, true); |
| 5053 | + document.addEventListener(evt, allowHandler, true); |
| 5054 | + }); |
| 5055 | + |
| 5056 | + // 3. 定时清理内联属性 (暴力清除) |
| 5057 | + window._unlockTimer = setInterval(() => { |
| 5058 | + try { |
| 5059 | + const targets = [document, document.body]; |
| 5060 | + const props = ['oncopy', 'oncut', 'onpaste', 'onselectstart', 'oncontextmenu']; |
| 5061 | + targets.forEach(t => { |
| 5062 | + if(!t) return; |
| 5063 | + props.forEach(p => { |
| 5064 | + t[p] = null; |
| 5065 | + }); |
| 5066 | + }); |
| 5067 | + } catch(e) {} |
| 5068 | + }, 1000); |
| 5069 | + |
| 5070 | + Toast.show('提示', '已开启解除复制限制\n现在可以尝试选择文本并复制'); |
| 5071 | + } else { |
| 5072 | + // 1. 移除 CSS |
| 5073 | + if (styleEl) { |
| 5074 | + styleEl.remove(); |
| 5075 | + styleEl = null; |
| 5076 | + } |
| 5077 | + |
| 5078 | + // 2. 移除事件监听 |
| 5079 | + events.forEach(evt => { |
| 5080 | + window.removeEventListener(evt, allowHandler, true); |
| 5081 | + document.removeEventListener(evt, allowHandler, true); |
| 5082 | + }); |
| 5083 | + |
| 5084 | + // 3. 清除定时器 |
| 5085 | + if (window._unlockTimer) { |
| 5086 | + clearInterval(window._unlockTimer); |
| 5087 | + window._unlockTimer = null; |
| 5088 | + } |
| 5089 | + |
| 5090 | + Toast.show('提示', '已关闭解除复制限制'); |
| 5091 | + } |
| 5092 | + } |
| 5093 | + |
| 5094 | + |
| 5095 | + |
| 5096 | + // 强制复制选中文本 |
| 5097 | + async function forceCopySelection() { |
| 5098 | + const selection = window.getSelection(); |
| 5099 | + const text = selection.toString(); |
| 5100 | + |
| 5101 | + if (!text) { |
| 5102 | + Toast.show('提示', '请先选择要复制的文本'); |
| 5103 | + return; |
| 5104 | + } |
| 5105 | + |
| 5106 | + // 优先使用 GM_setClipboard (它不依赖页面事件,也不受拦截器影响) |
| 5107 | + if (typeof GM_setClipboard !== 'undefined') { |
| 5108 | + try { |
| 5109 | + GM_setClipboard(text, 'text'); |
| 5110 | + Toast.show('成功', '已强制复制选中内容到剪切板'); |
| 5111 | + return; |
| 5112 | + } catch (e) { |
| 5113 | + console.error('GM_setClipboard 失败,降级尝试:', e); |
| 5114 | + } |
| 5115 | + } |
| 5116 | + |
| 5117 | + // 如果必须使用 navigator.clipboard.writeText |
| 5118 | + // 临时禁用拦截器,确保复制操作能通过 |
| 5119 | + const wasEnabled = styleEl !== null; // 通过 styleEl 判断是否开启 |
| 5120 | + const events = ['copy', 'cut', 'paste', 'selectstart', 'contextmenu', 'dragstart', 'mousedown', 'mouseup', 'keydown', 'keyup']; |
| 5121 | + |
| 5122 | + try { |
| 5123 | + if (wasEnabled) { |
| 5124 | + events.forEach(evt => { |
| 5125 | + window.removeEventListener(evt, allowHandler, true); |
| 5126 | + document.removeEventListener(evt, allowHandler, true); |
| 5127 | + }); |
| 5128 | + } |
| 5129 | + |
| 5130 | + await navigator.clipboard.writeText(text); |
| 5131 | + Toast.show('成功', '已复制选中内容'); |
| 5132 | + |
| 5133 | + } catch (err) { |
| 5134 | + console.error('复制失败:', err); |
| 5135 | + Toast.show('错误', '复制失败: ' + err.message); |
| 5136 | + } finally { |
| 5137 | + // 恢复拦截器 (放在 finally 块中确保总是执行) |
| 5138 | + // 重新检查 styleEl 状态,因为在此期间可能被关闭 |
| 5139 | + if (styleEl !== null) { |
| 5140 | + events.forEach(evt => { |
| 5141 | + window.addEventListener(evt, allowHandler, true); |
| 5142 | + document.addEventListener(evt, allowHandler, true); |
| 5143 | + }); |
| 5144 | + } |
| 5145 | + } |
| 5146 | + } |
| 5147 | + |
4901 | 5148 | function toggleLog() { |
4902 | 5149 | // 使用store中的状态作为主要判断依据,DOM状态作为备用 |
4903 | 5150 | const storedHidden = store.get('logger.hidden', 0) === 1; |
|
5196 | 5443 | Logger.append(`[开关集] ${label}`); |
5197 | 5444 | } |
5198 | 5445 |
|
| 5446 | + function toggleUnlockCopy(enable) { |
| 5447 | + setUnlockCopy(enable); |
| 5448 | + const label = enable ? '开复制' : '关复制'; |
| 5449 | + Logger.append(`[开关集] ${label}`); |
| 5450 | + } |
| 5451 | + |
| 5452 | + function copySelection() { |
| 5453 | + forceCopySelection(); |
| 5454 | + Logger.append(`[工具] 强制复制选中文本`); |
| 5455 | + } |
| 5456 | + |
5199 | 5457 | async function configSafeCode() { |
5200 | 5458 | const key = 'clipboard_safecode'; |
5201 | 5459 | const current = store.get(key, ''); |
|
5622 | 5880 |
|
5623 | 5881 | // 第4列:定时任务、推送文本 |
5624 | 5882 | { id: 'schedule-open', label: '定时任务', column: 4, handler: toggleScheduleManager }, |
| 5883 | + { id: 'force-copy', label: '复制选中', column: 4, handler: copySelection }, |
5625 | 5884 |
|
5626 | 5885 | // 第5列:配置集、开关集、指令集 |
5627 | 5886 | { id: 'cfg-open', label: '配置集', column: 5, handler: toggleGroup('配置集') }, |
|
5653 | 5912 | storeKey: 'gray_mode_enabled', |
5654 | 5913 | handler: makeToggle('gray-mode', '开灰度', '关灰度', 'gray_mode_enabled', (enabled) => toggleGrayMode(enabled)) |
5655 | 5914 | }, |
| 5915 | + { |
| 5916 | + id: 'unlock-copy', |
| 5917 | + label: '开复制', |
| 5918 | + group: '开关集', |
| 5919 | + isToggle: true, |
| 5920 | + storeKey: 'unlock_copy_enabled', |
| 5921 | + handler: makeToggle('unlock-copy', '开复制', '关复制', 'unlock_copy_enabled', (enabled) => toggleUnlockCopy(enabled)) |
| 5922 | + }, |
5656 | 5923 |
|
5657 | 5924 | // 分组:配置集 |
5658 | 5925 | { |
|
5681 | 5948 | storeKey: 'remote_commands_enabled', |
5682 | 5949 | handler: makeToggle('cfg-remote-enable', '启用远程指令', '禁用远程指令', 'remote_commands_enabled') |
5683 | 5950 | }, |
| 5951 | + { |
| 5952 | + id: 'cfg-picker-mode', |
| 5953 | + label: '模式: 简易', |
| 5954 | + group: '配置集', |
| 5955 | + isToggle: true, |
| 5956 | + storeKey: 'element_picker_mode', |
| 5957 | + handler: makeToggle('cfg-picker-mode', '模式: 简易', '模式: 全路径', 'element_picker_mode') |
| 5958 | + }, |
5684 | 5959 | // 组内按钮:推送文本 |
5685 | 5960 | { |
5686 | 5961 | id: 'cfg-push', |
|
0 commit comments