// ==UserScript==
// @name 完全解除任意网站复制粘贴限制 & 原生复制粘贴使用体验
// @namespace http://tampermonkey.net/
// @version 1.2
// @description 优先接管编辑器粘贴管线(UEditor/CKEditor/TinyMCE/Quill),将内容转为纯文本或去样式后放行;否则在捕获阶段统一处理 paste 并原生注入,保留撤销;解锁选中/右键/拖拽;支持同源 iframe 与动态渲染。
// @author AMT
// @match *://*/*
// @grant none
// @run-at document-start
// @license MIT
// ==/UserScript==
(function () {
'use strict';
/*** 配置 ***/
const PURE_TEXT = true; // true: 彻底纯文本;false: 仅去 style/class/id/on* 等内联样式与类名,保留基本标签
const ENABLE_GLOBAL_PASTE_CAPTURE = true; // 在捕获阶段统一接管 paste(对大多数站点更稳)
/************** 0. 解锁常规禁止:选中 / 右键 / 拖拽 / 长按 **************/
const blocked = ['selectstart','mousedown','mouseup','mousemove','contextmenu','dragstart'];
blocked.forEach(ev => addEvent(document, ev, e => e.stopImmediatePropagation(), { capture: true }));
onReady(() => { if (document.body) document.body.onselectstart = null; });
const css = document.createElement('style');
css.textContent = `*{user-select:text!important;-webkit-user-select:text!important;}`;
document.documentElement.appendChild(css);
/************** 1. 优先:编辑器感知式粘贴钩子 **************/
// —— UEditor
function hookUEditor(win) {
try {
const UE = win.UE;
if (!UE || !UE.getEditor) return false;
const textareas = Array.from(win.document.getElementsByTagName('textarea'))
.filter(t => t.id && /answer|ueditor|editor/i.test(t.id));
textareas.forEach(t => {
const ed = UE.getEditor(t.id);
if (!ed) return;
ed.ready(() => {
// 尝试卸掉站点自带 beforepaste(若有命名的全局函数)
try { ed.removeListener('beforepaste', win.editorPaste); } catch {}
// UEditor 的纯文本选项(若支持)
try { ed.setOpt && ed.setOpt('pasteplain', true); } catch {}
ed.addListener('beforepaste', function (_type, html) {
try {
if (PURE_TEXT && html && typeof html.text === 'string') {
html.html = escapeHtml(html.text); // 彻底纯文本
} else if (html && typeof html.html === 'string') {
html.html = sanitizeHTML(html.html); // 去样式/类名
}
} catch {}
return true; // 放行
});
});
});
return textareas.length > 0;
} catch { return false; }
}
// —— CKEditor 4
function hookCKEditor4(win) {
try {
const CK = win.CKEDITOR;
if (!CK || !CK.instances) return false;
let hooked = 0;
Object.values(CK.instances).forEach(inst => {
if (inst.__amt_paste_hooked) return;
inst.on('paste', evt => {
const data = evt.data;
try {
const text = (data && (data.clipboardData?.getData('text/plain') || data.dataValue)) || '';
if (PURE_TEXT) {
data.type = 'text';
data.dataValue = escapeHtml(text);
} else {
data.dataValue = sanitizeHTML(data.dataValue || text);
}
} catch {}
}, null, null, 0);
inst.__amt_paste_hooked = true;
hooked++;
});
return hooked > 0;
} catch { return false; }
}
// —— TinyMCE
function hookTinyMCE(win) {
try {
const TM = win.tinymce;
if (!TM || !TM.editors) return false;
let hooked = 0;
TM.editors.forEach(ed => {
if (!ed || ed.destroyed || ed.__amt_paste_hooked) return;
ed.on('Paste', e => {
try {
const dt = e.clipboardData;
let text = '';
if (dt) text = dt.getData('text/plain') || dt.getData('text') || '';
if (PURE_TEXT) {
e.preventDefault();
ed.insertContent(escapeHtml(text));
} else if (text) {
e.preventDefault();
ed.insertContent(sanitizeHTML(text));
}
} catch {}
});
try { ed.on('keydown', ev => ev.stopImmediatePropagation(), true); } catch {}
ed.__amt_paste_hooked = true;
hooked++;
});
return hooked > 0;
} catch { return false; }
}
// —— Quill
function hookQuill(win) {
try {
const Quill = win.Quill;
if (!Quill || !Quill.prototype) return false;
// 尝试从全局变量或节点上找到 quill 实例
const nodes = win.document.querySelectorAll('.ql-editor');
let hooked = 0;
nodes.forEach(n => {
const q = n.__quill || n.parentElement?.__quill || null;
if (!q || q.__amt_paste_hooked) return;
const Delta = Quill.import && Quill.import('delta');
if (Delta) {
q.clipboard.addMatcher(Node.ELEMENT_NODE, (node, delta) => {
if (PURE_TEXT) {
const text = node.innerText || node.textContent || '';
return new Delta().insert(text);
} else {
// 去样式:保留文本与基础换行
const text = (node.innerText || node.textContent || '').replace(/\r/g,'');
return new Delta().insert(text);
}
});
q.__amt_paste_hooked = true;
hooked++;
}
});
return hooked > 0;
} catch { return false; }
}
// —— 尝试在主文档与同源 iframe 内多轮挂钩
function tryHookEditors(win) {
let any = false;
any = hookUEditor(win) || any;
any = hookCKEditor4(win) || any;
any = hookTinyMCE(win) || any;
any = hookQuill(win) || any;
return any;
}
// 初次与轮询/观察(应对异步加载)
onReady(() => {
// 主文档
bootHook(window, document);
// 同源 iframe
observeIframes(doc => bootHook(doc.defaultView || window, doc));
});
function bootHook(win, doc) {
let tries = 0;
const tick = () => {
tryHookEditors(win);
if (++tries < 60) setTimeout(tick, 300); // 最多尝试 ~18s
};
tick();
// DOM 变化也再试(题目区/编辑器异步渲染)
const mo = new MutationObserver(() => tryHookEditors(win));
mo.observe(doc.documentElement, { childList: true, subtree: true });
}
/************** 2. 通用:捕获阶段统一处理 paste 并原生注入 **************/
if (ENABLE_GLOBAL_PASTE_CAPTURE) {
const onPasteCapture = (e) => {
const t = findEditableTarget(e);
if (!t) return; // 非编辑目标,不处理
// 从剪贴板取纯文本
const dt = e.clipboardData;
let text = '';
if (dt) text = dt.getData('text/plain') || dt.getData('text') || '';
// 无文本则放行(比如图片/文件粘贴)
if (!text) return;
e.stopImmediatePropagation();
e.preventDefault();
const final = PURE_TEXT ? text : stripHTMLToCleanTextOrBasic(text);
insertTextAtCursor(t, final);
};
addEvent(document, 'paste', onPasteCapture, { capture: true, passive: false });
observeIframes(doc => addEvent(doc, 'paste', onPasteCapture, { capture: true, passive: false }));
}
/************** 3. 注入实现:保留撤销栈 **************/
function insertTextAtCursor(el, text) {
if (!el) return;
const doc = el.ownerDocument || document;
// 优先用原生命令(很多富文本/框架能把它纳入撤销栈)
try {
el.focus();
if (doc.execCommand && doc.execCommand('insertText', false, text)) {
dispatchInput(el, text);
return;
}
} catch { /* fallthrough */ }
// input/textarea
if (!el.isContentEditable) {
try {
const s = el.selectionStart ?? 0, e = el.selectionEnd ?? s;
el.setRangeText(text, s, e, 'end'); // 保留撤销
} catch {
const val = String(el.value ?? '');
const s = el.selectionStart|0, e = el.selectionEnd|0;
setNativeValue(el, val.slice(0, s) + text + val.slice(e));
try { el.setSelectionRange(s + text.length, s + text.length); } catch {}
}
dispatchInput(el, text);
return;
}
// contenteditable:Range 手动插入
const sel = doc.getSelection && doc.getSelection();
if (sel && sel.rangeCount) {
const range = sel.getRangeAt(0);
range.deleteContents();
const tn = doc.createTextNode(text);
range.insertNode(tn);
range.setStartAfter(tn);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
dispatchInput(el, text);
}
}
function dispatchInput(el, data) {
// beforeinput
try { el.dispatchEvent(new InputEvent('beforeinput', { data, inputType:'insertFromPaste', bubbles:true, cancelable:true })); } catch {}
// input
try { el.dispatchEvent(new InputEvent('input', { data, inputType:'insertFromPaste', bubbles:true })); } catch {
el.dispatchEvent(new Event('input', { bubbles:true }));
}
// change(站点依赖)
try { el.dispatchEvent(new Event('change', { bubbles:true })); } catch {}
}
/************** 4. 目标判定 / 事件 / iframe / 工具 **************/
function isEditable(el) {
if (!el) return false;
if (el.isContentEditable) return true;
const tag = (el.tagName || '').toLowerCase();
if (tag === 'textarea') return true;
if (tag === 'input') {
// 避免密码框
return /^(?:text|search|email|url|tel|password|number)$/i.test(el.type || '');
}
return false;
}
function findEditableTarget(e) {
// 支持穿过 shadow DOM,沿 composedPath 找最近的可编辑
const path = (e.composedPath && e.composedPath()) || [];
for (const n of path) {
if (n && isEditable(n)) return n;
}
// 兜底:activeElement
const ae = (e.target && (e.target.ownerDocument || document).activeElement) || document.activeElement;
return isEditable(ae) ? ae : null;
}
function addEvent(target, type, handler, opts) {
try { target.addEventListener(type, handler, opts || false); } catch {}
}
function observeIframes(cb) {
const seen = new WeakSet();
const hook = frame => {
if (!frame || seen.has(frame)) return;
seen.add(frame);
try {
const doc = frame.contentDocument;
if (doc) cb(doc);
} catch { /* 跨域 iframe 无法访问 */ }
};
const scan = root => root.querySelectorAll('iframe');
onReady(() => scan(document).forEach(hook));
const mo = new MutationObserver(muts => muts.forEach(m => m.addedNodes?.forEach(n => {
if (n && n.tagName === 'IFRAME') hook(n);
})));
mo.observe(document.documentElement, { childList: true, subtree: true });
}
function onReady(fn) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn, { once: true });
} else {
fn();
}
}
function setNativeValue(el, value) {
const proto = el.tagName === 'TEXTAREA'
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
const des = Object.getOwnPropertyDescriptor(proto, 'value');
des && des.set && des.set.call(el, value);
}
/************** 5. 纯文本/去样式处理 **************/
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
function sanitizeHTML(html) {
// 仅去掉 style/class/id/on* 这类容易被站点拒绝的内联属性;保留标签结构
try {
const div = document.createElement('div');
div.innerHTML = html;
const walk = node => {
if (node.nodeType === 1) { // ELEMENT_NODE
// 删除危险/冗余属性
const rm = [];
for (const a of node.attributes) {
const name = a.name.toLowerCase();
if (name === 'style' || name === 'class' || name === 'id' || name.startsWith('on') || name.startsWith('data-')) {
rm.push(a.name);
}
}
rm.forEach(n => node.removeAttribute(n));
}
node.childNodes && node.childNodes.forEach(walk);
};
div.childNodes.forEach(walk);
return div.innerHTML;
} catch {
// 退一步:去 style/class 的简单正则
return String(html).replace(/\s*(?:style|class|id|on\w+|data-[\w-]+)="[^"]*"/gi, '');
}
}
function stripHTMLToCleanTextOrBasic(mixed) {
// 输入可能是 HTML 或纯文本;先简单判定
if (!/[<>&]/.test(mixed)) return mixed; // 看起来像纯文本
// 尝试 DOM 解析再提取纯文本
try {
const div = document.createElement('div');
div.innerHTML = mixed;
// 将 <br>/<p> 转为换行
Array.from(div.querySelectorAll('br')).forEach(br => { br.replaceWith('\n'); });
Array.from(div.querySelectorAll('p,div,li')).forEach(el => {
if (el.lastChild && el.lastChild.nodeType !== 3) el.appendChild(document.createTextNode('\n'));
else el.appendChild(document.createTextNode('\n'));
});
return (div.textContent || '').replace(/\u00A0/g, ' ');
} catch {
// 失败则硬去标签
return String(mixed).replace(/<[^>]+>/g, '');
}
}
// console.log('[PasteHook] loaded');
})();