feat: add right-click restorer for current site
- Add RightClickRestorer page with active unlock trigger - Content script with dual-world strategy (isolated + main world) - Monkey patch preventDefault/stopPropagation via background executeScript - Overlay penetration for pointer-events blocking layers - Delegated main-world injection to avoid CSP restrictions - Async message handling for reliable state updates - i18n translations (zh/en) and unit tests
This commit is contained in:
@@ -58,7 +58,96 @@ export default defineBackground(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// 3. 异步刷新请求监听(统一使用 alarms API,避免 MV3 Service Worker 被销毁导致任务丢失)
|
||||
// 3. 注入主环境脚本(content script 无权访问 chrome.tabs/scripting,委托 background 执行)
|
||||
onMessage(MessageAction.INJECT_MAIN_WORLD_SCRIPT, async (message) => {
|
||||
const sender = message.sender as chrome.runtime.MessageSender | undefined;
|
||||
const tabId = sender?.tab?.id;
|
||||
|
||||
if (!tabId) {
|
||||
console.warn('[RightClickRestorer] Injection request missing tabId');
|
||||
return { success: false, message: 'Missing tabId' };
|
||||
}
|
||||
|
||||
try {
|
||||
await browser.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: () => {
|
||||
'use strict';
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
if (w.__testingToolsRightClickPatched) return;
|
||||
w.__testingToolsRightClickPatched = true;
|
||||
|
||||
const PROTECTED = ['contextmenu', 'copy', 'paste', 'cut', 'selectstart'];
|
||||
|
||||
/* 1. 屏蔽 MouseEvent.prototype.preventDefault(含 mousedown 右键) */
|
||||
const _origPreventDefault = MouseEvent.prototype.preventDefault;
|
||||
Object.defineProperty(MouseEvent.prototype, 'preventDefault', {
|
||||
value: function (this: MouseEvent) {
|
||||
const t = this.type;
|
||||
if (PROTECTED.includes(t) || (t === 'mousedown' && this.button === 2)) {
|
||||
return;
|
||||
}
|
||||
return _origPreventDefault.call(this);
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
/* 2. 屏蔽 Event.prototype.stopPropagation / stopImmediatePropagation */
|
||||
const _origStopPropagation = Event.prototype.stopPropagation;
|
||||
Object.defineProperty(Event.prototype, 'stopPropagation', {
|
||||
value: function (this: Event) {
|
||||
if (PROTECTED.includes(this.type)) return;
|
||||
return _origStopPropagation.call(this);
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const _origStopImmediatePropagation = Event.prototype.stopImmediatePropagation;
|
||||
Object.defineProperty(Event.prototype, 'stopImmediatePropagation', {
|
||||
value: function (this: Event) {
|
||||
if (PROTECTED.includes(this.type)) return;
|
||||
return _origStopImmediatePropagation.call(this);
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
/* 3. 拦截 document.oncontextmenu(处理 return false 方式) */
|
||||
let _docOnContextMenu: unknown = null;
|
||||
Object.defineProperty(document, 'oncontextmenu', {
|
||||
get() {
|
||||
return _docOnContextMenu;
|
||||
},
|
||||
set(fn: unknown) {
|
||||
if (typeof fn === 'function') {
|
||||
_docOnContextMenu = function (this: GlobalEventHandlers, e: MouseEvent) {
|
||||
const r = (fn as (this: GlobalEventHandlers, ev: MouseEvent) => unknown).call(
|
||||
this,
|
||||
e,
|
||||
);
|
||||
return r === false ? true : r;
|
||||
};
|
||||
} else {
|
||||
_docOnContextMenu = fn;
|
||||
}
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
},
|
||||
world: 'MAIN',
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
console.error('[RightClickRestorer] executeScript failed:', errorMsg);
|
||||
return { success: false, message: errorMsg };
|
||||
}
|
||||
});
|
||||
|
||||
// 4. 异步刷新请求监听(统一使用 alarms API,避免 MV3 Service Worker 被销毁导致任务丢失)
|
||||
onMessage(MessageAction.RELOAD_TAB, async (message) => {
|
||||
const { tabId, delay = 0 } = message.data;
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { MessageAction, onMessage } from '@/utils/messages';
|
||||
import { getTextStats } from '@/utils/textStatistics';
|
||||
import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover';
|
||||
|
||||
// 💡 1. 国际化超进化:对接 chrome.i18n 插件标准 API,如果环境不支持则安全降级,拒绝硬编码中文
|
||||
function getI18nText(key: string, fallback: string): string {
|
||||
if (typeof chrome !== 'undefined' && chrome.i18n) {
|
||||
return chrome.i18n.getMessage(key) || fallback;
|
||||
@@ -39,20 +38,16 @@ function convertTimestamp(input: string): string {
|
||||
let lastClickX = 0;
|
||||
let lastClickY = 0;
|
||||
|
||||
// 💡 使用 capture: true 确保在任何极其复杂的单页应用(SPA)中都能精准捕获右键坐标
|
||||
document.addEventListener(
|
||||
'contextmenu',
|
||||
(e) => {
|
||||
lastClickX = e.clientX;
|
||||
lastClickY = e.clientY;
|
||||
},
|
||||
{ capture: true, passive: true }, // 优化滚动与捕获性能
|
||||
{ capture: true, passive: true },
|
||||
);
|
||||
|
||||
export function initContextMenuHandler(): void {
|
||||
// 💡 2. 全局自净化大闸(Global Auto-Purge Grid):
|
||||
// 当用户在网页上进行左键点击、滚动视视口、或调整大小时,
|
||||
// 证明心流已经移开,自发隐退所有浮动的 Popover 弹窗,体验顺滑得丝丝入扣!
|
||||
const dismissPopover = (): void => {
|
||||
hidePopover();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { MessageAction, onMessage, sendMessage } from '@/utils/messages';
|
||||
|
||||
const BADGE_ID = 'testing-tools-right-click-restorer-badge';
|
||||
const BADGE_STYLE_ID = 'testing-tools-right-click-restorer-badge-style';
|
||||
|
||||
let isRestored = false;
|
||||
|
||||
function updateBadge(): void {
|
||||
const badge = document.getElementById(BADGE_ID);
|
||||
if (badge) {
|
||||
badge.style.opacity = isRestored ? '1' : '0';
|
||||
}
|
||||
}
|
||||
|
||||
function createBadge(): void {
|
||||
if (document.getElementById(BADGE_ID)) return;
|
||||
|
||||
if (!document.getElementById(BADGE_STYLE_ID)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = BADGE_STYLE_ID;
|
||||
style.textContent = `
|
||||
#${BADGE_ID} {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
z-index: 2147483646;
|
||||
padding: 6px 12px;
|
||||
background: #2e7d32;
|
||||
color: #ffffff;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-out;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#${BADGE_ID} {
|
||||
background: #4caf50;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
const badge = document.createElement('div');
|
||||
badge.id = BADGE_ID;
|
||||
badge.textContent = '\u53f3\u952e\u5df2\u89e3\u9501';
|
||||
document.body.appendChild(badge);
|
||||
}
|
||||
|
||||
/* ======================== Main World 注入 ======================== */
|
||||
|
||||
async function injectMainWorldScript(): Promise<void> {
|
||||
try {
|
||||
const response = await sendMessage(MessageAction.INJECT_MAIN_WORLD_SCRIPT);
|
||||
if (!response.success) {
|
||||
console.error('[RightClickRestorer] Background injection failed:', response.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[RightClickRestorer] Failed to request main world injection:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/* ======================== Isolated World 事件拦截 ======================== */
|
||||
|
||||
function installEventIntercepts(): void {
|
||||
window.addEventListener(
|
||||
'contextmenu',
|
||||
(e) => {
|
||||
if (!isRestored) return;
|
||||
e.stopPropagation();
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
// 部分网站在 mousedown 阶段阻止 contextmenu
|
||||
window.addEventListener(
|
||||
'mousedown',
|
||||
(e) => {
|
||||
if (!isRestored) return;
|
||||
if (e.button === 2 || (e.buttons & 2) !== 0) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/* ======================== 遮罩层穿透 ======================== */
|
||||
|
||||
const MEDIA_TAGS = new Set(['IMG', 'VIDEO', 'CANVAS', 'SVG']);
|
||||
|
||||
function initMousePenetration(): void {
|
||||
window.addEventListener(
|
||||
'mousedown',
|
||||
(e) => {
|
||||
if (!isRestored || e.button !== 2) return;
|
||||
|
||||
const elements = document.elementsFromPoint(e.clientX, e.clientY);
|
||||
if (!elements.length) return;
|
||||
|
||||
let targetMedia: HTMLElement | null = null;
|
||||
for (const el of elements) {
|
||||
if (MEDIA_TAGS.has(el.tagName)) {
|
||||
targetMedia = el as HTMLElement;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetMedia) return;
|
||||
|
||||
const modified: Array<{ el: HTMLElement; original: string | null }> = [];
|
||||
let foundMedia = false;
|
||||
|
||||
for (const el of elements) {
|
||||
const htmlEl = el as HTMLElement;
|
||||
|
||||
if (el === targetMedia) {
|
||||
foundMedia = true;
|
||||
const original = htmlEl.style.pointerEvents || null;
|
||||
htmlEl.style.setProperty('pointer-events', 'all', 'important');
|
||||
modified.push({ el: htmlEl, original });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!foundMedia) {
|
||||
const original = htmlEl.style.pointerEvents || null;
|
||||
htmlEl.style.setProperty('pointer-events', 'none', 'important');
|
||||
modified.push({ el: htmlEl, original });
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
for (const { el, original } of modified) {
|
||||
if (original === null || original === '') {
|
||||
el.style.removeProperty('pointer-events');
|
||||
} else {
|
||||
el.style.pointerEvents = original;
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/* ======================== 激活保护 ======================== */
|
||||
|
||||
async function activateProtection(): Promise<void> {
|
||||
if (isRestored) return;
|
||||
|
||||
await injectMainWorldScript();
|
||||
|
||||
// 再次检查,防止并发调用导致重复注册
|
||||
if (isRestored) return;
|
||||
|
||||
installEventIntercepts();
|
||||
initMousePenetration();
|
||||
|
||||
isRestored = true;
|
||||
updateBadge();
|
||||
}
|
||||
|
||||
/* ======================== 消息通信 ======================== */
|
||||
|
||||
function initMessaging(): void {
|
||||
onMessage(MessageAction.RESTORE_RIGHT_CLICK, async () => {
|
||||
await activateProtection();
|
||||
return { success: true, restored: isRestored };
|
||||
});
|
||||
|
||||
onMessage(MessageAction.QUERY_RIGHT_CLICK_STATUS, () => {
|
||||
return { success: true, restored: isRestored };
|
||||
});
|
||||
}
|
||||
|
||||
/* ======================== 入口 ======================== */
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
runAt: 'document_start',
|
||||
main() {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', createBadge, { once: true });
|
||||
} else {
|
||||
createBadge();
|
||||
}
|
||||
initMessaging();
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user