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:
@@ -9,8 +9,8 @@ import {
|
|||||||
|
|
||||||
describe('features', () => {
|
describe('features', () => {
|
||||||
describe('FEATURES', () => {
|
describe('FEATURES', () => {
|
||||||
it('should have 10 features defined', () => {
|
it('should have 11 features defined', () => {
|
||||||
expect(FEATURES).toHaveLength(10);
|
expect(FEATURES).toHaveLength(11);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have all required properties for each feature', () => {
|
it('should have all required properties for each feature', () => {
|
||||||
@@ -95,7 +95,7 @@ describe('features', () => {
|
|||||||
describe('getAllFeatureKeys', () => {
|
describe('getAllFeatureKeys', () => {
|
||||||
it('should return all feature keys', () => {
|
it('should return all feature keys', () => {
|
||||||
const allKeys = getAllFeatureKeys();
|
const allKeys = getAllFeatureKeys();
|
||||||
expect(allKeys).toHaveLength(10);
|
expect(allKeys).toHaveLength(11);
|
||||||
expect(allKeys).toContain('dashboard');
|
expect(allKeys).toContain('dashboard');
|
||||||
expect(allKeys).toContain('timestamp');
|
expect(allKeys).toContain('timestamp');
|
||||||
expect(allKeys).toContain('storageCleaner');
|
expect(allKeys).toContain('storageCleaner');
|
||||||
@@ -106,6 +106,7 @@ describe('features', () => {
|
|||||||
expect(allKeys).toContain('base64Converter');
|
expect(allKeys).toContain('base64Converter');
|
||||||
expect(allKeys).toContain('markdownToHtml');
|
expect(allKeys).toContain('markdownToHtml');
|
||||||
expect(allKeys).toContain('htmlToMarkdown');
|
expect(allKeys).toContain('htmlToMarkdown');
|
||||||
|
expect(allKeys).toContain('rightClickRestorer');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,9 +123,9 @@ describe('features', () => {
|
|||||||
expect(pageOrder).toContain('qrCode');
|
expect(pageOrder).toContain('qrCode');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have 9 items in page order', () => {
|
it('should have 10 items in page order', () => {
|
||||||
const pageOrder = getDefaultPageOrder();
|
const pageOrder = getDefaultPageOrder();
|
||||||
expect(pageOrder).toHaveLength(9);
|
expect(pageOrder).toHaveLength(10);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
Code,
|
Code,
|
||||||
File,
|
File,
|
||||||
|
MousePointerClick,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info';
|
export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info';
|
||||||
@@ -26,6 +27,7 @@ const JsonToolsPage = lazy(() => import('@/pages/JsonTools'));
|
|||||||
const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter'));
|
const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter'));
|
||||||
const MarkdownToHtmlPage = lazy(() => import('@/pages/MarkdownToHtml'));
|
const MarkdownToHtmlPage = lazy(() => import('@/pages/MarkdownToHtml'));
|
||||||
const HtmlToMarkdownPage = lazy(() => import('@/pages/HtmlToMarkdown'));
|
const HtmlToMarkdownPage = lazy(() => import('@/pages/HtmlToMarkdown'));
|
||||||
|
const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer'));
|
||||||
|
|
||||||
export interface FeatureConfig {
|
export interface FeatureConfig {
|
||||||
key: PageType;
|
key: PageType;
|
||||||
@@ -170,6 +172,19 @@ export const FEATURES: FeatureConfig[] = [
|
|||||||
tab: HtmlToMarkdownPage,
|
tab: HtmlToMarkdownPage,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'rightClickRestorer',
|
||||||
|
labelKey: 'features:rightClickRestorer.title',
|
||||||
|
descriptionKey: 'features:rightClickRestorer.description',
|
||||||
|
themeColorKey: 'success',
|
||||||
|
icon: MousePointerClick,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: RightClickRestorerPage,
|
||||||
|
sidepanel: RightClickRestorerPage,
|
||||||
|
tab: RightClickRestorerPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getFeatureByKey(key: PageType): FeatureConfig | undefined {
|
export function getFeatureByKey(key: PageType): FeatureConfig | undefined {
|
||||||
|
|||||||
@@ -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) => {
|
onMessage(MessageAction.RELOAD_TAB, async (message) => {
|
||||||
const { tabId, delay = 0 } = message.data;
|
const { tabId, delay = 0 } = message.data;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { MessageAction, onMessage } from '@/utils/messages';
|
|||||||
import { getTextStats } from '@/utils/textStatistics';
|
import { getTextStats } from '@/utils/textStatistics';
|
||||||
import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover';
|
import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover';
|
||||||
|
|
||||||
// 💡 1. 国际化超进化:对接 chrome.i18n 插件标准 API,如果环境不支持则安全降级,拒绝硬编码中文
|
|
||||||
function getI18nText(key: string, fallback: string): string {
|
function getI18nText(key: string, fallback: string): string {
|
||||||
if (typeof chrome !== 'undefined' && chrome.i18n) {
|
if (typeof chrome !== 'undefined' && chrome.i18n) {
|
||||||
return chrome.i18n.getMessage(key) || fallback;
|
return chrome.i18n.getMessage(key) || fallback;
|
||||||
@@ -39,20 +38,16 @@ function convertTimestamp(input: string): string {
|
|||||||
let lastClickX = 0;
|
let lastClickX = 0;
|
||||||
let lastClickY = 0;
|
let lastClickY = 0;
|
||||||
|
|
||||||
// 💡 使用 capture: true 确保在任何极其复杂的单页应用(SPA)中都能精准捕获右键坐标
|
|
||||||
document.addEventListener(
|
document.addEventListener(
|
||||||
'contextmenu',
|
'contextmenu',
|
||||||
(e) => {
|
(e) => {
|
||||||
lastClickX = e.clientX;
|
lastClickX = e.clientX;
|
||||||
lastClickY = e.clientY;
|
lastClickY = e.clientY;
|
||||||
},
|
},
|
||||||
{ capture: true, passive: true }, // 优化滚动与捕获性能
|
{ capture: true, passive: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
export function initContextMenuHandler(): void {
|
export function initContextMenuHandler(): void {
|
||||||
// 💡 2. 全局自净化大闸(Global Auto-Purge Grid):
|
|
||||||
// 当用户在网页上进行左键点击、滚动视视口、或调整大小时,
|
|
||||||
// 证明心流已经移开,自发隐退所有浮动的 Popover 弹窗,体验顺滑得丝丝入扣!
|
|
||||||
const dismissPopover = (): void => {
|
const dismissPopover = (): void => {
|
||||||
hidePopover();
|
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();
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -37,5 +37,9 @@
|
|||||||
"htmlToMarkdown": {
|
"htmlToMarkdown": {
|
||||||
"title": "HTML to Markdown",
|
"title": "HTML to Markdown",
|
||||||
"description": "Real-time HTML conversion and Markdown preview"
|
"description": "Real-time HTML conversion and Markdown preview"
|
||||||
|
},
|
||||||
|
"rightClickRestorer": {
|
||||||
|
"title": "Right Click Restorer",
|
||||||
|
"description": "Detect and restore disabled browser right-click menus"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"title": "Right Click Restorer",
|
||||||
|
"description": "Detect and restore disabled browser right-click menus",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"currentDomain": "Current Domain",
|
||||||
|
"statusLocked": "Locked",
|
||||||
|
"statusUnlocked": "Unlocked",
|
||||||
|
"unlockDesc": "Click the button below to temporarily unlock the right-click menu for the current website. You will need to unlock again after refreshing the page.",
|
||||||
|
"unlockBtn": "Unlock Right Click",
|
||||||
|
"alreadyUnlocked": "Right Click Unlocked"
|
||||||
|
}
|
||||||
@@ -37,5 +37,9 @@
|
|||||||
"htmlToMarkdown": {
|
"htmlToMarkdown": {
|
||||||
"title": "HTML 转 Markdown",
|
"title": "HTML 转 Markdown",
|
||||||
"description": "实时 HTML 转换与 Markdown 预览"
|
"description": "实时 HTML 转换与 Markdown 预览"
|
||||||
|
},
|
||||||
|
"rightClickRestorer": {
|
||||||
|
"title": "右键恢复",
|
||||||
|
"description": "检测并恢复被网站禁用的浏览器右键菜单"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"title": "右键恢复",
|
||||||
|
"description": "检测并恢复被网站禁用的浏览器右键菜单",
|
||||||
|
"loading": "正在加载...",
|
||||||
|
"currentDomain": "当前域名",
|
||||||
|
"statusLocked": "未解锁",
|
||||||
|
"statusUnlocked": "已解锁",
|
||||||
|
"unlockDesc": "点击下方按钮,为当前网站临时解锁右键菜单。刷新页面后需要重新解锁。",
|
||||||
|
"unlockBtn": "解锁当前网站右键",
|
||||||
|
"alreadyUnlocked": "右键已解锁"
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import RightClickRestorerPage from '../index';
|
||||||
|
|
||||||
|
const mockUnlock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../useRightClickRestorer', () => ({
|
||||||
|
useRightClickRestorer: () => ({
|
||||||
|
domain: 'example.com',
|
||||||
|
isLoading: false,
|
||||||
|
isUnlocked: false,
|
||||||
|
unlock: mockUnlock,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('RightClickRestorerPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render current domain', () => {
|
||||||
|
render(<RightClickRestorerPage />);
|
||||||
|
expect(screen.getByText(/example\.com/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render locked status', () => {
|
||||||
|
render(<RightClickRestorerPage />);
|
||||||
|
expect(screen.getByText(/statusLocked/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call unlock when button clicked', () => {
|
||||||
|
render(<RightClickRestorerPage />);
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(mockUnlock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||||
|
import { useRightClickRestorer } from '../useRightClickRestorer';
|
||||||
|
import { sendMessageToContent } from '@/utils/messages';
|
||||||
|
|
||||||
|
const mockTabsQuery = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/utils/messages', () => ({
|
||||||
|
MessageAction: {
|
||||||
|
RESTORE_RIGHT_CLICK: 'restoreRightClick',
|
||||||
|
QUERY_RIGHT_CLICK_STATUS: 'queryRightClickStatus',
|
||||||
|
},
|
||||||
|
sendMessageToContent: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockTabsQuery.mockResolvedValue([{ url: 'https://example.com/path' }]);
|
||||||
|
chrome.tabs.query = mockTabsQuery;
|
||||||
|
vi.mocked(sendMessageToContent).mockResolvedValue({ success: true, restored: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useRightClickRestorer', () => {
|
||||||
|
it('should load domain and query status', async () => {
|
||||||
|
const { result } = renderHook(() => useRightClickRestorer());
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||||
|
|
||||||
|
expect(result.current.domain).toBe('example.com');
|
||||||
|
expect(result.current.isUnlocked).toBe(false);
|
||||||
|
expect(sendMessageToContent).toHaveBeenCalledWith('queryRightClickStatus');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should unlock right click', async () => {
|
||||||
|
vi.mocked(sendMessageToContent).mockResolvedValueOnce({ success: true, restored: false });
|
||||||
|
vi.mocked(sendMessageToContent).mockResolvedValueOnce({ success: true, restored: true });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRightClickRestorer());
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.unlock();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isUnlocked).toBe(true);
|
||||||
|
expect(sendMessageToContent).toHaveBeenLastCalledWith('restoreRightClick');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle sendMessage failure gracefully', async () => {
|
||||||
|
vi.mocked(sendMessageToContent).mockRejectedValue(new Error('Connection failed'));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRightClickRestorer());
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.unlock();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isUnlocked).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Shield, ShieldCheck, MousePointerClick } from 'lucide-react';
|
||||||
|
import { useRightClickRestorer } from './useRightClickRestorer';
|
||||||
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
|
||||||
|
export default function RightClickRestorerPage() {
|
||||||
|
const { t } = useLazyTranslation('rightClickRestorer');
|
||||||
|
const { domain, isLoading, isUnlocked, unlock } = useRightClickRestorer();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full animate-in fade-in duration-200">
|
||||||
|
<span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide">
|
||||||
|
{t('rightClickRestorer:loading')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 w-full flex flex-col space-y-4 animate-in fade-in duration-300">
|
||||||
|
{/* Current Domain */}
|
||||||
|
<div className="w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden transition-all">
|
||||||
|
<div className="p-4">
|
||||||
|
<Label className="text-sm font-medium">{t('rightClickRestorer:currentDomain')}</Label>
|
||||||
|
<div className="mt-2 flex items-center justify-between">
|
||||||
|
<code className="text-sm bg-muted px-2 py-1 rounded">{domain || '—'}</code>
|
||||||
|
{isUnlocked ? (
|
||||||
|
<Badge variant="default" className="gap-1 bg-green-600 hover:bg-green-700">
|
||||||
|
<ShieldCheck className="h-3 w-3" />
|
||||||
|
{t('rightClickRestorer:statusUnlocked')}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<Shield className="h-3 w-3" />
|
||||||
|
{t('rightClickRestorer:statusLocked')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unlock Action */}
|
||||||
|
<div className="w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden transition-all">
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<p className="text-xs text-muted-foreground">{t('rightClickRestorer:unlockDesc')}</p>
|
||||||
|
<Button
|
||||||
|
className="w-full gap-2"
|
||||||
|
onClick={() => void unlock()}
|
||||||
|
disabled={isUnlocked}
|
||||||
|
variant={isUnlocked ? 'secondary' : 'default'}
|
||||||
|
>
|
||||||
|
<MousePointerClick className="h-4 w-4" />
|
||||||
|
{isUnlocked
|
||||||
|
? t('rightClickRestorer:alreadyUnlocked')
|
||||||
|
: t('rightClickRestorer:unlockBtn')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { MessageAction, sendMessageToContent } from '@/utils/messages';
|
||||||
|
|
||||||
|
export interface UseRightClickRestorerReturn {
|
||||||
|
domain: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
isUnlocked: boolean;
|
||||||
|
unlock: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRightClickRestorer(): UseRightClickRestorerReturn {
|
||||||
|
const [domain, setDomain] = useState<string>('');
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [isUnlocked, setIsUnlocked] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||||
|
if (tab?.url) {
|
||||||
|
try {
|
||||||
|
setDomain(new URL(tab.url).hostname);
|
||||||
|
} catch {
|
||||||
|
setDomain('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await sendMessageToContent(MessageAction.QUERY_RIGHT_CLICK_STATUS);
|
||||||
|
if (response?.success) {
|
||||||
|
setIsUnlocked(response.restored);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RightClickRestorer] Failed to load state:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const unlock = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
|
||||||
|
if (response?.success) {
|
||||||
|
setIsUnlocked(response.restored);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RightClickRestorer] Failed to unlock:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
domain,
|
||||||
|
isLoading,
|
||||||
|
isUnlocked,
|
||||||
|
unlock,
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+2
-1
@@ -11,7 +11,8 @@ export type PageType =
|
|||||||
| 'jsonDiff' // JSON 差异比较工具
|
| 'jsonDiff' // JSON 差异比较工具
|
||||||
| 'base64Converter' // Base64 转换器工具
|
| 'base64Converter' // Base64 转换器工具
|
||||||
| 'markdownToHtml' // Markdown 转 HTML 工具
|
| 'markdownToHtml' // Markdown 转 HTML 工具
|
||||||
| 'htmlToMarkdown'; // HTML 转 Markdown 工具
|
| 'htmlToMarkdown' // HTML 转 Markdown 工具
|
||||||
|
| 'rightClickRestorer'; // 右键菜单恢复工具
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON 工具页面子模式类型定义
|
* JSON 工具页面子模式类型定义
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ export enum MessageAction {
|
|||||||
RELOAD_TAB = 'reloadTab',
|
RELOAD_TAB = 'reloadTab',
|
||||||
SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged',
|
SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged',
|
||||||
CONTEXT_MENU_CLICKED = 'contextMenuClicked',
|
CONTEXT_MENU_CLICKED = 'contextMenuClicked',
|
||||||
|
RESTORE_RIGHT_CLICK = 'restoreRightClick',
|
||||||
|
QUERY_RIGHT_CLICK_STATUS = 'queryRightClickStatus',
|
||||||
|
INJECT_MAIN_WORLD_SCRIPT = 'injectMainWorldScript',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageResponse {
|
export interface MessageResponse {
|
||||||
@@ -21,6 +24,11 @@ export interface ProtocolMap {
|
|||||||
[MessageAction.RELOAD_TAB](data: { tabId: number; delay?: number }): MessageResponse;
|
[MessageAction.RELOAD_TAB](data: { tabId: number; delay?: number }): MessageResponse;
|
||||||
[MessageAction.SIDE_PANEL_STATE_CHANGED](data: { isOpen: boolean }): void;
|
[MessageAction.SIDE_PANEL_STATE_CHANGED](data: { isOpen: boolean }): void;
|
||||||
[MessageAction.CONTEXT_MENU_CLICKED](data: ContextMenuClickedPayload): void;
|
[MessageAction.CONTEXT_MENU_CLICKED](data: ContextMenuClickedPayload): void;
|
||||||
|
[MessageAction.RESTORE_RIGHT_CLICK](data: undefined): MessageResponse & { restored: boolean };
|
||||||
|
[MessageAction.QUERY_RIGHT_CLICK_STATUS](
|
||||||
|
data: undefined,
|
||||||
|
): MessageResponse & { restored: boolean };
|
||||||
|
[MessageAction.INJECT_MAIN_WORLD_SCRIPT](data: undefined): MessageResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>();
|
export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const localeModules: Record<
|
|||||||
base64Converter: () => import('@/i18n/locales/zh/base64Converter.json'),
|
base64Converter: () => import('@/i18n/locales/zh/base64Converter.json'),
|
||||||
markdownToHtml: () => import('@/i18n/locales/zh/markdownToHtml.json'),
|
markdownToHtml: () => import('@/i18n/locales/zh/markdownToHtml.json'),
|
||||||
htmlToMarkdown: () => import('@/i18n/locales/zh/htmlToMarkdown.json'),
|
htmlToMarkdown: () => import('@/i18n/locales/zh/htmlToMarkdown.json'),
|
||||||
|
rightClickRestorer: () => import('@/i18n/locales/zh/rightClickRestorer.json'),
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
timestamp: () => import('@/i18n/locales/en/timestamp.json'),
|
timestamp: () => import('@/i18n/locales/en/timestamp.json'),
|
||||||
@@ -30,6 +31,7 @@ const localeModules: Record<
|
|||||||
base64Converter: () => import('@/i18n/locales/en/base64Converter.json'),
|
base64Converter: () => import('@/i18n/locales/en/base64Converter.json'),
|
||||||
markdownToHtml: () => import('@/i18n/locales/en/markdownToHtml.json'),
|
markdownToHtml: () => import('@/i18n/locales/en/markdownToHtml.json'),
|
||||||
htmlToMarkdown: () => import('@/i18n/locales/en/htmlToMarkdown.json'),
|
htmlToMarkdown: () => import('@/i18n/locales/en/htmlToMarkdown.json'),
|
||||||
|
rightClickRestorer: () => import('@/i18n/locales/en/rightClickRestorer.json'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user