diff --git a/AGENTS.md b/AGENTS.md
index 82046db..ac91798 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,6 @@
# AGENTS.md
-WXT 浏览器扩展项目 (React 19 + TypeScript + MUI v7)。
+WXT 浏览器扩展项目 (React 19 + TypeScript)。
## 核心命令
@@ -9,6 +9,8 @@ npm run dev # Chrome 开发模式 (HMR)
npm run dev:firefox # Firefox 开发模式
npm run build # Chrome 生产构建
npm run build:firefox # Firefox 生产构建
+npm run zip # 打包 Chrome 扩展 (.output/*.zip)
+npm run zip:firefox # 打包 Firefox 扩展
npm run lint # ESLint (--max-warnings=0)
npm run typecheck # tsc --noEmit
npm run test # vitest run (单次执行)
@@ -20,48 +22,60 @@ npm run test:coverage # 带覆盖率的测试
## 验证流程
-CI 执行顺序: `lint → typecheck → test → build` (build 依赖前三者)。
+CI 执行顺序: `setup → lint/typecheck/test(并行) → build` (build 依赖前三者)。
-Pre-commit hook (lint-staged) 顺序:
+Pre-commit hook (`.husky/pre-commit` 调用 `lint-staged`):
-1. `prettier --write`
-2. `eslint --fix --max-warnings=0`
-3. `tsc --noEmit`
+- 代码文件 (`*.{ts,tsx,js,jsx,mjs}`): `eslint --fix --max-warnings=0 --no-warn-ignored` → `prettier --write`
+- 其他文件 (`*.{json,css,scss,md}`): `prettier --write`
-提交前确保三者通过。
+提交前确保 `lint` 和 `typecheck` 通过。
+
+> **注意**: Release 工作流 (`.github/workflows/release.yml`) 存在脚本名错误,typecheck 步骤使用了不存在的
+> `npm run compile`,正确命令应为 `npm run typecheck`。
+
+## WXT 生成文件
+
+- `.wxt/` 目录由 `postinstall` 自动执行 `wxt prepare` 生成,包含 TypeScript 类型声明和扩展的 tsconfig。
+- 生产构建输出到 `.output/` 目录。
+- `tsconfig.json` 继承自 `./.wxt/tsconfig.json`。
## 项目结构
```
config/features.tsx # 功能定义(路由 + 元数据的单一事实来源)
-config/pageTheme.ts # 页面级主题常量
-config/theme.ts # MUI 全局主题
entrypoints/ # 扩展入口点 (popup/, options/, sidepanel/, background.ts, content.ts)
pages/ # 功能页面组件 (懒加载)
components/ # 可复用 UI 组件
providers/ # React Context (Router, Theme 等)
+hooks/ # 自定义 React Hooks
utils/ # 工具函数与服务抽象
types/ # TypeScript 类型声明
-i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json)
+i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json 及各功能独立 JSON)
```
## 关键架构决策
-**路由**: 不使用 React Router。通过 `config/features.tsx` 的 `FEATURES` 数组管理,`RouterProvider` 根据 `PageType` 渲染对应组件。
+**路由**: 不使用 React Router。通过 `config/features.tsx` 的 `FEATURES` 数组管理,`RouterProvider` 根据 `PageType`
+渲染对应组件。支持三种渲染模式: popup / sidepanel / tab。
-**存储**: 所有 Chrome Storage 键必须在 `types/storage.d.ts` 的 `StorageSchema` 中定义。使用 `utils/chromeStorage.ts` 及其 Hook。
+**存储**: 所有 Chrome Storage 键必须在 `types/storage.d.ts` 的 `StorageSchema` 中定义。使用 `utils/chromeStorage.ts` 及其
+Hook。Router 同时使用 `chrome.storage.local` 和 `localStorage` 做快照以消除首屏闪烁。
**通信**: 使用 `@webext-core/messaging`,协议定义在 `utils/messages.ts`。
**路径别名**: `@/` 映射到项目根目录 (已在 tsconfig 和 vitest.config 中配置)。
+**浏览器兼容**: 优先使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API。
+
## 测试环境
- 环境: jsdom
- 全局变量: `vitest/globals` (describe, it, expect 等无需导入)
- Setup 文件: `vitest.setup.ts` 自动 mock:
- - `chrome.*` API (storage, tabs, runtime, cookies 等)
+ - `chrome.*` / `browser.*` API (storage, tabs, runtime, cookies 等)
- `react-i18next` (返回 key 作为翻译)
+ - `@/utils/useLazyTranslation` (返回 `ns:key` 格式翻译)
- `window.matchMedia`
- 测试文件命名: `__tests__/*.test.{ts,tsx}` 或 `*.test.{ts,tsx}`
@@ -70,14 +84,14 @@ i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json)
- 命名空间: `common` (默认), `features`
- 翻译键格式: `namespace:key` (如 `features:timestamp.title`)
- 语言: `zh` (默认), `en`
-- 添加新翻译: 编辑 `i18n/locales/{zh,en}/{common,features}.json`
+- 添加新翻译: 编辑 `i18n/locales/{zh,en}/{common,features}.json` 及对应功能独立 JSON
## 新功能开发清单
1. 在 `types/storage.d.ts` 添加 `PageType` 联合类型
2. 在 `config/features.tsx` 的 `FEATURES` 数组添加配置
3. 在 `pages/` 创建页面组件 (懒加载)
-4. 在 `i18n/locales/{zh,en}/features.json` 添加翻译
+4. 在 `i18n/locales/{zh,en}/features.json` 添加翻译(复杂功能可新建独立 JSON)
5. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
6. 添加对应的单元测试
@@ -85,14 +99,16 @@ i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json)
- 禁止使用 `any` (测试文件除外)
- 未使用变量/参数: 使用 `_` 前缀 (如 `_unused`)
-- 样式: 复杂页面样式放 `config/pageTheme.ts`,简单样式用 MUI `sx` prop
-- 格式: Prettier (100 字符宽, 单引号, 尾逗号)
+- 样式: 使用 Tailwind CSS + shadcn/ui (通过 `className` 和 `cn()` 工具)
+- 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行)
+- ESLint 使用 `typescript-eslint` 的 `projectService: true`(无需手动维护 project 路径)
## 技术栈版本
- WXT: ^0.20.26
- React: ^19.2.6
-- MUI: ^7.3.8
+- Tailwind CSS: ^3.4.19
+- shadcn/ui (基于 Radix UI + class-variance-authority)
- TypeScript: ^5.9.3
- Vitest: ^4.1.7
- i18next: ^26.2.0
diff --git a/README.md b/README.md
index 0841b9b..4c56054 100644
--- a/README.md
+++ b/README.md
@@ -41,18 +41,41 @@
- **安全检查**: 自动去除 `Bearer` 前缀,处理异常输入并提供友好提示.
- **签名查看**: 展示 JWT 签名部分,辅助验证令牌完整性.
+### 🖱️ 右键恢复工具
+
+- **当前页面检测**: 自动识别当前活动标签页的域名.
+- **一键恢复**: 解除网站对右键菜单的限制,恢复复制、粘贴等基础操作.
+- **状态可视化**: 通过 Badge 组件直观展示当前页面的锁定/解锁状态.
+
### 🖼️ 二维码工具
- **生成器**: 将当前 URL 或自定义文本快速转换为二维码,支持下载.
- **解析器**: 支持通过上传图片或粘贴图片来解析二维码内容.
+### 🔄 JSON 工具
+
+- **差异比较**: 对比两段 JSON 数据,高亮展示差异.
+- **格式化**: 支持 JSON 美化、压缩、转 YAML / TOML.
+
+### 🧰 Base64 转换器
+
+- **文本编解码**: 支持文本内容的 Base64 编码与解码.
+- **文件转换**: 支持文件与 Base64 字符串互转.
+- **图像预览**: 支持图片 Base64 编码与实时预览.
+
+### 📝 Markdown ↔ HTML 转换
+
+- **Markdown 转 HTML**: 实时预览 Markdown 渲染结果,支持导出 HTML 源码.
+- **HTML 转 Markdown**: 将 HTML 内容转换为 Markdown 格式.
+
## 技术栈
- **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
- **前端**: React 19 + TypeScript
-- **UI 组件**: Material UI (MUI) @7.x
-- **样式**: Emotion (Styled Components)
+- **UI 组件**: shadcn/ui (基于 Radix UI 的无头组件库)
+- **样式**: Tailwind CSS + class-variance-authority + cn() 工具函数
- **日期处理**: dayjs (集成 UTC 和 Timezone 插件)
+- **国际化**: i18next + react-i18next
- **通信**: @webext-core/messaging
- **存储**: Chrome Storage API (类型安全封装)
- **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成)
@@ -62,9 +85,8 @@
```text
├── components/ # 可复用 React 组件
-├── config/ # 应用配置(路由、功能元数据、主题)
-│ ├── features.tsx # 功能定义与路由映射
-│ └── pageTheme.ts # 各功能页面的视觉风格配置
+├── config/ # 应用配置
+│ └── features.tsx # 功能定义与路由映射
├── entrypoints/ # 扩展程序入口点
│ ├── popup/ # 点击图标弹出的主界面
│ ├── options/ # 扩展程序设置页面
@@ -72,10 +94,13 @@
│ ├── background.ts # 后台 Service Worker
│ └── content.ts # 网页注入脚本
├── pages/ # 各功能模块的页面组件
-├── providers/ # 全局状态提供者 (Router, Snackbar 等)
-├── types/ # TypeScript 类型声明
+├── providers/ # 全局状态提供者 (Router, Theme 等)
+├── hooks/ # 自定义 React Hooks
├── utils/ # 工具函数与服务抽象
-├── public/ # 静态资源 (图标、 manifest 资源等)
+├── types/ # TypeScript 类型声明
+├── lib/ # 通用工具函数 (cn, utils 等)
+├── i18n/ # 国际化资源
+├── public/ # 静态资源 (图标、manifest 资源等)
├── wxt.config.ts # WXT 框架核心配置
└── package.json # 项目元数据与依赖管理
```
@@ -89,15 +114,20 @@
### 常用命令
-| 命令 | 说明 |
-| ----------------------- | -------------------------------- |
-| `npm run dev` | 启动 Chrome 开发模式(支持 HMR) |
-| `npm run dev:firefox` | 启动 Firefox 开发模式 |
-| `npm run build` | 构建 Chrome 生产版本 |
-| `npm run compile` | 执行 TypeScript 类型检查 |
-| `npm run lint` | 执行 ESLint 代码规范检查 |
-| `npm run test` | 运行单元测试 |
-| `npm run test:coverage` | 生成测试覆盖率报告 |
+| 命令 | 说明 |
+| ----------------------- | --------------------------------- |
+| `npm run dev` | 启动 Chrome 开发模式(支持 HMR) |
+| `npm run dev:firefox` | 启动 Firefox 开发模式 |
+| `npm run build` | 构建 Chrome 生产版本 |
+| `npm run build:firefox` | 构建 Firefox 生产版本 |
+| `npm run zip` | 打包 Chrome 扩展 (.output/\*.zip) |
+| `npm run zip:firefox` | 打包 Firefox 扩展 |
+| `npm run typecheck` | 执行 TypeScript 类型检查 |
+| `npm run lint` | 执行 ESLint 代码规范检查 |
+| `npm run test` | 运行单元测试 |
+| `npm run test:coverage` | 生成测试覆盖率报告 |
+
+运行单个测试: `npx vitest run path/to/file.test.ts`
### 自动化流程
@@ -110,12 +140,14 @@
本扩展根据功能需要申请了以下权限:
-- `storage`: 存储用户设置和工具配置.
+- `storage` & `unlimitedStorage`: 存储用户设置、工具配置及大量数据.
- `activeTab` & `tabs`: 获取当前页面 URL 及其元数据.
-- `scripting`: 在网页中执行清理脚本.
+- `scripting`: 在网页中执行清理和右键恢复脚本.
- `cookies`: 管理和清理网站 Cookie.
- `sidePanel`: 支持在浏览器侧边栏中运行.
- `clipboardWrite`: 提供一键复制功能.
+- `contextMenus`: 注册右键菜单,支持快捷操作.
+- `alarms`: 管理后台定时任务(如清理后自动刷新).
## 浏览器支持
diff --git a/components/DecodeResultPaper.tsx b/components/DecodeResultPaper.tsx
index 617ffb5..5c20dca 100644
--- a/components/DecodeResultPaper.tsx
+++ b/components/DecodeResultPaper.tsx
@@ -10,7 +10,7 @@
import { Download } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { formatFileSize } from '@/utils/base64Converter';
-import { useTranslation } from 'react-i18next';
+import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface DecodeResultPaperProps {
/** 标题文案,由调用方传入 i18n key 对应的值(如 decodedFileOutput / decodedImageOutput) */
@@ -38,7 +38,7 @@ export default function DecodeResultPaper({
onDownload,
children,
}: DecodeResultPaperProps) {
- const { t } = useTranslation('base64Converter');
+ const { t } = useLazyTranslation('base64Converter');
return (
diff --git a/components/PageHeader.tsx b/components/PageHeader.tsx
deleted file mode 100644
index 3bd9fac..0000000
--- a/components/PageHeader.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-import { ReactNode, useMemo } from 'react';
-import { getEntryPointType } from '@/config/features';
-import { cn } from '@/lib/utils'; // shadcn 核心类名合并工具
-
-export interface PageHeaderProps extends React.HTMLAttributes
{
- /** 要显示的图标组件 */
- icon: ReactNode;
- /**
- * 图标的颜色,支持:
- * 1. Tailwind 颜色类名 (如 'text-blue-500', 'text-primary') -> 推荐
- * 2. 原生颜色值 (如 '#3b82f6')
- */
- iconColor?: string;
- /** 主标题文本 */
- title: string;
- /** 副标题文本(可选) */
- subtitle?: string;
- /** 在标题右侧显示的徽章/标签组件(可选) */
- badge?: ReactNode;
- /** 覆盖图标容器的类名 */
- iconClassName?: string;
- /** 覆盖主标题的类名 */
- titleClassName?: string;
- /** 覆盖副标题的类名 */
- subtitleClassName?: string;
-}
-
-export default function PageHeader({
- icon,
- iconColor = 'text-blue-500', // 默认改用类名,若需保持 Hex 可写 "#3b82f6"
- title,
- subtitle,
- badge,
- iconClassName,
- titleClassName,
- subtitleClassName,
- className,
- ...props
-}: PageHeaderProps) {
- const entryPointType = useMemo(() => getEntryPointType(), []);
-
- // 扩展环境判断:如果是 popup 形式则不渲染头部
- if (entryPointType === 'popup') {
- return null;
- }
-
- // 判断传入的是否是 Hex/RGB 等原生颜色值
- const isRawColor =
- iconColor.startsWith('#') || iconColor.startsWith('rgb') || iconColor.startsWith('hsl');
-
- return (
-
- {/* 图标容器 */}
-
- {/* 确保图标大小可控,通过子元素选择器约束 SVG 宽高 */}
-
{icon}
-
-
- {/* 标题区域 */}
-
- {/* 标题行(含徽章) */}
-
-
- {title}
-
- {badge &&
{badge}
}
-
-
- {/* 副标题 - 使用 p 标签(block)保证换行 */}
- {subtitle && (
-
- {subtitle}
-
- )}
-
-
- );
-}
diff --git a/components/__tests__/DecodeResultPaper.test.tsx b/components/__tests__/DecodeResultPaper.test.tsx
index 7dbc74f..a999307 100644
--- a/components/__tests__/DecodeResultPaper.test.tsx
+++ b/components/__tests__/DecodeResultPaper.test.tsx
@@ -40,7 +40,7 @@ describe('DecodeResultPaper 组件', () => {
it('应渲染下载按钮', () => {
render();
- expect(screen.getByRole('button', { name: 'download' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeInTheDocument();
});
it('应渲染 children 内容', () => {
@@ -64,7 +64,7 @@ describe('DecodeResultPaper 组件', () => {
it('点击下载按钮时应调用 onDownload', () => {
render();
- fireEvent.click(screen.getByRole('button', { name: 'download' }));
+ fireEvent.click(screen.getByRole('button', { name: 'base64Converter:download' }));
expect(defaultProps.onDownload).toHaveBeenCalledTimes(1);
});
});
@@ -72,12 +72,12 @@ describe('DecodeResultPaper 组件', () => {
describe('按钮状态', () => {
it('文件名为空时下载按钮应禁用', () => {
render();
- expect(screen.getByRole('button', { name: 'download' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeDisabled();
});
it('文件名不为空时下载按钮应启用', () => {
render();
- expect(screen.getByRole('button', { name: 'download' })).toBeEnabled();
+ expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeEnabled();
});
});
});
diff --git a/components/__tests__/PageHeader.test.tsx b/components/__tests__/PageHeader.test.tsx
deleted file mode 100644
index 0b4a8f0..0000000
--- a/components/__tests__/PageHeader.test.tsx
+++ /dev/null
@@ -1,100 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import PageHeader, { type PageHeaderProps } from '@/components/PageHeader';
-
-vi.mock('@/config/features', () => ({
- getEntryPointType: vi.fn(() => 'sidepanel'),
-}));
-
-describe('PageHeader 组件系统', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- afterEach(() => {
- vi.restoreAllMocks();
- vi.resetAllMocks();
- });
-
- const defaultProps: PageHeaderProps = {
- icon: ⏰,
- title: '时间戳转换',
- subtitle: 'Unix 毫秒数转换与格式化',
- };
-
- describe('PageHeader UI 渲染', () => {
- it('应渲染页面标题栏&副标题', () => {
- render();
- expect(screen.getByText('时间戳转换')).toBeInTheDocument();
- expect(screen.getByText('Unix 毫秒数转换与格式化')).toBeInTheDocument();
- });
-
- it('应渲染图标', () => {
- render();
- expect(screen.getByTestId('test-icon')).toBeInTheDocument();
- });
-
- it('应渲染自定义图标&图标颜色', () => {
- render(
- X}
- iconColor="#FF0000"
- />,
- );
- expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
- });
-
- it('应默认使用蓝色作为 primary 色', () => {
- render();
- const iconContainer = screen.getByTestId('test-icon').parentElement?.parentElement;
- expect(iconContainer).toHaveClass('text-blue-500');
- });
-
- it('应渲染 badge 组件', () => {
- const badge = New;
- render();
- expect(screen.getByTestId('test-badge')).toBeInTheDocument();
- });
-
- it('应支持自定义 iconClassName', () => {
- render();
- const iconContainer = screen.getByTestId('test-icon').parentElement?.parentElement;
- expect(iconContainer).toHaveClass('custom-icon-class');
- });
-
- it('应支持自定义 titleClassName', () => {
- render();
- const title = screen.getByText('时间戳转换');
- expect(title).toHaveClass('custom-title-class');
- });
-
- it('应支持自定义 subtitleClassName', () => {
- render();
- const subtitle = screen.getByText('Unix 毫秒数转换与格式化');
- expect(subtitle).toHaveClass('custom-subtitle-class');
- });
-
- it('应支持自定义 className', () => {
- const { container } = render();
- const outerElement = container.firstChild;
- expect(outerElement).toHaveClass('custom-page-header');
- });
-
- it('无副标题时不渲染副标题区域', () => {
- const { container } = render();
- const subtitles = container.querySelectorAll('.text-muted-foreground');
- expect(subtitles.length).toBe(0);
- });
- });
-
- describe('PageHeader 入口点隐藏', () => {
- it('popup 模式下应返回 null', async () => {
- const { getEntryPointType } = await import('@/config/features');
- vi.mocked(getEntryPointType).mockReturnValue('popup');
-
- const { container } = render();
- expect(container.innerHTML).toBe('');
- });
- });
-});
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/config/pageTheme.ts b/config/pageTheme.ts
deleted file mode 100644
index f3f9b80..0000000
--- a/config/pageTheme.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-export const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
-
-export const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
-
-export type UnitType = 'ms' | 's';
-export type ZoneType = (typeof ZONES)[number];
-
-export const THEME_COLORS = {
- primary: '#1976d2',
- primaryDark: '#1565c0',
- primaryLight: '#42a5f5',
- success: '#2e7d32',
- successDark: '#1b5e20',
- successLight: '#4caf50',
- warning: '#e65100',
- warningDark: '#bf360c',
- warningLight: '#ff9800',
- error: '#c62828',
- errorDark: '#b71c1c',
- errorLight: '#f44336',
- purple: '#6a1b9a',
- purpleDark: '#4a148c',
- purpleLight: '#9c27b0',
- indigo: '#303f9f',
- indigoDark: '#1a237e',
- indigoLight: '#7986cb',
- white: '#FFFFFF',
- black: '#000000',
-} as const;
-
-export const STATUS_COLORS = {
- success: THEME_COLORS.success,
- warning: THEME_COLORS.warning,
- error: THEME_COLORS.error,
- info: THEME_COLORS.primary,
-} as const;
-
-export const timestampPageStyles = {
- primaryColor: THEME_COLORS.primary,
-};
-
-export const storageCleanerPageStyles = {
- warningColor: THEME_COLORS.warning,
-};
-
-export const qrCodePageStyles = {
- primaryColor: THEME_COLORS.success,
-};
-
-export const textStatisticsPageStyles = {
- primaryColor: THEME_COLORS.purple,
-};
-
-export const base64ConverterPageStyles = {
- primaryColor: THEME_COLORS.indigo,
-};
diff --git a/docs/superpowers/specs/2026-05-13-page-error-boundary-design.md b/docs/superpowers/specs/2026-05-13-page-error-boundary-design.md
deleted file mode 100644
index e90df6d..0000000
--- a/docs/superpowers/specs/2026-05-13-page-error-boundary-design.md
+++ /dev/null
@@ -1,135 +0,0 @@
-# 为懒加载页面组件添加独立 ErrorBoundary 保护 — 设计文档
-
-- 日期:2026-05-13
-- 范围:popup / sidepanel / tab / options 入口下的页面错误隔离
-
-## 背景与目标
-
-当前应用通过 `config/features.tsx` 中的 `React.lazy()` 懒加载所有页面组件。`RouterContainer` 使用 `Suspense` 包裹动态组件,而错误边界仅在外层 `entrypoints/popup/App.tsx`、`entrypoints/sidepanel/App.tsx` 中包裹 `RouterContainer`、以及 `entrypoints/options/App.tsx` 顶层。
-
-问题:单个懒加载页面在加载或渲染时一旦抛错,错误会冒泡到全局 `ErrorBoundary`,触发全屏错误 UI,整个路由容器和 TopBar 一起被替换。用户必须刷新页面才能继续使用其他工具,体验受损。
-
-目标:将错误影响范围限制在当前页面区域;其他页面、TopBar、导航、Snackbar 不受影响;用户可在错误状态下切换到其他工具或重试当前页。
-
-## 设计概要
-
-新增 `PageErrorBoundary` 组件,专门用于页面级错误隔离,在 `RouterContainer` 的 `Suspense` 内层使用;`options/App.tsx` 也替换为同款页面级边界。现有全局 `ErrorBoundary` 保留作为兜底,覆盖 TopBar / Snackbar / RouterProvider 等同级组件。
-
-## 组件设计
-
-### `components/PageErrorBoundary.tsx`(新增)
-
-复用现有 `ErrorBoundary` 的错误捕获机制(`getDerivedStateFromError` + `componentDidCatch`),但具备以下差异:
-
-- **轻量内嵌 UI**:使用 `Paper` + 居中文本,去除全屏 `Container` + `mt:8` 布局,适配 popup 400×600 与 sidepanel 等窄屏环境。结构:
- - 图标 (`ErrorOutlineIcon`)
- - 标题:"该页面加载失败"
- - 副标题:"页面在加载或渲染时遇到错误,您可以重试或切换到其他工具。"
- - 折叠错误信息块(沿用现有错误展示样式,使用 monospace、可滚动)
- - 主操作按钮:"重试"(`RefreshIcon`)—— 重置内部 state,让子树重新挂载
-- **`resetKey` prop**:可选;当 `resetKey` 在 `componentDidUpdate` 中变化时,自动重置 `hasError` / `error`,无需用户手动操作
-- **`componentDidCatch`**:仍使用 `console.error('Uncaught error in page:', error, errorInfo)` 输出,不引入额外上报
-
-接口:
-```ts
-interface PageErrorBoundaryProps {
- children: ReactNode;
- resetKey?: string | number; // 变化时自动重置
-}
-```
-
-### `components/ErrorBoundary.tsx`(不变)
-
-保留作为全局兜底。负责捕获 TopBar、SnackbarProvider、RouterProvider 等同级组件中可能出现的错误,沿用全屏 `Container` 样式与"刷新应用"操作。
-
-## 集成点
-
-### `components/RouterContainer.tsx`(修改)
-
-在 `Suspense` 内层插入 `PageErrorBoundary`,传入 `resetKey={currentPage}`:
-
-```tsx
-}>
-
- {Component && }
-
-
-```
-
-说明:
-- `PageErrorBoundary` 置于 `Suspense` 内部,可同时捕获懒加载 chunk 加载失败(异步异常)与页面渲染期同步错误
-- `resetKey={currentPage}` 使页面切换时自动清除错误状态,无需用户干预
-- 保留外层 `Box key={currentPage}` 与动画 className,不改变页面切换语义
-
-### `entrypoints/options/App.tsx`(修改)
-
-将顶层 `` 替换为 ``。options 是单页应用,统一使用页面级错误卡片即可。
-
-### `entrypoints/popup/App.tsx`、`entrypoints/sidepanel/App.tsx`(不变)
-
-保留外层 `` 包裹 ``,作为同级组件(TopBar 等)的兜底。`PageErrorBoundary` 与全局 `ErrorBoundary` 各司其职:
-
-- 页面级(`PageErrorBoundary`):捕获懒加载页面内部错误,隔离影响范围,仅替换页面区域
-- 全局(`ErrorBoundary`):捕获 RouterContainer 自身、TopBar、Snackbar 等组件错误,作为最后兜底
-
-## 数据流 / 错误处理
-
-### 捕获路径
-- 懒加载 chunk 加载失败(网络 / CSP / chunk 缺失) → `Suspense` 内部 promise reject → `PageErrorBoundary` 捕获
-- 页面渲染期同步错误(组件抛错、null 引用等) → `PageErrorBoundary` 捕获
-- 事件回调或 Promise 中的异步错误 → React 错误边界不捕获(固有行为,本次不处理)
-
-### 恢复路径
-- **页面切换自动重置**:用户从错误页切换到其他页面 → `currentPage` 变化 → `resetKey` 变化 → `PageErrorBoundary.componentDidUpdate` 重置 → 新页面正常渲染
-- **当前页重试**:用户点击"重试" → 内部 state 重置 → 子树重新挂载 → React 重新触发 `lazy()` 加载(懒加载失败时也会重新发起 `import()`)
-- **持续错误**:若 `lazy()` chunk 始终无法加载(例如永久 404),重试会再次显示错误卡片;用户可切换到其他页面继续使用其他工具
-
-### 日志
-沿用 `console.error`;不引入 Sentry / 外部上报。
-
-## 测试策略
-
-### 新增 `components/__tests__/PageErrorBoundary.test.tsx`
-
-1. 正常渲染:子组件正常渲染时,输出原始 children
-2. 同步错误捕获:子组件抛错时,显示错误卡片(标题"该页面加载失败"、错误信息)
-3. 重试按钮恢复:错误状态下,将 children 替换为正常组件,点击"重试"按钮,重置状态并显示正常内容
-4. `resetKey` 变化自动重置:错误状态下 `resetKey` 变化时,自动清空错误并渲染新 children
-5. `resetKey` 不变保持错误:children 变化但 `resetKey` 未变化时,保持错误状态(避免误重置)
-6. 错误信息显示:错误的 `toString()` 内容能在 UI 中可见
-
-### 新增 `components/__tests__/RouterContainer.test.tsx`
-
-- 通过 mock `useRouter` 与 `FEATURES`,注入一个会抛错的懒加载组件,验证 `PageErrorBoundary` 捕获错误且 TopBar / 父容器 DOM 仍存在
-- 切换 `currentPage`(重新触发 hook 返回值)后验证错误自动清除、新页面正常渲染
-
-### 不变
-- `components/__tests__/ErrorBoundary.test.tsx` 不需改动(全局边界行为未变)
-
-### 风格
-遵循现有 `ErrorBoundary.test.tsx` 模式:`vi.spyOn(console, 'error')` 抑制噪声 + `@testing-library/react` 的 `render` + `screen.getByText` 断言。
-
-## 文件清单
-
-**新增:**
-- `components/PageErrorBoundary.tsx`
-- `components/__tests__/PageErrorBoundary.test.tsx`
-- `components/__tests__/RouterContainer.test.tsx`
-
-**修改:**
-- `components/RouterContainer.tsx` — 在 `Suspense` 内层包裹 `PageErrorBoundary resetKey={currentPage}`
-- `entrypoints/options/App.tsx` — 将 `` 替换为 ``
-
-**不变:**
-- `components/ErrorBoundary.tsx`
-- `components/__tests__/ErrorBoundary.test.tsx`
-- `entrypoints/popup/App.tsx`、`entrypoints/sidepanel/App.tsx`
-- `config/features.tsx`
-
-## 验收标准
-
-1. 单页面在懒加载或渲染时抛错,仅当前页面区域显示错误卡片,TopBar 与导航仍可点击
-2. 在错误状态下切换到其他工具,新页面能正常加载与显示
-3. 点击错误卡片中的"重试"按钮,子树重新挂载并重新触发懒加载
-4. 全局 `ErrorBoundary` 仍能捕获 TopBar / Snackbar 等同级组件的错误
-5. 所有新增测试通过;现有 `ErrorBoundary` 测试不受影响;`npm run compile`、`npm run lint`、`npm run test` 均通过
diff --git a/entrypoints/background.ts b/entrypoints/background.ts
index cf4e4c2..fcfe6eb 100644
--- a/entrypoints/background.ts
+++ b/entrypoints/background.ts
@@ -5,12 +5,12 @@ import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMen
import { saveContextMenuData } from '@/utils/useContextMenuData';
export default defineBackground(() => {
- // 1. 扩展初次安装或更新时,注册右键上下文大闸
+ // 1. 扩展初次安装或更新时,注册右键上下文菜单
browser.runtime.onInstalled.addListener(() => {
createAllContextMenus();
});
- // 2. 右键点击中央中枢路由
+ // 2. 右键点击路由
browser.contextMenus.onClicked.addListener(async (info, _tab) => {
const result = parseContextMenuClick(info.menuItemId as string, info);
@@ -24,12 +24,10 @@ export default defineBackground(() => {
const { featureKey, payload } = result.data;
try {
- // 检查侧边栏(Side Panel)的挂载激活状态
const sidePanelState = await browser.storage.local.get('sidePanelOpen');
const isSidePanelOpen = sidePanelState.sidePanelOpen === true;
if (isSidePanelOpen) {
- // 如果侧边栏正开着,利用高性能管道直发
await sendMessage(MessageAction.CONTEXT_MENU_CLICKED, { featureKey, payload });
return;
}
@@ -37,18 +35,13 @@ export default defineBackground(() => {
console.debug('[Context Menu] Side panel pipeline is not available:', err);
}
- // 💡 核心自愈机制:保存数据到共享沙箱 Storage,Popup 打开后(无论是自动还是手动)都会读取
await saveContextMenuData({ featureKey, payload });
- // 打开 popup 弹窗
try {
await browser.action.openPopup();
} catch (err) {
- // 💡 修复点:自动打开 Popup 失败时,绝对不能将 pendingData 撕毁!
- // 保持数据留在 storage 内部,由于 Service Worker 的持久化,用户之后不管什么时候手动点开图标,
- // 数据依旧完好如初,完美契合了你的设计注释!
console.warn(
- '[Context Menu] 自动打开 popup 失败,请手动点击扩展图标,暂存数据已安全保留在内存中:',
+ '[Context Menu] 自动打开 popup 失败,请手动点击扩展图标,暂存数据已安全保留:',
err,
);
}
@@ -65,7 +58,96 @@ export default defineBackground(() => {
}
});
- // 💡 3. 异步刷新请求监听
+ // 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;
@@ -75,28 +157,33 @@ export default defineBackground(() => {
});
};
- // 如果小于 1000ms(短抖动缓冲),可以使用极轻量级 setTimeout 防御
- // 如果是秒级以上的延时,为防止 Service Worker 闲置被内核销毁,应当使用 Alarms 沙箱驱动
- if (delay > 0 && delay < 1000) {
- setTimeout(executeReload, delay);
- } else if (delay >= 1000) {
- const alarmName = `reload-tab-${tabId}-${Date.now()}`;
-
- // 创建一个临时的一次性 Alarm 闹钟
- await browser.alarms.create(alarmName, { when: Date.now() + delay });
-
- // 动态注册一个一次性的生命周期续航守卫
- const alarmListener = (alarm: { name: string }) => {
- if (alarm.name === alarmName) {
- executeReload();
- browser.alarms.onAlarm.removeListener(alarmListener);
- }
- };
- browser.alarms.onAlarm.addListener(alarmListener);
- } else {
+ if (delay <= 0) {
executeReload();
+ return { success: true };
}
- return { success: true, message: '刷新请求已通过常驻 Service Worker 安全隔离区' };
+ const alarmName = `reload-tab-${tabId}-${Date.now()}`;
+
+ // 创建一次性 Alarm,由浏览器内核保障触发(不受 Service Worker 生命周期影响)
+ await browser.alarms.create(alarmName, { when: Date.now() + delay });
+
+ // 兜底清理:若 alarm 因异常未触发,delay 后 5 秒强制移除监听器并清理 alarm
+ const cleanupTimeout = setTimeout(() => {
+ browser.alarms.onAlarm.removeListener(alarmListener);
+ browser.alarms.clear(alarmName).catch(() => {});
+ }, delay + 5000);
+
+ const alarmListener = (alarm: { name: string }) => {
+ if (alarm.name !== alarmName) return;
+
+ clearTimeout(cleanupTimeout);
+ executeReload();
+ browser.alarms.onAlarm.removeListener(alarmListener);
+ browser.alarms.clear(alarmName).catch(() => {});
+ };
+
+ browser.alarms.onAlarm.addListener(alarmListener);
+
+ return { success: true };
});
});
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/options/App.tsx b/entrypoints/options/App.tsx
index 121eeb9..346be9f 100644
--- a/entrypoints/options/App.tsx
+++ b/entrypoints/options/App.tsx
@@ -1,5 +1,5 @@
import { SyntheticEvent, useEffect, useMemo, useState } from 'react';
-import { GripVertical, RefreshCw, Settings } from 'lucide-react';
+import { GripVertical, RefreshCw } from 'lucide-react';
import {
closestCenter,
DndContext,
@@ -28,7 +28,6 @@ import {
} from '@/config/features';
import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar';
import PageErrorBoundary from '@/components/PageErrorBoundary';
-import PageHeader from '@/components/PageHeader';
import { useTranslation } from 'react-i18next';
const PALETTE_COLORS: Record = {
@@ -317,12 +316,6 @@ export default function App() {
{/* 顶部标题与导航栏 */}
-
}
- iconColor="#1976d2"
- title="应用设置"
- subtitle="针对不同窗口类型独立配置 Dashboard 中显示的功能及其排序"
- />
{/* Tab 与恢复按钮同行 */}
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..897a416
--- /dev/null
+++ b/i18n/locales/en/rightClickRestorer.json
@@ -0,0 +1,13 @@
+{
+ "title": "Right Click Restorer",
+ "description": "Detect and restore disabled browser right-click menus",
+ "loading": "Loading...",
+ "currentDomain": "Current Domain",
+ "statusLocked": "Locked",
+ "statusUnlocked": "Unlocked",
+ "unsupported": "Unsupported",
+ "unsupportedDesc": "The current page is a browser internal or extension page. Right-click unlock is not available. Please switch to a regular webpage.",
+ "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..4c6bd34
--- /dev/null
+++ b/i18n/locales/zh/rightClickRestorer.json
@@ -0,0 +1,13 @@
+{
+ "title": "右键恢复",
+ "description": "检测并恢复被网站禁用的浏览器右键菜单",
+ "loading": "正在加载...",
+ "currentDomain": "当前域名",
+ "statusLocked": "未解锁",
+ "statusUnlocked": "已解锁",
+ "unsupported": "不支持",
+ "unsupportedDesc": "当前页面为浏览器内部页面或扩展页面,无法解锁右键功能。请切换到普通网页后重试。",
+ "unlockDesc": "点击下方按钮,为当前网站临时解锁右键菜单。刷新页面后需要重新解锁。",
+ "unlockBtn": "解锁当前网站右键",
+ "alreadyUnlocked": "右键已解锁"
+}
diff --git a/pages/Base64Converter/Base64ConverterSection.tsx b/pages/Base64Converter/Base64ConverterSection.tsx
index 5543201..3cc0d4e 100644
--- a/pages/Base64Converter/Base64ConverterSection.tsx
+++ b/pages/Base64Converter/Base64ConverterSection.tsx
@@ -1,6 +1,6 @@
import { Image as ImageIcon, Trash2, Upload } from 'lucide-react';
import TextInputArea from '@/components/TextInputArea';
-import { useTranslation } from 'react-i18next';
+import { useLazyTranslation } from '@/utils/useLazyTranslation';
import CopyButton from '@/components/CopyButton';
import DecodeResultPaper from '@/components/DecodeResultPaper';
import { Button } from '@/components/ui/button';
@@ -19,7 +19,7 @@ interface Base64ConverterSectionProps {
}
export default function Base64ConverterSection({ mode }: Base64ConverterSectionProps) {
- const { t } = useTranslation('base64Converter');
+ const { t } = useLazyTranslation('base64Converter');
const [direction, setDirection] = useStorageState(
`base64Converter/${mode}Mode/direction`,
diff --git a/pages/Base64Converter/FileMode.tsx b/pages/Base64Converter/FileMode.tsx
index 5cf89a1..c331fac 100644
--- a/pages/Base64Converter/FileMode.tsx
+++ b/pages/Base64Converter/FileMode.tsx
@@ -1,6 +1,6 @@
import { Trash2, Upload } from 'lucide-react';
import TextInputArea from '@/components/TextInputArea';
-import { useTranslation } from 'react-i18next';
+import { useLazyTranslation } from '@/utils/useLazyTranslation';
import CopyButton from '@/components/CopyButton';
import DecodeResultPaper from '@/components/DecodeResultPaper';
import { Button } from '@/components/ui/button';
@@ -15,7 +15,7 @@ const isValidDirection = (val: unknown): val is Base64ConvertDirection =>
val === 'encode' || val === 'decode';
export default function FileMode() {
- const { t } = useTranslation('base64Converter');
+ const { t } = useLazyTranslation('base64Converter');
const [direction, setDirection] = useStorageState(
'base64Converter/fileMode/direction',
'encode',
diff --git a/pages/Base64Converter/ImageMode.tsx b/pages/Base64Converter/ImageMode.tsx
index 7c5cd00..a61285a 100644
--- a/pages/Base64Converter/ImageMode.tsx
+++ b/pages/Base64Converter/ImageMode.tsx
@@ -1,6 +1,6 @@
import { Image as ImageIcon, Trash2 } from 'lucide-react';
import TextInputArea from '@/components/TextInputArea';
-import { useTranslation } from 'react-i18next';
+import { useLazyTranslation } from '@/utils/useLazyTranslation';
import CopyButton from '@/components/CopyButton';
import DecodeResultPaper from '@/components/DecodeResultPaper';
import { Button } from '@/components/ui/button';
@@ -15,7 +15,7 @@ const isValidDirection = (val: unknown): val is Base64ConvertDirection =>
val === 'encode' || val === 'decode';
export default function ImageMode() {
- const { t } = useTranslation('base64Converter');
+ const { t } = useLazyTranslation('base64Converter');
const [direction, setDirection] = useStorageState(
'base64Converter/imageMode/direction',
'encode',
diff --git a/pages/Base64Converter/TextMode.tsx b/pages/Base64Converter/TextMode.tsx
index c78960d..3c34e72 100644
--- a/pages/Base64Converter/TextMode.tsx
+++ b/pages/Base64Converter/TextMode.tsx
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import TextInputArea from '@/components/TextInputArea';
-import { useTranslation } from 'react-i18next';
+import { useLazyTranslation } from '@/utils/useLazyTranslation';
import CopyButton from '@/components/CopyButton';
import { base64ToText, textToBase64 } from '@/utils/base64Converter';
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
@@ -20,7 +20,7 @@ interface TextModeProps {
}
export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
- const { t } = useTranslation('base64Converter');
+ const { t } = useLazyTranslation('base64Converter');
// 1. 纯净的核心源状态机:只保留输入源和转换方向
const [input, setInput] = useState('');
diff --git a/pages/Base64Converter/__tests__/FileMode.test.tsx b/pages/Base64Converter/__tests__/FileMode.test.tsx
index 3e9f91a..4af2760 100644
--- a/pages/Base64Converter/__tests__/FileMode.test.tsx
+++ b/pages/Base64Converter/__tests__/FileMode.test.tsx
@@ -28,8 +28,8 @@ describe('FileMode', () => {
it('应该渲染文件上传区域', async () => {
render();
await waitForStorageReady();
- expect(screen.getByText('clickOrDropToFile')).toBeInTheDocument();
- expect(screen.getByText('maxFileSize')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:clickOrDropToFile')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:maxFileSize')).toBeInTheDocument();
});
it('应该处理有效的文件选择', async () => {
@@ -44,7 +44,7 @@ describe('FileMode', () => {
await waitFor(() => {
expect(screen.getByText('test.txt')).toBeInTheDocument();
- expect(screen.getByText('base64Output')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:base64Output')).toBeInTheDocument();
});
});
@@ -60,7 +60,7 @@ describe('FileMode', () => {
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
- expect(screen.getByRole('alert')).toHaveTextContent('fileSizeExceeded');
+ expect(screen.getByRole('alert')).toHaveTextContent('base64Converter:fileSizeExceeded');
});
});
@@ -76,11 +76,11 @@ describe('FileMode', () => {
expect(screen.getByText('test.txt')).toBeInTheDocument();
});
- fireEvent.click(screen.getByText('clear'));
+ fireEvent.click(screen.getByText('base64Converter:clear'));
await waitFor(() => {
expect(screen.queryByText('test.txt')).not.toBeInTheDocument();
- expect(screen.getByText('clickOrDropToFile')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:clickOrDropToFile')).toBeInTheDocument();
});
});
@@ -108,8 +108,8 @@ describe('FileMode', () => {
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
- expect(screen.getByText(/originalSize/)).toBeInTheDocument();
- expect(screen.getByText(/encodedSize/)).toBeInTheDocument();
+ expect(screen.getByText(/base64Converter:originalSize/)).toBeInTheDocument();
+ expect(screen.getByText(/base64Converter:encodedSize/)).toBeInTheDocument();
});
});
@@ -129,23 +129,25 @@ describe('FileMode', () => {
it('应该渲染 encode/decode 切换按钮', () => {
render();
- expect(screen.getByText('encode')).toBeInTheDocument();
- expect(screen.getByText('decode')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:encode')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:decode')).toBeInTheDocument();
});
it('切到 decode 应该显示 Base64 输入框', async () => {
render();
await waitForStorageReady();
- fireEvent.click(screen.getByText('decode'));
- expect(await screen.findByPlaceholderText('decodeBase64Placeholder')).toBeInTheDocument();
+ fireEvent.click(screen.getByText('base64Converter:decode'));
+ expect(
+ await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder'),
+ ).toBeInTheDocument();
});
it('解码 PDF Base64 后应该显示 application/pdf 与默认文件名 decoded.pdf', async () => {
render();
await waitForStorageReady();
- fireEvent.click(screen.getByText('decode'));
+ fireEvent.click(screen.getByText('base64Converter:decode'));
- const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
+ const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
act(() => {
@@ -153,7 +155,7 @@ describe('FileMode', () => {
});
await waitFor(() => {
- expect(screen.getByText('decodedFileOutput')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:decodedFileOutput')).toBeInTheDocument();
expect(screen.getByText(/application\/pdf/)).toBeInTheDocument();
expect(screen.getByDisplayValue('decoded.pdf')).toBeInTheDocument();
});
@@ -162,9 +164,9 @@ describe('FileMode', () => {
it('解码后的文件名应该可编辑', async () => {
render();
await waitForStorageReady();
- fireEvent.click(screen.getByText('decode'));
+ fireEvent.click(screen.getByText('base64Converter:decode'));
- const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
+ const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
act(() => {
@@ -179,24 +181,24 @@ describe('FileMode', () => {
it('解码后应该显示下载按钮', async () => {
render();
await waitForStorageReady();
- fireEvent.click(screen.getByText('decode'));
+ fireEvent.click(screen.getByText('base64Converter:decode'));
- const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
+ const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
act(() => {
vi.advanceTimersByTime(250);
});
- expect(await screen.findByText('download')).toBeInTheDocument();
+ expect(await screen.findByText('base64Converter:download')).toBeInTheDocument();
});
it('解码非法 Base64 应该显示 invalidBase64 错误', async () => {
render();
await waitForStorageReady();
- fireEvent.click(screen.getByText('decode'));
+ fireEvent.click(screen.getByText('base64Converter:decode'));
- const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
+ const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
fireEvent.change(input, { target: { value: '!!!not base64' } });
act(() => {
@@ -204,16 +206,16 @@ describe('FileMode', () => {
});
await waitFor(() => {
- expect(screen.getByText('invalidBase64')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:invalidBase64')).toBeInTheDocument();
});
});
it('切换方向时应该清空解码状态', async () => {
render();
await waitForStorageReady();
- fireEvent.click(screen.getByText('decode'));
+ fireEvent.click(screen.getByText('base64Converter:decode'));
- const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
+ const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
act(() => {
@@ -221,10 +223,10 @@ describe('FileMode', () => {
});
await waitFor(() => {
- expect(screen.getByText('decodedFileOutput')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:decodedFileOutput')).toBeInTheDocument();
});
- fireEvent.click(screen.getByText('encode'));
+ fireEvent.click(screen.getByText('base64Converter:encode'));
await waitFor(() => {
expect(screen.queryByText('decodedFileOutput')).not.toBeInTheDocument();
diff --git a/pages/Base64Converter/__tests__/ImageMode.test.tsx b/pages/Base64Converter/__tests__/ImageMode.test.tsx
index e8ccfc7..440dbe7 100644
--- a/pages/Base64Converter/__tests__/ImageMode.test.tsx
+++ b/pages/Base64Converter/__tests__/ImageMode.test.tsx
@@ -26,8 +26,8 @@ describe('ImageMode', () => {
it('应该渲染图像上传区域', async () => {
render();
await waitForStorageReady();
- expect(screen.getByText('clickOrDropToImage')).toBeInTheDocument();
- expect(screen.getByText('supportedFormats')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:clickOrDropToImage')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:supportedFormats')).toBeInTheDocument();
});
it('应该接受有效的图像文件', async () => {
@@ -40,7 +40,7 @@ describe('ImageMode', () => {
await waitFor(() => {
expect(screen.getByText('test.png')).toBeInTheDocument();
- expect(screen.getByText('base64Output')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:base64Output')).toBeInTheDocument();
});
});
@@ -53,7 +53,7 @@ describe('ImageMode', () => {
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
- expect(screen.getByRole('alert')).toHaveTextContent('unsupportedImageType');
+ expect(screen.getByRole('alert')).toHaveTextContent('base64Converter:unsupportedImageType');
});
});
@@ -68,7 +68,7 @@ describe('ImageMode', () => {
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
- expect(screen.getByRole('alert')).toHaveTextContent('fileSizeExceeded');
+ expect(screen.getByRole('alert')).toHaveTextContent('base64Converter:fileSizeExceeded');
});
});
@@ -98,11 +98,11 @@ describe('ImageMode', () => {
expect(screen.getByText('test.png')).toBeInTheDocument();
});
- fireEvent.click(screen.getByText('clear'));
+ fireEvent.click(screen.getByText('base64Converter:clear'));
await waitFor(() => {
expect(screen.queryByText('test.png')).not.toBeInTheDocument();
- expect(screen.getByText('clickOrDropToImage')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:clickOrDropToImage')).toBeInTheDocument();
});
});
@@ -124,16 +124,16 @@ describe('ImageMode', () => {
it('应该渲染 encode/decode 切换按钮', async () => {
render();
await waitForStorageReady();
- expect(screen.getByText('encode')).toBeInTheDocument();
- expect(screen.getByText('decode')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:encode')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:decode')).toBeInTheDocument();
});
it('解码 PNG Base64 后应该显示图像预览', async () => {
render();
await waitForStorageReady();
- fireEvent.click(screen.getByText('decode'));
+ fireEvent.click(screen.getByText('base64Converter:decode'));
- const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
+ const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } });
act(() => {
@@ -141,7 +141,7 @@ describe('ImageMode', () => {
});
await waitFor(() => {
- expect(screen.getByText('decodedImageOutput')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:decodedImageOutput')).toBeInTheDocument();
const img = screen.getByAltText('decoded preview');
expect(img).toBeInTheDocument();
expect(img.tagName.toLowerCase()).toBe('img');
@@ -151,9 +151,9 @@ describe('ImageMode', () => {
it('解码后默认文件名应该为 decoded.png', async () => {
render();
await waitForStorageReady();
- fireEvent.click(screen.getByText('decode'));
+ fireEvent.click(screen.getByText('base64Converter:decode'));
- const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
+ const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } });
act(() => {
@@ -166,9 +166,9 @@ describe('ImageMode', () => {
it('解码非法 Base64 应该显示 invalidBase64 错误', async () => {
render();
await waitForStorageReady();
- fireEvent.click(screen.getByText('decode'));
+ fireEvent.click(screen.getByText('base64Converter:decode'));
- const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
+ const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
fireEvent.change(input, { target: { value: '!!!not base64' } });
act(() => {
@@ -176,7 +176,7 @@ describe('ImageMode', () => {
});
await waitFor(() => {
- expect(screen.getByText('invalidBase64')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:invalidBase64')).toBeInTheDocument();
});
});
});
diff --git a/pages/Base64Converter/__tests__/TextMode.test.tsx b/pages/Base64Converter/__tests__/TextMode.test.tsx
index cae191a..c5d86d3 100644
--- a/pages/Base64Converter/__tests__/TextMode.test.tsx
+++ b/pages/Base64Converter/__tests__/TextMode.test.tsx
@@ -18,18 +18,18 @@ describe('TextMode', () => {
it('应该渲染编码/解码切换按钮', () => {
render();
- expect(screen.getByText('encode')).toBeInTheDocument();
- expect(screen.getByText('decode')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:encode')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:decode')).toBeInTheDocument();
});
it('应该渲染输入框和转换按钮', () => {
render();
- expect(screen.getByPlaceholderText('textInputPlaceholder')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('base64Converter:textInputPlaceholder')).toBeInTheDocument();
});
it('应该将文本编码为 Base64', async () => {
render();
- const input = screen.getByPlaceholderText('textInputPlaceholder');
+ const input = screen.getByPlaceholderText('base64Converter:textInputPlaceholder');
fireEvent.change(input, { target: { value: 'Hello' } });
act(() => {
@@ -37,7 +37,7 @@ describe('TextMode', () => {
});
await waitFor(() => {
- expect(screen.getByText('base64Output')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:base64Output')).toBeInTheDocument();
});
expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8=');
});
@@ -46,9 +46,9 @@ describe('TextMode', () => {
render();
// 切换到解码模式
- fireEvent.click(screen.getByText('decode'));
+ fireEvent.click(screen.getByText('base64Converter:decode'));
- const input = screen.getByPlaceholderText('base64InputPlaceholder');
+ const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
fireEvent.change(input, { target: { value: 'SGVsbG8=' } });
act(() => {
@@ -56,7 +56,7 @@ describe('TextMode', () => {
});
await waitFor(() => {
- expect(screen.getByText('textOutput')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:textOutput')).toBeInTheDocument();
});
expect(screen.getByTestId('copy-button')).toHaveTextContent('Hello');
});
@@ -65,9 +65,9 @@ describe('TextMode', () => {
render();
// 切换到解码模式
- fireEvent.click(screen.getByText('decode'));
+ fireEvent.click(screen.getByText('base64Converter:decode'));
- const input = screen.getByPlaceholderText('base64InputPlaceholder');
+ const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
fireEvent.change(input, { target: { value: 'invalid!!!' } });
act(() => {
@@ -75,7 +75,7 @@ describe('TextMode', () => {
});
await waitFor(() => {
- expect(screen.getByText('invalidBase64')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:invalidBase64')).toBeInTheDocument();
});
});
@@ -83,7 +83,7 @@ describe('TextMode', () => {
render();
// 先编码
- const input = screen.getByPlaceholderText('textInputPlaceholder');
+ const input = screen.getByPlaceholderText('base64Converter:textInputPlaceholder');
fireEvent.change(input, { target: { value: 'Hello' } });
act(() => {
@@ -95,7 +95,7 @@ describe('TextMode', () => {
});
// 切换方向
- fireEvent.click(screen.getByText('decode'));
+ fireEvent.click(screen.getByText('base64Converter:decode'));
// 输出应该被清除
await waitFor(() => {
@@ -106,7 +106,7 @@ describe('TextMode', () => {
it('点击清除按钮应该清空所有内容', async () => {
render();
- const input = screen.getByPlaceholderText('textInputPlaceholder');
+ const input = screen.getByPlaceholderText('base64Converter:textInputPlaceholder');
fireEvent.change(input, { target: { value: 'Hello' } });
act(() => {
@@ -128,48 +128,48 @@ describe('TextMode', () => {
it('解码模式下粘贴图片 data URI 时应该显示切换图像模式的提示', () => {
render();
- fireEvent.click(screen.getByText('decode'));
+ fireEvent.click(screen.getByText('base64Converter:decode'));
- const input = screen.getByPlaceholderText('base64InputPlaceholder');
+ const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
fireEvent.change(input, {
target: { value: 'data:image/png;base64,iVBORw0KGgo=' },
});
- expect(screen.getByText('imageDataUriHint')).toBeInTheDocument();
- expect(screen.getByText('switchToImageMode')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:imageDataUriHint')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:switchToImageMode')).toBeInTheDocument();
});
it('粘贴非图片 data URI 时不应该显示图像模式提示', () => {
render();
- fireEvent.click(screen.getByText('decode'));
+ fireEvent.click(screen.getByText('base64Converter:decode'));
- const input = screen.getByPlaceholderText('base64InputPlaceholder');
+ const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
fireEvent.change(input, { target: { value: 'SGVsbG8=' } });
- expect(screen.queryByText('imageDataUriHint')).not.toBeInTheDocument();
+ expect(screen.queryByText('base64Converter:imageDataUriHint')).not.toBeInTheDocument();
});
it('点击切换图像模式按钮应该调用 onSwitchToImageMode 回调', () => {
const onSwitch = vi.fn();
render();
- fireEvent.click(screen.getByText('decode'));
- const input = screen.getByPlaceholderText('base64InputPlaceholder');
+ fireEvent.click(screen.getByText('base64Converter:decode'));
+ const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
fireEvent.change(input, {
target: { value: 'data:image/png;base64,iVBORw0KGgo=' },
});
- fireEvent.click(screen.getByText('switchToImageMode'));
+ fireEvent.click(screen.getByText('base64Converter:switchToImageMode'));
expect(onSwitch).toHaveBeenCalledTimes(1);
});
it('解码模式下对二进制数据应该显示更清晰的错误', async () => {
render();
- fireEvent.click(screen.getByText('decode'));
+ fireEvent.click(screen.getByText('base64Converter:decode'));
- const input = screen.getByPlaceholderText('base64InputPlaceholder');
+ const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } });
act(() => {
@@ -177,7 +177,7 @@ describe('TextMode', () => {
});
await waitFor(() => {
- expect(screen.getByText('binaryDataDetected')).toBeInTheDocument();
+ expect(screen.getByText('base64Converter:binaryDataDetected')).toBeInTheDocument();
});
});
});
diff --git a/pages/Base64Converter/__tests__/index.test.tsx b/pages/Base64Converter/__tests__/index.test.tsx
index 41395c2..2a79764 100644
--- a/pages/Base64Converter/__tests__/index.test.tsx
+++ b/pages/Base64Converter/__tests__/index.test.tsx
@@ -73,11 +73,4 @@ describe('Base64ConverterPage', () => {
});
expect(screen.queryByTestId('text-mode')).not.toBeInTheDocument();
});
-
- it('应该渲染页面标题', async () => {
- render();
- await waitForStorageInit();
- expect(screen.getByText('base64Converter:pageTitle')).toBeInTheDocument();
- expect(screen.getByText('base64Converter:pageSubtitle')).toBeInTheDocument();
- });
});
diff --git a/pages/Base64Converter/index.tsx b/pages/Base64Converter/index.tsx
index 485dd7d..5b53d44 100644
--- a/pages/Base64Converter/index.tsx
+++ b/pages/Base64Converter/index.tsx
@@ -1,7 +1,4 @@
-import { Image as ImageIcon, Type, Upload } from 'lucide-react';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
-import PageHeader from '@/components/PageHeader';
-import { base64ConverterPageStyles } from '@/config/pageTheme';
import { useStorageState } from '@/utils/useStorageState';
import type { Base64ConverterPageMode } from '@/types/storage';
import TextMode from './TextMode';
@@ -22,22 +19,8 @@ export default function Index() {
isValidPageMode,
);
- const modeIcon: Record = {
- text: ,
- file: ,
- image: ,
- };
-
return (
-
-
(null);
const [info, setInfo] = useState(null);
diff --git a/pages/HtmlToMarkdown/index.tsx b/pages/HtmlToMarkdown/index.tsx
index a2d2001..aab869b 100644
--- a/pages/HtmlToMarkdown/index.tsx
+++ b/pages/HtmlToMarkdown/index.tsx
@@ -1,7 +1,6 @@
import { useCallback, useMemo, useState } from 'react';
-import { Code, Download, Trash2 } from 'lucide-react';
+import { Download, Trash2 } from 'lucide-react';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
-import PageHeader from '@/components/PageHeader';
import CopyButton from '@/components/CopyButton';
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { Button } from '@/components/ui/button'; // 💡 1. 全面回归规范:引入原生的 shadcn 原子 Button
@@ -51,12 +50,6 @@ export default function HtmlToMarkdownPage() {
- 统一收拢为我们先前在 Dashboard 页、JSON 工具箱制定的 p-4 space-y-4 标准极客桌面规格。
*/
-
}
- />
-
{/* 工具栏集成区 */}
diff --git a/pages/JsonTools/index.tsx b/pages/JsonTools/index.tsx
index c8ba24a..e808363 100644
--- a/pages/JsonTools/index.tsx
+++ b/pages/JsonTools/index.tsx
@@ -1,7 +1,5 @@
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
-import { ArrowRightLeft, Braces, GitCompareArrows, Minimize2 } from 'lucide-react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
-import PageHeader from '@/components/PageHeader';
import JsonDiffInput from './JsonDiffInput';
import DiffResult from './DiffResult';
import DiffNavigator from './DiffNavigator';
@@ -99,22 +97,6 @@ export default function Index() {
const activePath = diffResult && total > 0 ? diffResult.diffPaths[currentDiffIndex] : undefined;
- const modeTitles: Record
= {
- diff: { title: 'jsonDiff:pageTitle', subtitle: 'jsonDiff:pageSubtitle' },
- format: { title: 'jsonFormat:formatTitle', subtitle: 'jsonFormat:formatSubtitle' },
- yaml: { title: 'jsonFormat:yamlTitle', subtitle: 'jsonFormat:yamlSubtitle' },
- toml: { title: 'jsonFormat:tomlTitle', subtitle: 'jsonFormat:tomlSubtitle' },
- minify: { title: 'jsonFormat:minifyTitle', subtitle: 'jsonFormat:minifySubtitle' },
- };
-
- const modeIcon: Record = {
- diff: ,
- format: ,
- yaml: ,
- toml: ,
- minify: ,
- };
-
const yamlConvert: ConvertFunction = useCallback((text: string) => {
const r = jsonToYaml(text);
return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes };
@@ -132,14 +114,6 @@ export default function Index() {
return (
-
-
setPageMode(v)}
diff --git a/pages/Jwt/index.tsx b/pages/Jwt/index.tsx
index b79ca9f..189c0c7 100644
--- a/pages/Jwt/index.tsx
+++ b/pages/Jwt/index.tsx
@@ -1,6 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
-import { Key } from 'lucide-react';
-import PageHeader from '@/components/PageHeader';
import { parseJwt, stringifyJson } from '@/utils/jwt';
import CopyButton from '@/components/CopyButton';
import TextInputArea from '@/components/TextInputArea';
@@ -73,12 +71,6 @@ export default function Index() {
return (
-
} // 💡 规范锁死 Icon 宽高,抹杀闪烁
- />
-
{/* 输入终端 */}
- }
- className="pb-1"
- />
-
{/* 工具集成控制中枢 */}
diff --git a/pages/QrCode/__tests__/index.test.tsx b/pages/QrCode/__tests__/index.test.tsx
index 465b762..ff0145f 100644
--- a/pages/QrCode/__tests__/index.test.tsx
+++ b/pages/QrCode/__tests__/index.test.tsx
@@ -68,12 +68,6 @@ describe('QrCodePage', () => {
expect(screen.getByText('qrCode:qrToUrl')).toBeInTheDocument();
});
- it('应该渲染页面标题', () => {
- render(
);
- expect(screen.getByText('qrCode:pageTitle')).toBeInTheDocument();
- expect(screen.getByText('qrCode:pageSubtitle')).toBeInTheDocument();
- });
-
it('切换到解析模式应该渲染 ImageUploader', () => {
render(
);
fireEvent.click(screen.getByText('qrCode:qrToUrl'));
diff --git a/pages/QrCode/index.tsx b/pages/QrCode/index.tsx
index d9797e5..cd2713b 100644
--- a/pages/QrCode/index.tsx
+++ b/pages/QrCode/index.tsx
@@ -1,7 +1,5 @@
-import { QrCode as QrCodeIcon } from 'lucide-react'; // 💡 别名规整,防止与页面组件发生重名误判
-import PageHeader from '@/components/PageHeader';
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
-import { qrCodePageStyles } from '@/config/pageTheme';
+
import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { QrCodeContext } from './contexts/QrCodeContext';
import { useQrCode } from './hooks/useQrCode';
@@ -26,15 +24,6 @@ export default function Index() {
- 替换为标准的 p-4 呼吸内边距配合 flex flex-col space-y-4,接管系统级重排!
*/}
- {/* 标题控制栏:追加微调 py-0.5,防范文字边缘截断 */}
-
} // 💡 规范对齐:强制锁死 Icon 宽高,抹杀闪烁
- iconColor={qrCodePageStyles.primaryColor}
- className="pb-1"
- />
-
{/* 流式中央控制切流卡:注入 sm 断点防御,防范单栏状态下发生变形 */}
({
+ useRightClickRestorer: () => ({
+ domain: 'example.com',
+ isLoading: false,
+ isUnlocked: false,
+ isUnsupported: 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..929051e
--- /dev/null
+++ b/pages/RightClickRestorer/__tests__/useRightClickRestorer.test.ts
@@ -0,0 +1,92 @@
+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(result.current.isUnsupported).toBe(false);
+ expect(sendMessageToContent).toHaveBeenCalledWith('queryRightClickStatus');
+ });
+
+ it('should mark internal pages as unsupported', async () => {
+ mockTabsQuery.mockResolvedValue([{ url: 'chrome://newtab/' }]);
+ chrome.tabs.query = mockTabsQuery;
+
+ const { result } = renderHook(() => useRightClickRestorer());
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.isUnsupported).toBe(true);
+ expect(result.current.isUnlocked).toBe(false);
+ expect(sendMessageToContent).not.toHaveBeenCalled();
+ });
+
+ 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 not unlock unsupported pages', async () => {
+ mockTabsQuery.mockResolvedValue([{ url: 'chrome://settings/' }]);
+ chrome.tabs.query = mockTabsQuery;
+
+ 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);
+ expect(sendMessageToContent).not.toHaveBeenCalled();
+ });
+
+ 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..31194c2
--- /dev/null
+++ b/pages/RightClickRestorer/index.tsx
@@ -0,0 +1,85 @@
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Badge } from '@/components/ui/badge';
+import { Shield, ShieldCheck, MousePointerClick, AlertTriangle } from 'lucide-react';
+import { useRightClickRestorer } from './useRightClickRestorer';
+import { useLazyTranslation } from '@/utils/useLazyTranslation';
+
+export default function RightClickRestorerPage() {
+ const { t } = useLazyTranslation('rightClickRestorer');
+ const { domain, isLoading, isUnlocked, isUnsupported, unlock } = useRightClickRestorer();
+
+ if (isLoading) {
+ return (
+
+
+ {t('rightClickRestorer:loading')}
+
+
+ );
+ }
+
+ return (
+
+ {/* Current Domain */}
+
+
+
+
+
+ {domain || '—'}
+
+ {isUnsupported ? (
+
+
+ {t('rightClickRestorer:unsupported')}
+
+ ) : isUnlocked ? (
+
+
+ {t('rightClickRestorer:statusUnlocked')}
+
+ ) : (
+
+
+ {t('rightClickRestorer:statusLocked')}
+
+ )}
+
+
+
+
+ {/* Unlock Action */}
+
+
+ {isUnsupported ? (
+ <>
+
+ {t('rightClickRestorer:unsupportedDesc')}
+
+
+ >
+ ) : (
+ <>
+
{t('rightClickRestorer:unlockDesc')}
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/pages/RightClickRestorer/useRightClickRestorer.ts b/pages/RightClickRestorer/useRightClickRestorer.ts
new file mode 100644
index 0000000..f7abeb4
--- /dev/null
+++ b/pages/RightClickRestorer/useRightClickRestorer.ts
@@ -0,0 +1,90 @@
+import { useCallback, useEffect, useState } from 'react';
+import { MessageAction, sendMessageToContent } from '@/utils/messages';
+
+const UNSUPPORTED_PROTOCOLS = new Set([
+ 'chrome:',
+ 'chrome-extension:',
+ 'about:',
+ 'edge:',
+ 'brave:',
+]);
+
+function isUnsupportedPage(url: string | undefined): boolean {
+ if (!url) return true;
+ try {
+ const protocol = new URL(url).protocol;
+ return UNSUPPORTED_PROTOCOLS.has(protocol);
+ } catch {
+ return true;
+ }
+}
+
+export interface UseRightClickRestorerReturn {
+ domain: string;
+ isLoading: boolean;
+ isUnlocked: boolean;
+ isUnsupported: boolean;
+ unlock: () => Promise;
+}
+
+export function useRightClickRestorer(): UseRightClickRestorerReturn {
+ const [domain, setDomain] = useState('');
+ const [isLoading, setIsLoading] = useState(true);
+ const [isUnlocked, setIsUnlocked] = useState(false);
+ const [isUnsupported, setIsUnsupported] = useState(false);
+
+ useEffect(() => {
+ const load = async () => {
+ try {
+ const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
+ const url = tab?.url;
+
+ if (url) {
+ try {
+ setDomain(new URL(url).hostname);
+ } catch {
+ setDomain('');
+ }
+ }
+
+ if (isUnsupportedPage(url)) {
+ setIsUnsupported(true);
+ setIsLoading(false);
+ return;
+ }
+
+ 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 () => {
+ if (isUnsupported) return;
+
+ try {
+ const response = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
+ if (response?.success) {
+ setIsUnlocked(response.restored);
+ }
+ } catch (err) {
+ console.error('[RightClickRestorer] Failed to unlock:', err);
+ }
+ }, [isUnsupported]);
+
+ return {
+ domain,
+ isLoading,
+ isUnlocked,
+ isUnsupported,
+ unlock,
+ };
+}
diff --git a/pages/StorageCleaner/AutoRefreshToggle.tsx b/pages/StorageCleaner/AutoRefreshToggle.tsx
index 301a35c..a062723 100644
--- a/pages/StorageCleaner/AutoRefreshToggle.tsx
+++ b/pages/StorageCleaner/AutoRefreshToggle.tsx
@@ -1,17 +1,44 @@
+/**
+ * 自动刷新开关组件
+ *
+ * 用于 StorageCleaner 页面,控制是否自动刷新存储数据列表。
+ * 以卡片形式展示,左侧为标签文本,右侧为 shadcn/ui Switch 开关。
+ *
+ * @example
+ * ```tsx
+ * setReloadAfterClean(checked)}
+ * />
+ * ```
+ */
+
import React from 'react';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { cn } from '@/lib/utils';
-// 引入官方的 Switch 原子组件
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
-interface AutoRefreshToggleProps extends Omit, 'onChange'> {
- autoRefresh: boolean;
+export interface AutoRefreshToggleProps extends Omit<
+ React.HTMLAttributes,
+ 'onChange'
+> {
+ /** 开关的当前状态(受控) */
+ reloadAfterClean: boolean;
+ /** 状态变化时的回调函数 */
onChange: (checked: boolean) => void;
}
+/**
+ * 自动刷新开关
+ *
+ * @param reloadAfterClean - 当前开关状态
+ * @param onChange - 状态变化回调,接收新的布尔值
+ * @param className - 额外的 CSS 类名,用于覆盖或扩展样式
+ * @param props - 透传给外层 div 的其他 HTML 属性
+ */
export default function AutoRefreshToggle({
- autoRefresh,
+ reloadAfterClean,
onChange,
className,
...props
@@ -21,15 +48,12 @@ export default function AutoRefreshToggle({
return (
- {/* 3. 使用标准的 shadcn/ui Label 组件:
- 绑定 htmlFor 建立安全的表单无障碍桥梁,使得用户点击文字也能触发开关联动
- */}
+ {/* Label 与 Switch 通过 htmlFor + id 关联,支持点击文字触发开关 */}
);
}
diff --git a/pages/StorageCleaner/index.tsx b/pages/StorageCleaner/index.tsx
index db9a0df..b932a00 100644
--- a/pages/StorageCleaner/index.tsx
+++ b/pages/StorageCleaner/index.tsx
@@ -11,29 +11,26 @@ import { useLazyTranslation } from '@/utils/useLazyTranslation';
export default function Index() {
const { t } = useLazyTranslation('storageCleaner');
- // 1. 完美对接全新重构、无需回调参数的纯净版状态 Hook
const {
error,
isInitializing,
options,
sizes,
- autoRefresh,
+ reloadAfterClean,
loading,
result,
showConfirm,
setShowConfirm,
allSelected,
someSelected,
- handleAutoRefreshChange,
+ handleReloadAfterCleanChange,
handleOptionChange,
handleSelectAll,
handleClean,
} = useStorageCleaner();
- // 计算当前的按钮锁定状态:没有任何一项被勾选,或者正在清理中,则禁用大按钮
const isButtonDisabled = !(someSelected || allSelected) || loading;
- // 2. 初始化骨架屏:全面升级为符合 shadcn 规范的无损微动效 Spinner
if (isInitializing) {
return (
@@ -45,18 +42,12 @@ export default function Index() {
);
}
- // 拦截非法域名或受限域名的错误提示页(ErrorDisplay 内部已在上一轮做好 p-4 居中)
if (error) {
return
;
}
return (
- /* 3. 终极版页面根容器:
- - 彻底灌入 p-4,全局对齐线向内收缩 16px,终结贴边惨剧。
- - 用 space-y-3.5 替代块级外边距(mb-3 等),让所有卡片之间的垂直间距处于绝对一致的黄金比例。
- */
- {/* 存储网格核心中控面板 (内部已满血复活半选状态) */}
- {/* 自动刷新开关 */}
-
+
- {/* 4. 主行动按钮超进化:
- - 彻底剥离 bg-amber-500,全面回归系统标准的 variant="destructive"。
- - 享受高风险操作该有的危险红警示,完美契合上一轮重构的二次确认弹窗基调。
- - 高级动态加载:当处于 cleaning 状态时,文字自动流转,且左侧自动淡入等宽 Loader2 图标。
- */}