Merge develop into main (#27)
* feat: 添加 contextMenus 权限和消息类型定义 * feat: 实现右键菜单注册与点击分流逻辑 - 新增 utils/contextMenu.ts 封装菜单配置和解析函数 - 在 background.ts 监听 onInstalled 初始化菜单 - 实现 contextMenus.onClicked 点击事件处理 - 分流逻辑:优先发送到侧边栏,否则打开 options 页面 - 添加 contextMenu 单元测试(15个测试用例) - 更新 vitest.setup.ts 添加 contextMenus mock * feat: 实现 Content Script 原位轻量提示 UI - 新增 uiPopover.ts 实现原位弹窗组件(深色主题、自动定位、自动隐藏) - 新增 contextMenuHandler.ts 处理右键菜单消息 - 时间戳转换:在点击位置显示转换结果 - 文本统计:显示字符/单词/行数/字节统计 - 更新 messageHandler.ts 挂载右键菜单消息监听 * feat: 实现 React 页面层右键菜单数据联动 - 新增 useContextMenuData Hook 处理右键菜单数据传递 - 重构 Jwt/Base64Converter/TextStatistics/QrCode 页面接收右键数据 - RouterProvider 支持从 URL 参数解析右键菜单数据 - 添加 contextMenu/pendingData 存储键到 StorageSchema - 添加 useContextMenuData 单元测试(12个测试用例) * test: 添加 background 单元测试与边界情况处理 - 新增 entrypoints/__tests__/background.test.ts (12个测试用例) - 超长文本截断限制 (>10000字符) - Base64 内联图片拦截并返回错误提示 - 更新 parseContextMenuClick 返回 ParseResult 格式 - 更新测试匹配新的返回格式 - lint/typecheck/test 全部通过 (579个测试) * fix: 修复右键菜单跳转到错误页面的问题 - 将 options 页面路径从 '/entrypoints/options/index.html' 修正为 '/options.html' - WXT 构建后 entrypoints/options/index.html 会输出为根目录的 options.html * fix: 修复右键菜单跳转目标为 popup 页面 - 将 fallback 页面从 options.html 改为 popup.html - popup 页面使用 RouterProvider,可以正确处理 URL 参数并跳转到对应功能页面 - options 页面是独立设置页,不支持 URL 参数路由 * fix: 使用 openPopup() 替代 tabs.create() 打开弹窗 - background.ts: 使用 browser.action.openPopup() 打开 popup 弹窗 - 先保存数据到 storage,然后打开 popup - RouterProvider.tsx: 初始化时检查 storage 中的 pendingData 并跳转到对应页面 - 移除之前错误的 tabs.create() 方式 * fix: 修复右键菜单数据未传递到目标页面的问题 - RouterProvider 不再提前清除 pendingData - 让目标页面的 useContextMenuData 来消费和清除数据 - 这样确保 TextStatistics 等页面能正确接收选中的文本 * fix: 修复 popup 已打开时右键菜单不生效的问题 - 在 RouterProvider 中添加对 contextMenu/pendingData 的 storage 变化监听 - 当 pendingData 变化时,自动跳转到对应功能页面 - 解决了 openPopup() 只聚焦已有窗口而不触发重新挂载的问题 * feat: 支持右键菜单图片二维码识别 - 在 qrCodeParser.ts 中添加 parseQrCodeFromUrl 函数,支持从图片 URL 解析二维码 - 在 QrCodeToUrlSection 中使用 useContextMenuData 接收右键菜单传递的图片 URL - 自动下载图片并解析二维码,显示解析结果 * refactor: 暂时移除图片二维码识别功能 - 删除 qrCode-image 右键菜单配置 - 删除 parseQrCodeFromUrl 函数 - 删除 QrCodeToUrlSection 中的 useContextMenuData 相关代码 - 更新相关测试用例 * feat: 支持右键菜单时间戳转换 - 在 useTimestampConverter 中添加 useContextMenuData hook - 智能识别输入是时间戳还是日期时间字符串 - 自动切换到对应模式并执行转换 - 时间戳自动识别秒/毫秒单位 * fix: 修复右键菜单功能的多个逻辑漏洞 1. 修复时区硬编码问题 - 使用用户选择的时区而非固定 Asia/Shanghai 2. 修复 openPopup() 失败后数据残留 - 失败时清除 storage 中的待处理数据 3. 统一数据过期时间常量 - 导出 CONTEXT_MENU_DATA_EXPIRY_MS 并统一使用 4. 修复 featureKey 类型断言不安全 - 使用映射表处理非常规菜单 ID * refactor(qrcode): 提取独立组件并添加单元测试 - 创建 pages/QrCode/types.ts 定义状态类型接口 - 提取 QrCodePreview 组件用于二维码预览和操作 - 提取 ImageUploader 组件封装图片上传、拖拽、粘贴逻辑 - 重构 UrlToQrCodeSection 状态提升到父组件 - 重构 QrCodeToUrlSection 通过回调传递解析结果 - 更新主页面 index.tsx 集中管理所有状态 - 为 QrCodePreview 和 ImageUploader 添加单元测试 (23 个用例) * refactor(qrcode): 引入通用组件并重构双栏布局 - 引入 SwitchButtonGroup 用于模式切换 - 引入 TextInputArea 用于文本输入和结果展示 - 使用 MUI Grid 构建响应式双栏布局 - 实现 generate/parse 模式下的左右面板内容切换 - 修复 iconColor 颜色格式错误,使用 qrCodePageStyles.primaryColor * refactor(qrcode): 国际化补充、质量保障与错误处理优化 - 补充 i18n 翻译键(generateMode, parseMode, pasteHint 等) - 创建 useDebounce Hook 实现输入防抖 (200ms) - 使用 useRef 解决 useEffect 无限循环问题 - 编写 pages/QrCode 单元测试 (7 个用例) - 优化右键菜单错误处理,改进用户提示 - 运行 603 个测试全部通过 * refactor(qrcode): 组件化重构,采用 Context + Hook 模式 - 创建 QrCodeContext 和 QrCodeProvider 管理共享状态 - 提取 useQrCode Hook 封装所有状态和业务逻辑 - 创建 GeneratePanel 组件处理二维码生成模式 - 创建 ParsePanel 组件处理二维码解析模式 - 简化 index.tsx 为容器组件 (340行 → 47行) - 删除未使用的旧组件文件 (QrCodeToUrlSection, UrlToQrCodeSection) - 清理 types.ts 中未使用的类型定义 - 603 个测试全部通过 * fix(qrcode): 固定二维码预览图片尺寸,避免布局抖动 - 设置 QR_PREVIEW_IMAGE 固定尺寸 250x250 - 与 QRious 生成的二维码大小保持一致 - 解决输入内容变化时预览窗口大小跳动问题 * fix(qrcode): 修复切换 tab 后二维码图片失效问题 - 移除 ImageUploader 组件卸载时的预览 URL 释放逻辑 - 在 useQrCode hook 中统一管理预览 URL 生命周期 - 创建新预览 URL 前释放旧的,避免内存泄漏 * fix(qrcode): 固定图片上传区域高度,避免布局抖动 - 将 DROPZONE 的 minHeight: 200 改为 height: 250 - 与二维码预览图片高度保持一致 - 解决上传图片时 div 高度变化问题 * refactor(qrCode): 优化二维码解析功能 - 将'二维码转 URL'改为'二维码转文本' - 将解析结果的文本预览框的 placeholder 置为空 - 去除手动解析二维码功能,只保留自动解析 * fix(pageHeader): 使用 MUI 主题色替代硬编码颜色,支持暗色模式 * feat(pageHeader): popup 模式下隐藏 PageHeader 组件 * fix(i18n): 统一使用 useLazyTranslation 避免翻译 key 闪烁 * perf: 配置 manualChunks 拆分 vendor chunk,优化打包体积 - 使用 Vite 插件 manualChunksForHtmlOnly 仅对 HTML 多入口构建生效 - 跳过 background/content-script 的 IIFE 构建(不支持 manualChunks) - 拆分 vendor-react (193KB)、vendor-mui (326KB)、vendor-i18n (55KB) - 按需加载 vendor-qr (78KB)、vendor-dnd (45KB)、vendor-markdown (41KB) - PageErrorBoundary chunk 从 454KB 降至 36KB
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
CONTEXT_MENU_CONFIGS,
|
||||
createAllContextMenus,
|
||||
parseContextMenuClick,
|
||||
MAX_PAYLOAD_LENGTH,
|
||||
} from '@/utils/contextMenu';
|
||||
|
||||
describe('contextMenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('CONTEXT_MENU_CONFIGS', () => {
|
||||
it('应该包含 7 个菜单项配置', () => {
|
||||
expect(CONTEXT_MENU_CONFIGS).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('应该有一个父级菜单项 Testing Tools', () => {
|
||||
const parentMenu = CONTEXT_MENU_CONFIGS.find((c) => c.id === 'testing-tools-parent');
|
||||
expect(parentMenu).toBeDefined();
|
||||
expect(parentMenu?.title).toBe('Testing Tools');
|
||||
expect(parentMenu?.parentId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该有 4 个 selection 上下文的子菜单', () => {
|
||||
const selectionMenus = CONTEXT_MENU_CONFIGS.filter(
|
||||
(c) => c.contexts[0] === 'selection' && c.parentId === 'testing-tools-parent',
|
||||
);
|
||||
expect(selectionMenus).toHaveLength(4);
|
||||
expect(selectionMenus.map((m) => m.id)).toEqual([
|
||||
'jwt',
|
||||
'base64Converter',
|
||||
'textStatistics',
|
||||
'timestamp',
|
||||
]);
|
||||
});
|
||||
|
||||
it('应该有 2 个 page 上下文的子菜单', () => {
|
||||
const pageMenus = CONTEXT_MENU_CONFIGS.filter(
|
||||
(c) => c.contexts[0] === 'page' && c.parentId === 'testing-tools-parent',
|
||||
);
|
||||
expect(pageMenus).toHaveLength(2);
|
||||
expect(pageMenus.map((m) => m.id)).toEqual(['storageCleaner', 'qrCode-page']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAllContextMenus', () => {
|
||||
it('应该为每个配置调用 chrome.contextMenus.create', () => {
|
||||
createAllContextMenus();
|
||||
|
||||
expect(chrome.contextMenus.create).toHaveBeenCalledTimes(7);
|
||||
});
|
||||
|
||||
it('应该使用正确的参数创建菜单项', () => {
|
||||
createAllContextMenus();
|
||||
|
||||
expect(chrome.contextMenus.create).toHaveBeenCalledWith({
|
||||
id: 'testing-tools-parent',
|
||||
title: 'Testing Tools',
|
||||
contexts: ['all'],
|
||||
parentId: undefined,
|
||||
});
|
||||
|
||||
expect(chrome.contextMenus.create).toHaveBeenCalledWith({
|
||||
id: 'jwt',
|
||||
title: '🔑 解析 JWT',
|
||||
contexts: ['selection'],
|
||||
parentId: 'testing-tools-parent',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseContextMenuClick', () => {
|
||||
const createMockOnClickData = (
|
||||
overrides: Partial<chrome.contextMenus.OnClickData> = {},
|
||||
): chrome.contextMenus.OnClickData => ({
|
||||
menuItemId: 'test',
|
||||
editable: false,
|
||||
pageUrl: 'https://example.com',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('当点击 qrCode-page 菜单时应返回 qrCode 功能和 pageUrl', () => {
|
||||
const info = createMockOnClickData({
|
||||
pageUrl: 'https://example.com/page',
|
||||
});
|
||||
|
||||
const result = parseContextMenuClick('qrCode-page', info);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { featureKey: 'qrCode', payload: 'https://example.com/page' },
|
||||
});
|
||||
});
|
||||
|
||||
it('当点击有 selectionText 的菜单时应返回对应功能和选中文本', () => {
|
||||
const info = createMockOnClickData({
|
||||
selectionText: 'selected text',
|
||||
});
|
||||
|
||||
const result = parseContextMenuClick('jwt', info);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { featureKey: 'jwt', payload: 'selected text' },
|
||||
});
|
||||
});
|
||||
|
||||
it('当点击 timestamp 菜单时应正确映射功能键', () => {
|
||||
const info = createMockOnClickData({
|
||||
selectionText: '1234567890',
|
||||
});
|
||||
|
||||
const result = parseContextMenuClick('timestamp', info);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { featureKey: 'timestamp', payload: '1234567890' },
|
||||
});
|
||||
});
|
||||
|
||||
it('当点击 storageCleaner 菜单时应返回 pageUrl', () => {
|
||||
const info = createMockOnClickData({
|
||||
pageUrl: 'https://example.com',
|
||||
});
|
||||
|
||||
const result = parseContextMenuClick('storageCleaner', info);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { featureKey: 'storageCleaner', payload: 'https://example.com' },
|
||||
});
|
||||
});
|
||||
|
||||
it('当没有 selectionText 和 pageUrl 时应返回错误', () => {
|
||||
const info = createMockOnClickData({
|
||||
pageUrl: undefined,
|
||||
});
|
||||
|
||||
const result = parseContextMenuClick('someMenu', info);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: '无法获取有效数据',
|
||||
});
|
||||
});
|
||||
|
||||
it('selectionText 优先于 pageUrl', () => {
|
||||
const info = createMockOnClickData({
|
||||
selectionText: 'selected text',
|
||||
pageUrl: 'https://example.com',
|
||||
});
|
||||
|
||||
const result = parseContextMenuClick('jwt', info);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { featureKey: 'jwt', payload: 'selected text' },
|
||||
});
|
||||
});
|
||||
|
||||
it('当文本超过最大长度限制时应截断', () => {
|
||||
const longText = 'a'.repeat(MAX_PAYLOAD_LENGTH + 1000);
|
||||
const info = createMockOnClickData({
|
||||
selectionText: longText,
|
||||
});
|
||||
|
||||
const result = parseContextMenuClick('textStatistics', info);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.payload.length).toBe(MAX_PAYLOAD_LENGTH);
|
||||
});
|
||||
|
||||
it('当文本未超过最大长度限制时应保持原样', () => {
|
||||
const shortText = 'short text';
|
||||
const info = createMockOnClickData({
|
||||
selectionText: shortText,
|
||||
});
|
||||
|
||||
const result = parseContextMenuClick('textStatistics', info);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { featureKey: 'textStatistics', payload: 'short text' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import {
|
||||
useContextMenuData,
|
||||
saveContextMenuData,
|
||||
clearContextMenuData,
|
||||
} from '@/utils/useContextMenuData';
|
||||
|
||||
describe('useContextMenuData', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('saveContextMenuData', () => {
|
||||
it('应该保存数据到 storage 并添加时间戳', async () => {
|
||||
const data = { featureKey: 'jwt' as const, payload: 'test-token' };
|
||||
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
|
||||
|
||||
await saveContextMenuData(data);
|
||||
|
||||
expect(chrome.storage.local.set).toHaveBeenCalledWith({
|
||||
'contextMenu/pendingData': {
|
||||
featureKey: 'jwt',
|
||||
payload: 'test-token',
|
||||
timestamp: 1704110400000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('应该正确处理不同的 featureKey', async () => {
|
||||
const data = { featureKey: 'timestamp' as const, payload: '1234567890' };
|
||||
|
||||
await saveContextMenuData(data);
|
||||
|
||||
expect(chrome.storage.local.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
'contextMenu/pendingData': expect.objectContaining({
|
||||
featureKey: 'timestamp',
|
||||
payload: '1234567890',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearContextMenuData', () => {
|
||||
it('应该从 storage 中删除数据', async () => {
|
||||
await clearContextMenuData();
|
||||
|
||||
expect(chrome.storage.local.remove).toHaveBeenCalledWith(['contextMenu/pendingData']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useContextMenuData Hook', () => {
|
||||
it('当 storage 中有匹配数据时应调用 onData 回调', async () => {
|
||||
const mockData = {
|
||||
featureKey: 'jwt',
|
||||
payload: 'test-token',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
(chrome.storage.local.get as any).mockResolvedValue({
|
||||
'contextMenu/pendingData': mockData,
|
||||
});
|
||||
|
||||
const onData = vi.fn();
|
||||
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onData).toHaveBeenCalledWith('test-token');
|
||||
});
|
||||
});
|
||||
|
||||
it('当 storage 中没有数据时不应调用 onData 回调', async () => {
|
||||
(chrome.storage.local.get as any).mockResolvedValue({});
|
||||
|
||||
const onData = vi.fn();
|
||||
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('当 featureKey 不匹配时不应调用 onData 回调', async () => {
|
||||
const mockData = {
|
||||
featureKey: 'timestamp',
|
||||
payload: '1234567890',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
(chrome.storage.local.get as any).mockResolvedValue({
|
||||
'contextMenu/pendingData': mockData,
|
||||
});
|
||||
|
||||
const onData = vi.fn();
|
||||
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('当数据过期时不应调用 onData 回调并删除数据', async () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const mockData = {
|
||||
featureKey: 'jwt',
|
||||
payload: 'test-token',
|
||||
timestamp: now - 6000,
|
||||
};
|
||||
(chrome.storage.local.get as any).mockResolvedValue({
|
||||
'contextMenu/pendingData': mockData,
|
||||
});
|
||||
|
||||
const onData = vi.fn();
|
||||
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onData).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.local.remove).toHaveBeenCalledWith(['contextMenu/pendingData']);
|
||||
});
|
||||
});
|
||||
|
||||
it('消费数据后应删除 storage 中的数据', async () => {
|
||||
const mockData = {
|
||||
featureKey: 'jwt',
|
||||
payload: 'test-token',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
(chrome.storage.local.get as any).mockResolvedValue({
|
||||
'contextMenu/pendingData': mockData,
|
||||
});
|
||||
|
||||
const onData = vi.fn();
|
||||
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(chrome.storage.local.remove).toHaveBeenCalledWith(['contextMenu/pendingData']);
|
||||
});
|
||||
});
|
||||
|
||||
it('当 storage 变化且 featureKey 匹配时应调用 onData 回调', async () => {
|
||||
(chrome.storage.local.get as any).mockResolvedValue({});
|
||||
|
||||
const onData = vi.fn();
|
||||
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
|
||||
|
||||
const storageChangeHandler = (chrome.storage.onChanged.addListener as any).mock.calls[0][0];
|
||||
|
||||
const mockData = {
|
||||
featureKey: 'jwt',
|
||||
payload: 'new-token',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
(chrome.storage.local.get as any).mockResolvedValue({
|
||||
'contextMenu/pendingData': mockData,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
storageChangeHandler({
|
||||
'contextMenu/pendingData': { newValue: mockData },
|
||||
});
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onData).toHaveBeenCalledWith('new-token');
|
||||
});
|
||||
});
|
||||
|
||||
it('当 storage 变化但 featureKey 不匹配时不应调用 onData 回调', async () => {
|
||||
(chrome.storage.local.get as any).mockResolvedValue({});
|
||||
|
||||
const onData = vi.fn();
|
||||
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
|
||||
|
||||
const storageChangeHandler = (chrome.storage.onChanged.addListener as any).mock.calls[0][0];
|
||||
|
||||
const mockData = {
|
||||
featureKey: 'timestamp',
|
||||
payload: '1234567890',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
storageChangeHandler({
|
||||
'contextMenu/pendingData': { newValue: mockData },
|
||||
});
|
||||
});
|
||||
|
||||
expect(onData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('当 storage 变化但数据被删除时不应调用 onData 回调', async () => {
|
||||
(chrome.storage.local.get as any).mockResolvedValue({});
|
||||
|
||||
const onData = vi.fn();
|
||||
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
|
||||
|
||||
const storageChangeHandler = (chrome.storage.onChanged.addListener as any).mock.calls[0][0];
|
||||
|
||||
await act(async () => {
|
||||
storageChangeHandler({
|
||||
'contextMenu/pendingData': { newValue: null },
|
||||
});
|
||||
});
|
||||
|
||||
expect(onData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('组件卸载时应移除 storage 变化监听器', () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useContextMenuData({ featureKey: 'jwt', onData: vi.fn() }),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(chrome.storage.onChanged.removeListener).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { PageType } from '@/types/storage';
|
||||
|
||||
export interface ContextMenuItemConfig {
|
||||
id: string;
|
||||
title: string;
|
||||
contexts: [`${chrome.contextMenus.ContextType}`, ...`${chrome.contextMenus.ContextType}`[]];
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
export interface ContextMenuClickedInfo {
|
||||
featureKey: PageType;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface ParseResult {
|
||||
success: boolean;
|
||||
data?: ContextMenuClickedInfo;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const PARENT_MENU_ID = 'testing-tools-parent';
|
||||
|
||||
export const MAX_PAYLOAD_LENGTH = 10000;
|
||||
|
||||
/** 菜单项 ID 到 PageType 的映射(仅处理非常规映射) */
|
||||
const MENU_ID_TO_PAGE_TYPE: Record<string, PageType> = {
|
||||
'qrCode-page': 'qrCode',
|
||||
};
|
||||
|
||||
/**
|
||||
* 将菜单项 ID 转换为 PageType
|
||||
* 如果存在显式映射则使用映射,否则直接使用 menuItemId
|
||||
*/
|
||||
function getMenuPageType(menuItemId: string): PageType {
|
||||
return MENU_ID_TO_PAGE_TYPE[menuItemId] ?? (menuItemId as PageType);
|
||||
}
|
||||
|
||||
export const CONTEXT_MENU_CONFIGS: ContextMenuItemConfig[] = [
|
||||
{
|
||||
id: PARENT_MENU_ID,
|
||||
title: 'Testing Tools',
|
||||
contexts: [chrome.contextMenus.ContextType.ALL],
|
||||
},
|
||||
{
|
||||
id: 'jwt',
|
||||
title: '🔑 解析 JWT',
|
||||
contexts: [chrome.contextMenus.ContextType.SELECTION],
|
||||
parentId: PARENT_MENU_ID,
|
||||
},
|
||||
{
|
||||
id: 'base64Converter',
|
||||
title: '🔄 Base64 解码',
|
||||
contexts: [chrome.contextMenus.ContextType.SELECTION],
|
||||
parentId: PARENT_MENU_ID,
|
||||
},
|
||||
{
|
||||
id: 'textStatistics',
|
||||
title: '📊 统计选中文本',
|
||||
contexts: [chrome.contextMenus.ContextType.SELECTION],
|
||||
parentId: PARENT_MENU_ID,
|
||||
},
|
||||
{
|
||||
id: 'timestamp',
|
||||
title: '⏰ 转换时间戳',
|
||||
contexts: [chrome.contextMenus.ContextType.SELECTION],
|
||||
parentId: PARENT_MENU_ID,
|
||||
},
|
||||
{
|
||||
id: 'storageCleaner',
|
||||
title: '🧹 清理当前网站存储',
|
||||
contexts: [chrome.contextMenus.ContextType.PAGE],
|
||||
parentId: PARENT_MENU_ID,
|
||||
},
|
||||
{
|
||||
id: 'qrCode-page',
|
||||
title: '🔗 网页链接转二维码',
|
||||
contexts: [chrome.contextMenus.ContextType.PAGE],
|
||||
parentId: PARENT_MENU_ID,
|
||||
},
|
||||
];
|
||||
|
||||
export function createAllContextMenus(): void {
|
||||
for (const config of CONTEXT_MENU_CONFIGS) {
|
||||
chrome.contextMenus.create({
|
||||
id: config.id,
|
||||
title: config.title,
|
||||
contexts: config.contexts,
|
||||
parentId: config.parentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function parseContextMenuClick(
|
||||
menuItemId: string,
|
||||
info: chrome.contextMenus.OnClickData,
|
||||
): ParseResult {
|
||||
const featureKey = getMenuPageType(menuItemId);
|
||||
|
||||
if (info.selectionText) {
|
||||
const text = info.selectionText;
|
||||
|
||||
if (text.length > MAX_PAYLOAD_LENGTH) {
|
||||
return {
|
||||
success: true,
|
||||
data: { featureKey, payload: text.substring(0, MAX_PAYLOAD_LENGTH) },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { featureKey, payload: text },
|
||||
};
|
||||
}
|
||||
|
||||
if (info.pageUrl) {
|
||||
return {
|
||||
success: true,
|
||||
data: { featureKey, payload: info.pageUrl },
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, error: '无法获取有效数据' };
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { defineExtensionMessaging } from '@webext-core/messaging';
|
||||
export enum MessageAction {
|
||||
RELOAD_TAB = 'reloadTab',
|
||||
SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged',
|
||||
CONTEXT_MENU_CLICKED = 'contextMenuClicked',
|
||||
}
|
||||
|
||||
export interface MessageResponse {
|
||||
@@ -11,9 +12,15 @@ export interface MessageResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ContextMenuClickedPayload {
|
||||
featureKey: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>();
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import type { ContextMenuPendingData, PageType } from '@/types/storage';
|
||||
|
||||
const STORAGE_KEY = 'contextMenu/pendingData' as const;
|
||||
|
||||
/** 右键菜单数据过期时间(毫秒) */
|
||||
export const CONTEXT_MENU_DATA_EXPIRY_MS = 5000;
|
||||
|
||||
export interface UseContextMenuDataOptions {
|
||||
/** 当前页面的功能标识 */
|
||||
featureKey: PageType;
|
||||
/** 收到数据时的回调函数 */
|
||||
onData: (payload: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义 Hook:处理右键菜单传递的数据
|
||||
*
|
||||
* 使用方式:
|
||||
* 1. 在页面组件中调用此 Hook
|
||||
* 2. 传入当前页面的 featureKey 和数据处理回调
|
||||
* 3. Hook 会自动从 storage 中读取并消费匹配的数据
|
||||
*/
|
||||
export function useContextMenuData({ featureKey, onData }: UseContextMenuDataOptions): void {
|
||||
const checkAndConsumeData = useCallback(async () => {
|
||||
try {
|
||||
const data = await storageUtil.get(STORAGE_KEY, undefined);
|
||||
|
||||
if (!data) return;
|
||||
|
||||
if (data.featureKey !== featureKey) return;
|
||||
|
||||
if (Date.now() - data.timestamp > CONTEXT_MENU_DATA_EXPIRY_MS) {
|
||||
await storageUtil.remove(STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
await storageUtil.remove(STORAGE_KEY);
|
||||
|
||||
onData(data.payload);
|
||||
} catch (error) {
|
||||
console.error('[useContextMenuData] 处理右键菜单数据失败:', error);
|
||||
}
|
||||
}, [featureKey, onData]);
|
||||
|
||||
useEffect(() => {
|
||||
checkAndConsumeData();
|
||||
}, [checkAndConsumeData]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }) => {
|
||||
if (changes[STORAGE_KEY]) {
|
||||
const newData = changes[STORAGE_KEY].newValue as ContextMenuPendingData | null;
|
||||
if (newData && newData.featureKey === featureKey) {
|
||||
checkAndConsumeData();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
chrome.storage.onChanged.addListener(handleStorageChange);
|
||||
return () => chrome.storage.onChanged.removeListener(handleStorageChange);
|
||||
}, [featureKey, checkAndConsumeData]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存右键菜单数据到 storage
|
||||
* 由 RouterProvider 或入口组件调用
|
||||
*/
|
||||
export async function saveContextMenuData(
|
||||
data: Omit<ContextMenuPendingData, 'timestamp'>,
|
||||
): Promise<void> {
|
||||
const pendingData: ContextMenuPendingData = {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
await storageUtil.set(STORAGE_KEY, pendingData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除右键菜单待处理数据
|
||||
*/
|
||||
export async function clearContextMenuData(): Promise<void> {
|
||||
await storageUtil.remove(STORAGE_KEY);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* useDebounce Hook - 防抖值
|
||||
*
|
||||
* @param value - 需要防抖的值
|
||||
* @param delay - 延迟时间(毫秒)
|
||||
* @returns 防抖后的值
|
||||
*/
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
Reference in New Issue
Block a user