diff --git a/config/__tests__/features.test.ts b/config/__tests__/features.test.ts index 5ddcd27..ac6e29a 100644 --- a/config/__tests__/features.test.ts +++ b/config/__tests__/features.test.ts @@ -9,8 +9,8 @@ import { describe('features', () => { describe('FEATURES', () => { - it('should have 10 features defined', () => { - expect(FEATURES).toHaveLength(10); + it('should have 11 features defined', () => { + expect(FEATURES).toHaveLength(11); }); it('should have all required properties for each feature', () => { @@ -95,7 +95,7 @@ describe('features', () => { describe('getAllFeatureKeys', () => { it('should return all feature keys', () => { const allKeys = getAllFeatureKeys(); - expect(allKeys).toHaveLength(10); + expect(allKeys).toHaveLength(11); expect(allKeys).toContain('dashboard'); expect(allKeys).toContain('timestamp'); expect(allKeys).toContain('storageCleaner'); @@ -106,6 +106,7 @@ describe('features', () => { expect(allKeys).toContain('base64Converter'); expect(allKeys).toContain('markdownToHtml'); expect(allKeys).toContain('htmlToMarkdown'); + expect(allKeys).toContain('rightClickRestorer'); }); }); @@ -122,9 +123,9 @@ describe('features', () => { expect(pageOrder).toContain('qrCode'); }); - it('should have 9 items in page order', () => { + it('should have 10 items in page order', () => { const pageOrder = getDefaultPageOrder(); - expect(pageOrder).toHaveLength(9); + expect(pageOrder).toHaveLength(10); }); }); }); diff --git a/config/features.tsx b/config/features.tsx index 3f2def8..3dc631e 100644 --- a/config/features.tsx +++ b/config/features.tsx @@ -11,6 +11,7 @@ import { ArrowLeftRight, Code, File, + MousePointerClick, } from 'lucide-react'; 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 MarkdownToHtmlPage = lazy(() => import('@/pages/MarkdownToHtml')); const HtmlToMarkdownPage = lazy(() => import('@/pages/HtmlToMarkdown')); +const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer')); export interface FeatureConfig { key: PageType; @@ -170,6 +172,19 @@ export const FEATURES: FeatureConfig[] = [ 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 { diff --git a/entrypoints/background.ts b/entrypoints/background.ts index 3b4c0c4..fcfe6eb 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -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; + 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; diff --git a/entrypoints/content/contextMenuHandler.ts b/entrypoints/content/contextMenuHandler.ts index 249c1ff..651d0c0 100644 --- a/entrypoints/content/contextMenuHandler.ts +++ b/entrypoints/content/contextMenuHandler.ts @@ -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(); }; diff --git a/entrypoints/rightClickRestorer.content.ts b/entrypoints/rightClickRestorer.content.ts new file mode 100644 index 0000000..5d59201 --- /dev/null +++ b/entrypoints/rightClickRestorer.content.ts @@ -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 { + 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 { + 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: [''], + runAt: 'document_start', + main() { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', createBadge, { once: true }); + } else { + createBadge(); + } + initMessaging(); + }, +}); diff --git a/i18n/locales/en/features.json b/i18n/locales/en/features.json index b33b26c..96dc086 100644 --- a/i18n/locales/en/features.json +++ b/i18n/locales/en/features.json @@ -37,5 +37,9 @@ "htmlToMarkdown": { "title": "HTML to Markdown", "description": "Real-time HTML conversion and Markdown preview" + }, + "rightClickRestorer": { + "title": "Right Click Restorer", + "description": "Detect and restore disabled browser right-click menus" } } diff --git a/i18n/locales/en/rightClickRestorer.json b/i18n/locales/en/rightClickRestorer.json new file mode 100644 index 0000000..7bcee1a --- /dev/null +++ b/i18n/locales/en/rightClickRestorer.json @@ -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" +} diff --git a/i18n/locales/zh/features.json b/i18n/locales/zh/features.json index 660c45b..3cbfe0b 100644 --- a/i18n/locales/zh/features.json +++ b/i18n/locales/zh/features.json @@ -37,5 +37,9 @@ "htmlToMarkdown": { "title": "HTML 转 Markdown", "description": "实时 HTML 转换与 Markdown 预览" + }, + "rightClickRestorer": { + "title": "右键恢复", + "description": "检测并恢复被网站禁用的浏览器右键菜单" } } diff --git a/i18n/locales/zh/rightClickRestorer.json b/i18n/locales/zh/rightClickRestorer.json new file mode 100644 index 0000000..fb5cb85 --- /dev/null +++ b/i18n/locales/zh/rightClickRestorer.json @@ -0,0 +1,11 @@ +{ + "title": "右键恢复", + "description": "检测并恢复被网站禁用的浏览器右键菜单", + "loading": "正在加载...", + "currentDomain": "当前域名", + "statusLocked": "未解锁", + "statusUnlocked": "已解锁", + "unlockDesc": "点击下方按钮,为当前网站临时解锁右键菜单。刷新页面后需要重新解锁。", + "unlockBtn": "解锁当前网站右键", + "alreadyUnlocked": "右键已解锁" +} diff --git a/pages/RightClickRestorer/__tests__/index.test.tsx b/pages/RightClickRestorer/__tests__/index.test.tsx new file mode 100644 index 0000000..b355971 --- /dev/null +++ b/pages/RightClickRestorer/__tests__/index.test.tsx @@ -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(); + expect(screen.getByText(/example\.com/)).toBeInTheDocument(); + }); + + it('should render locked status', () => { + render(); + expect(screen.getByText(/statusLocked/)).toBeInTheDocument(); + }); + + it('should call unlock when button clicked', () => { + render(); + const button = screen.getByRole('button'); + fireEvent.click(button); + expect(mockUnlock).toHaveBeenCalled(); + }); +}); diff --git a/pages/RightClickRestorer/__tests__/useRightClickRestorer.test.ts b/pages/RightClickRestorer/__tests__/useRightClickRestorer.test.ts new file mode 100644 index 0000000..4f1b414 --- /dev/null +++ b/pages/RightClickRestorer/__tests__/useRightClickRestorer.test.ts @@ -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); + }); +}); diff --git a/pages/RightClickRestorer/index.tsx b/pages/RightClickRestorer/index.tsx new file mode 100644 index 0000000..a0539cd --- /dev/null +++ b/pages/RightClickRestorer/index.tsx @@ -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 ( +
+ + {t('rightClickRestorer:loading')} + +
+ ); + } + + return ( +
+ {/* Current Domain */} +
+
+ +
+ {domain || '—'} + {isUnlocked ? ( + + + {t('rightClickRestorer:statusUnlocked')} + + ) : ( + + + {t('rightClickRestorer:statusLocked')} + + )} +
+
+
+ + {/* Unlock Action */} +
+
+

{t('rightClickRestorer:unlockDesc')}

+ +
+
+
+ ); +} diff --git a/pages/RightClickRestorer/useRightClickRestorer.ts b/pages/RightClickRestorer/useRightClickRestorer.ts new file mode 100644 index 0000000..558d42b --- /dev/null +++ b/pages/RightClickRestorer/useRightClickRestorer.ts @@ -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; +} + +export function useRightClickRestorer(): UseRightClickRestorerReturn { + const [domain, setDomain] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isUnlocked, setIsUnlocked] = useState(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, + }; +} diff --git a/types/storage.d.ts b/types/storage.d.ts index c7394ce..dc13b16 100644 --- a/types/storage.d.ts +++ b/types/storage.d.ts @@ -11,7 +11,8 @@ export type PageType = | 'jsonDiff' // JSON 差异比较工具 | 'base64Converter' // Base64 转换器工具 | 'markdownToHtml' // Markdown 转 HTML 工具 - | 'htmlToMarkdown'; // HTML 转 Markdown 工具 + | 'htmlToMarkdown' // HTML 转 Markdown 工具 + | 'rightClickRestorer'; // 右键菜单恢复工具 /** * JSON 工具页面子模式类型定义 diff --git a/utils/messages.ts b/utils/messages.ts index 075b992..476c4bd 100644 --- a/utils/messages.ts +++ b/utils/messages.ts @@ -4,6 +4,9 @@ export enum MessageAction { RELOAD_TAB = 'reloadTab', SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged', CONTEXT_MENU_CLICKED = 'contextMenuClicked', + RESTORE_RIGHT_CLICK = 'restoreRightClick', + QUERY_RIGHT_CLICK_STATUS = 'queryRightClickStatus', + INJECT_MAIN_WORLD_SCRIPT = 'injectMainWorldScript', } export interface MessageResponse { @@ -21,6 +24,11 @@ export interface ProtocolMap { [MessageAction.RELOAD_TAB](data: { tabId: number; delay?: number }): MessageResponse; [MessageAction.SIDE_PANEL_STATE_CHANGED](data: { isOpen: boolean }): 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(); diff --git a/utils/useLazyTranslation.ts b/utils/useLazyTranslation.ts index 0fa32d4..1ef60e0 100644 --- a/utils/useLazyTranslation.ts +++ b/utils/useLazyTranslation.ts @@ -18,6 +18,7 @@ const localeModules: Record< base64Converter: () => import('@/i18n/locales/zh/base64Converter.json'), markdownToHtml: () => import('@/i18n/locales/zh/markdownToHtml.json'), htmlToMarkdown: () => import('@/i18n/locales/zh/htmlToMarkdown.json'), + rightClickRestorer: () => import('@/i18n/locales/zh/rightClickRestorer.json'), }, en: { timestamp: () => import('@/i18n/locales/en/timestamp.json'), @@ -30,6 +31,7 @@ const localeModules: Record< base64Converter: () => import('@/i18n/locales/en/base64Converter.json'), markdownToHtml: () => import('@/i18n/locales/en/markdownToHtml.json'), htmlToMarkdown: () => import('@/i18n/locales/en/htmlToMarkdown.json'), + rightClickRestorer: () => import('@/i18n/locales/en/rightClickRestorer.json'), }, };