feat: 实现右键菜单注册与点击分流逻辑
- 新增 utils/contextMenu.ts 封装菜单配置和解析函数 - 在 background.ts 监听 onInstalled 初始化菜单 - 实现 contextMenus.onClicked 点击事件处理 - 分流逻辑:优先发送到侧边栏,否则打开 options 页面 - 添加 contextMenu 单元测试(15个测试用例) - 更新 vitest.setup.ts 添加 contextMenus mock
This commit is contained in:
@@ -1,8 +1,36 @@
|
|||||||
import '../.wxt/types/imports.d.ts';
|
import '../.wxt/types/imports.d.ts';
|
||||||
import { browser } from 'wxt/browser';
|
import { browser } from 'wxt/browser';
|
||||||
import { MessageAction, onMessage } from '@/utils/messages';
|
import { MessageAction, onMessage, sendMessage } from '@/utils/messages';
|
||||||
|
import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMenu';
|
||||||
|
|
||||||
export default defineBackground(() => {
|
export default defineBackground(() => {
|
||||||
|
browser.runtime.onInstalled.addListener(() => {
|
||||||
|
createAllContextMenus();
|
||||||
|
});
|
||||||
|
|
||||||
|
browser.contextMenus.onClicked.addListener(async (info, _tab) => {
|
||||||
|
const result = parseContextMenuClick(info.menuItemId as string, info);
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
const { featureKey, payload } = result;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sidePanelState = await browser.storage.local.get('sidePanelOpen');
|
||||||
|
const isSidePanelOpen = sidePanelState.sidePanelOpen === true;
|
||||||
|
|
||||||
|
if (isSidePanelOpen) {
|
||||||
|
await sendMessage(MessageAction.CONTEXT_MENU_CLICKED, { featureKey, payload });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// sidepanel 未打开或无法通信,继续执行其他方案
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionsUrl = chrome.runtime.getURL('/entrypoints/options/index.html');
|
||||||
|
const params = new URLSearchParams({ feature: featureKey, payload });
|
||||||
|
await browser.tabs.create({ url: `${optionsUrl}?${params.toString()}` });
|
||||||
|
});
|
||||||
|
|
||||||
// 监听扩展图标点击事件,打开侧边栏
|
// 监听扩展图标点击事件,打开侧边栏
|
||||||
browser.action.onClicked.addListener(async (tab) => {
|
browser.action.onClicked.addListener(async (tab) => {
|
||||||
if (tab.id) {
|
if (tab.id) {
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
CONTEXT_MENU_CONFIGS,
|
||||||
|
createAllContextMenus,
|
||||||
|
parseContextMenuClick,
|
||||||
|
} from '@/utils/contextMenu';
|
||||||
|
|
||||||
|
describe('contextMenu', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CONTEXT_MENU_CONFIGS', () => {
|
||||||
|
it('应该包含 8 个菜单项配置', () => {
|
||||||
|
expect(CONTEXT_MENU_CONFIGS).toHaveLength(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
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('应该有 1 个 image 上下文的子菜单', () => {
|
||||||
|
const imageMenus = CONTEXT_MENU_CONFIGS.filter(
|
||||||
|
(c) => c.contexts[0] === 'image' && c.parentId === 'testing-tools-parent',
|
||||||
|
);
|
||||||
|
expect(imageMenus).toHaveLength(1);
|
||||||
|
expect(imageMenus[0].id).toBe('qrCode-image');
|
||||||
|
});
|
||||||
|
|
||||||
|
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(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
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-image 菜单时应返回 qrCode 功能和 srcUrl', () => {
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
srcUrl: 'https://example.com/image.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('qrCode-image', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
featureKey: 'qrCode',
|
||||||
|
payload: 'https://example.com/image.png',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当点击 qrCode-image 菜单但没有 srcUrl 时应返回空字符串', () => {
|
||||||
|
const info = createMockOnClickData({});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('qrCode-image', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
featureKey: 'qrCode',
|
||||||
|
payload: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当点击 qrCode-page 菜单时应返回 qrCode 功能和 pageUrl', () => {
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
pageUrl: 'https://example.com/page',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('qrCode-page', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
featureKey: 'qrCode',
|
||||||
|
payload: 'https://example.com/page',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当点击有 selectionText 的菜单时应返回对应功能和选中文本', () => {
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
selectionText: 'selected text',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('jwt', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
featureKey: 'jwt',
|
||||||
|
payload: 'selected text',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当点击 timestamp 菜单时应正确映射功能键', () => {
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
selectionText: '1234567890',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('timestamp', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
featureKey: 'timestamp',
|
||||||
|
payload: '1234567890',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当点击 storageCleaner 菜单时应返回 pageUrl', () => {
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
pageUrl: 'https://example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('storageCleaner', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
featureKey: 'storageCleaner',
|
||||||
|
payload: 'https://example.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当没有 selectionText 和 pageUrl 时应返回 null', () => {
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
pageUrl: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('someMenu', info);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selectionText 优先于 pageUrl', () => {
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
selectionText: 'selected text',
|
||||||
|
pageUrl: 'https://example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('jwt', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
featureKey: 'jwt',
|
||||||
|
payload: 'selected text',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PARENT_MENU_ID = 'testing-tools-parent';
|
||||||
|
|
||||||
|
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: 'qrCode-image',
|
||||||
|
title: '🖼️ 识别图中的二维码',
|
||||||
|
contexts: [chrome.contextMenus.ContextType.IMAGE],
|
||||||
|
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,
|
||||||
|
): ContextMenuClickedInfo | null {
|
||||||
|
const featureKey = menuItemId as PageType;
|
||||||
|
|
||||||
|
if (menuItemId === 'qrCode-image') {
|
||||||
|
return {
|
||||||
|
featureKey: 'qrCode',
|
||||||
|
payload: info.srcUrl || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (menuItemId === 'qrCode-page') {
|
||||||
|
return {
|
||||||
|
featureKey: 'qrCode',
|
||||||
|
payload: info.pageUrl || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.selectionText) {
|
||||||
|
return {
|
||||||
|
featureKey,
|
||||||
|
payload: info.selectionText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.pageUrl) {
|
||||||
|
return {
|
||||||
|
featureKey,
|
||||||
|
payload: info.pageUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -70,6 +70,28 @@ Object.defineProperty(global, 'chrome', {
|
|||||||
getAll: vi.fn().mockResolvedValue([]),
|
getAll: vi.fn().mockResolvedValue([]),
|
||||||
remove: vi.fn().mockResolvedValue(undefined),
|
remove: vi.fn().mockResolvedValue(undefined),
|
||||||
},
|
},
|
||||||
|
contextMenus: {
|
||||||
|
create: vi.fn(),
|
||||||
|
onClicked: {
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
},
|
||||||
|
ContextType: {
|
||||||
|
ALL: 'all',
|
||||||
|
PAGE: 'page',
|
||||||
|
FRAME: 'frame',
|
||||||
|
SELECTION: 'selection',
|
||||||
|
LINK: 'link',
|
||||||
|
EDITABLE: 'editable',
|
||||||
|
IMAGE: 'image',
|
||||||
|
VIDEO: 'video',
|
||||||
|
AUDIO: 'audio',
|
||||||
|
LAUNCHER: 'launcher',
|
||||||
|
BROWSER_ACTION: 'browser_action',
|
||||||
|
PAGE_ACTION: 'page_action',
|
||||||
|
ACTION: 'action',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
writable: true,
|
writable: true,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user