Greasy Fork is available in English.
保留飞书右键菜单;复制时写入 text/html 与 text/plain,保持格式;接口层强制 copy=1;轻量 CSS 去水印
// ==UserScript==
// @name 让你的飞书文档更好用
// @namespace https://bytedance.com
// @version 0.11.0
// @description 保留飞书右键菜单;复制时写入 text/html 与 text/plain,保持格式;接口层强制 copy=1;轻量 CSS 去水印
// @author merge by ChatGPT (based on NOABC & Tom-yang)
// @match *://*.feishu.cn/*
// @match *://*.larkoffice.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=feishu.cn
// @grant none
// @run-at document-start
// @license MIT
// ==/UserScript==
(function () {
if (window.__FEISHU_COPY_FIX_MERGED__) return;
window.__FEISHU_COPY_FIX_MERGED__ = true;
/******** 1) 复制:写入 text/html + text/plain,保留格式;不碰 contextmenu ********/
function selectionToHTMLAndText() {
const sel = window.getSelection && window.getSelection();
if (!sel || sel.rangeCount === 0) return { html: '', text: '' };
const range = sel.getRangeAt(0).cloneRange();
// 若是输入框/textarea,使用其原生 value 作为 plain text
const anchorNode = sel.anchorNode && (sel.anchorNode.nodeType === 3 ? sel.anchorNode.parentNode : sel.anchorNode);
const isFormControl = anchorNode && (anchorNode.nodeName === 'TEXTAREA' || (anchorNode.nodeName === 'INPUT' && /text|search|url|email|password|tel/i.test(anchorNode.type)));
let plain = '';
let html = '';
if (isFormControl) {
plain = anchorNode.value?.substring(anchorNode.selectionStart, anchorNode.selectionEnd) || '';
html = plain.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
} else {
const container = document.createElement('div');
container.appendChild(range.cloneContents());
html = container.innerHTML;
plain = (sel + '') || container.textContent || '';
}
return { html, text: plain };
}
// 不再重写 EventTarget.prototype.addEventListener,避免和站点逻辑冲突、也减少崩溃源
// 采用捕获阶段的全局监听,最先拿到事件,写入我们想要的剪贴板内容
document.addEventListener('copy', function(e) {
try {
const data = selectionToHTMLAndText();
if (!data) return;
// 有些选区为空则不处理
if (!data.text && !data.html) return;
// 写入两种 MIME,确保富文本粘贴保留结构,纯文本也可用
if (e.clipboardData) {
if (data.html) e.clipboardData.setData('text/html', data.html);
if (data.text) e.clipboardData.setData('text/plain', data.text);
e.preventDefault(); // 确保用我们写入的内容
}
} catch(_) {}
// 不 stopImmediatePropagation,尽量减少副作用;我们已经 preventDefault 了,站点就算再写也覆盖不了
}, true); // 捕获阶段
// 保留浏览器默认复制行为的回退(在极端情况下)
try {
Object.defineProperty(document, 'oncopy', { configurable: true, get: () => null, set: () => {} });
Object.defineProperty(window, 'oncopy', { configurable: true, get: () => null, set: () => {} });
} catch (_) {}
/******** 2) 接口层:强制 copy=1(XHR + fetch) ********/
(function patchXHR() {
const XHR = XMLHttpRequest;
if (!XHR || XHR.prototype.open.__patched_for_feishu_copy__) return;
const rawOpen = XHR.prototype.open;
XHR.prototype.open = function(method, url, ...rest) {
try { this.__fs_target_url__ = String(url || ''); } catch(_) {}
this.addEventListener('readystatechange', function () {
if (this.readyState !== 4) return;
const urlStr = this.__fs_target_url__ || '';
const isPermissionState =
(urlStr.includes('/space/api/suite/permission/') && urlStr.includes('/actions/state/')) ||
(urlStr.includes('/permission/') && urlStr.includes('/actions/state'));
if (!isPermissionState) return;
try {
let json = null;
if (typeof this.responseText === 'string') {
json = JSON.parse(this.responseText);
} else if (this.response && typeof this.response === 'object') {
json = this.response;
}
if (json?.data?.actions && json.data.actions.copy !== 1) {
json.data.actions.copy = 1;
try { Object.defineProperty(this, 'responseText', { configurable: true, get: () => JSON.stringify(json) }); } catch(_) {}
try { Object.defineProperty(this, 'response', { configurable: true, get: () => json }); } catch(_) {}
}
} catch(_) {}
}, false);
return rawOpen.call(this, method, url, ...rest);
};
XHR.prototype.open.__patched_for_feishu_copy__ = true;
})();
(function patchFetch() {
if (!window.fetch || window.fetch.__patched_for_feishu_copy__) return;
const rawFetch = window.fetch;
window.fetch = async function (...args) {
const res = await rawFetch(...args);
try {
const urlStr = String(args[0] || '');
const isPermissionState =
(urlStr.includes('/space/api/suite/permission/') && urlStr.includes('/actions/state/')) ||
(urlStr.includes('/permission/') && urlStr.includes('/actions/state'));
if (!isPermissionState) return res;
const clone = res.clone();
const json = await clone.json().catch(() => null);
if (json?.data?.actions && json.data.actions.copy !== 1) {
json.data.actions.copy = 1;
return new Response(JSON.stringify(json), {
status: res.status,
statusText: res.statusText,
headers: { 'Content-Type': 'application/json' }
});
}
} catch(_) {}
return res;
};
window.fetch.__patched_for_feishu_copy__ = true;
})();
/******** 3) 轻量去水印(仅 CSS) ********/
(function injectWatermarkCSS() {
if (typeof window.GM_addStyle === 'undefined') {
window.GM_addStyle = (css) => {
const head = document.head || document.documentElement;
if (!head) return null;
const style = document.createElement('style');
style.type = 'text/css';
style.textContent = css;
head.appendChild(style);
return style;
};
}
const bgImageNone = '{background-image: none !important;}';
const gen = (sel) => `${sel}${bgImageNone}`;
const css =
[
gen('[class*="watermark"]'),
gen('[style*="pointer-events: none"]'),
gen('.ssrWaterMark'),
gen('body>div>div>div>div[style*="position: fixed"]:not(:has(*))'),
gen('[class*="TIAWBFTROSIDWYKTTIAW"]'),
gen('body>div[style*="position: fixed"]:not(:has(*))'),
gen('#watermark-cache-container'),
gen('body>div[style*="inset: 0px;"]:not(:has(*))'),
gen('.chatMessages>div[style*="inset: 0px;"]'),
'* { -webkit-user-select: text !important; user-select: text !important; }'
].join('\n');
try { window.GM_addStyle(css); } catch(_) {}
})();
})();