diff --git a/entrypoints/__tests__/background.test.ts b/entrypoints/__tests__/background.test.ts new file mode 100644 index 0000000..21d5d32 --- /dev/null +++ b/entrypoints/__tests__/background.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { + createAllContextMenus, + parseContextMenuClick, + CONTEXT_MENU_CONFIGS, + MAX_PAYLOAD_LENGTH, +} from '@/utils/contextMenu'; +import { MessageAction } from '@/utils/messages'; + +describe('background 菜单注册与分流', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('菜单注册', () => { + it('应该调用 createAllContextMenus 创建所有菜单项', () => { + createAllContextMenus(); + + expect(chrome.contextMenus.create).toHaveBeenCalledTimes(CONTEXT_MENU_CONFIGS.length); + }); + + it('应该创建父级菜单 Testing Tools', () => { + createAllContextMenus(); + + expect(chrome.contextMenus.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'testing-tools-parent', + title: 'Testing Tools', + }), + ); + }); + + it('应该创建 JWT 解析子菜单', () => { + createAllContextMenus(); + + expect(chrome.contextMenus.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'jwt', + title: '🔑 解析 JWT', + parentId: 'testing-tools-parent', + }), + ); + }); + + it('应该创建网页链接转二维码子菜单', () => { + createAllContextMenus(); + + expect(chrome.contextMenus.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'qrCode-page', + title: '🔗 网页链接转二维码', + contexts: ['page'], + }), + ); + }); + }); + + describe('菜单点击解析', () => { + const createMockOnClickData = ( + overrides: Partial = {}, + ): chrome.contextMenus.OnClickData => ({ + menuItemId: 'test', + editable: false, + pageUrl: 'https://example.com', + ...overrides, + }); + + it('当有 selectionText 时应返回对应的 featureKey 和 payload', () => { + const info = createMockOnClickData({ + menuItemId: 'jwt', + selectionText: 'test-token', + }); + + const result = parseContextMenuClick('jwt', info); + + expect(result).toEqual({ + success: true, + data: { featureKey: 'jwt', payload: 'test-token' }, + }); + }); + + it('当没有 selectionText 和 srcUrl 时应返回错误', () => { + const info = createMockOnClickData({ + menuItemId: 'unknown', + pageUrl: undefined, + }); + + const result = parseContextMenuClick('unknown', info); + + expect(result).toEqual({ + success: false, + error: '无法获取有效数据', + }); + }); + + it('当文本超过最大长度限制时应截断', () => { + const longText = 'a'.repeat(MAX_PAYLOAD_LENGTH + 1000); + const info = createMockOnClickData({ + menuItemId: 'textStatistics', + 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({ + menuItemId: 'textStatistics', + selectionText: shortText, + }); + + const result = parseContextMenuClick('textStatistics', info); + + expect(result).toEqual({ + success: true, + data: { featureKey: 'textStatistics', payload: 'short text' }, + }); + }); + + it('应该正确处理页面 URL 菜单点击', () => { + const info = createMockOnClickData({ + menuItemId: 'storageCleaner', + pageUrl: 'https://example.com/page', + }); + + const result = parseContextMenuClick('storageCleaner', info); + + expect(result).toEqual({ + success: true, + data: { featureKey: 'storageCleaner', payload: 'https://example.com/page' }, + }); + }); + }); + + describe('消息类型定义', () => { + it('CONTEXT_MENU_CLICKED 消息类型应正确定义', () => { + expect(MessageAction.CONTEXT_MENU_CLICKED).toBe('contextMenuClicked'); + }); + }); +}); diff --git a/entrypoints/background.ts b/entrypoints/background.ts index 549ca01..c835720 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -1,8 +1,51 @@ import '../.wxt/types/imports.d.ts'; import { browser } from 'wxt/browser'; -import { MessageAction, onMessage } from '@/utils/messages'; +import { MessageAction, onMessage, sendMessage } from '@/utils/messages'; +import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMenu'; +import { saveContextMenuData } from '@/utils/useContextMenuData'; 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.success || !result.data) { + if (result.error) { + console.warn('[Context Menu]', result.error); + } + return; + } + + const { featureKey, payload } = result.data; + + 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 未打开或无法通信,继续执行其他方案 + } + + // 保存数据到 storage,popup 打开后会读取 + await saveContextMenuData({ featureKey, payload }); + + // 打开 popup 弹窗 + try { + await browser.action.openPopup(); + } catch (err) { + console.error('[Context Menu] 打开 popup 失败:', err); + // 打开失败时清除残留数据,避免下次打开 popup 时误触发 + await chrome.storage.local.remove('contextMenu/pendingData'); + } + }); + // 监听扩展图标点击事件,打开侧边栏 browser.action.onClicked.addListener(async (tab) => { if (tab.id) { diff --git a/entrypoints/content/contextMenuHandler.ts b/entrypoints/content/contextMenuHandler.ts new file mode 100644 index 0000000..f412274 --- /dev/null +++ b/entrypoints/content/contextMenuHandler.ts @@ -0,0 +1,62 @@ +import { MessageAction, onMessage } from '@/utils/messages'; +import { getTextStats } from '@/utils/textStatistics'; +import { showTimestampResult, showTextStatsResult, hidePopover } from './uiPopover'; +import type { ContextMenuClickedPayload } from '@/utils/messages'; + +function convertTimestamp(input: string): string { + const num = Number(input.trim()); + if (isNaN(num)) { + return '无效时间戳'; + } + + const d = num > 1e12 ? new Date(num) : new Date(num * 1000); + + if (isNaN(d.getTime())) { + return '无效时间戳'; + } + + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + const hours = String(d.getHours()).padStart(2, '0'); + const minutes = String(d.getMinutes()).padStart(2, '0'); + const seconds = String(d.getSeconds()).padStart(2, '0'); + + return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`; +} + +let lastClickX = 0; +let lastClickY = 0; + +document.addEventListener( + 'contextmenu', + (e) => { + lastClickX = e.clientX; + lastClickY = e.clientY; + }, + true, +); + +export function initContextMenuHandler(): void { + onMessage(MessageAction.CONTEXT_MENU_CLICKED, (message) => { + const { featureKey, payload } = message.data as ContextMenuClickedPayload; + + switch (featureKey) { + case 'timestamp': { + const result = convertTimestamp(payload); + showTimestampResult(lastClickX, lastClickY, payload, result); + break; + } + + case 'textStatistics': { + const stats = getTextStats(payload); + showTextStatsResult(lastClickX, lastClickY, payload, stats); + break; + } + + default: + hidePopover(); + break; + } + }); +} diff --git a/entrypoints/content/messageHandler.ts b/entrypoints/content/messageHandler.ts index 7993b0c..26b3743 100644 --- a/entrypoints/content/messageHandler.ts +++ b/entrypoints/content/messageHandler.ts @@ -1 +1,5 @@ -export function initMessageHandler() {} +import { initContextMenuHandler } from './contextMenuHandler'; + +export function initMessageHandler(): void { + initContextMenuHandler(); +} diff --git a/entrypoints/content/uiPopover.ts b/entrypoints/content/uiPopover.ts new file mode 100644 index 0000000..008f82c --- /dev/null +++ b/entrypoints/content/uiPopover.ts @@ -0,0 +1,247 @@ +const POPOVER_ID = 'testing-tools-popover'; +const POPOVER_STYLE_ID = 'testing-tools-popover-style'; + +function injectStyles(): void { + if (document.getElementById(POPOVER_STYLE_ID)) return; + + const style = document.createElement('style'); + style.id = POPOVER_STYLE_ID; + style.textContent = ` + #${POPOVER_ID} { + position: fixed; + z-index: 2147483647; + max-width: 400px; + min-width: 200px; + padding: 12px 16px; + background: #1a1a2e; + color: #e0e0e0; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 13px; + line-height: 1.5; + opacity: 0; + transform: translateY(-8px); + transition: opacity 0.2s ease, transform 0.2s ease; + pointer-events: none; + } + + #${POPOVER_ID}.visible { + opacity: 1; + transform: translateY(0); + pointer-events: auto; + } + + #${POPOVER_ID} .popover-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + } + + #${POPOVER_ID} .popover-title { + font-weight: 600; + font-size: 12px; + color: #a0a0b0; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + #${POPOVER_ID} .popover-close { + background: none; + border: none; + color: #808090; + cursor: pointer; + padding: 2px; + font-size: 16px; + line-height: 1; + } + + #${POPOVER_ID} .popover-close:hover { + color: #e0e0e0; + } + + #${POPOVER_ID} .popover-content { + word-break: break-all; + white-space: pre-wrap; + } + + #${POPOVER_ID} .popover-label { + color: #808090; + font-size: 11px; + margin-bottom: 4px; + } + + #${POPOVER_ID} .popover-value { + color: #ffffff; + font-family: 'SF Mono', 'Consolas', 'Monaco', monospace; + font-size: 14px; + padding: 6px 8px; + background: rgba(255, 255, 255, 0.05); + border-radius: 4px; + margin-bottom: 8px; + } + + #${POPOVER_ID} .popover-value:last-child { + margin-bottom: 0; + } + + #${POPOVER_ID} .stat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + } + + #${POPOVER_ID} .stat-item { + padding: 6px 8px; + background: rgba(255, 255, 255, 0.05); + border-radius: 4px; + } + + #${POPOVER_ID} .stat-label { + font-size: 11px; + color: #808090; + } + + #${POPOVER_ID} .stat-value { + font-size: 16px; + font-weight: 600; + color: #ffffff; + } + `; + document.head.appendChild(style); +} + +function getOrCreatePopover(): HTMLElement { + let popover = document.getElementById(POPOVER_ID); + if (!popover) { + injectStyles(); + popover = document.createElement('div'); + popover.id = POPOVER_ID; + document.body.appendChild(popover); + } + return popover; +} + +function positionPopover(popover: HTMLElement, x: number, y: number): void { + const rect = popover.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let left = x; + let top = y; + + if (left + rect.width > viewportWidth - 16) { + left = viewportWidth - rect.width - 16; + } + if (left < 16) { + left = 16; + } + + if (top + rect.height > viewportHeight - 16) { + top = y - rect.height - 8; + } + if (top < 16) { + top = 16; + } + + popover.style.left = `${left}px`; + popover.style.top = `${top}px`; +} + +let hideTimeout: ReturnType | null = null; + +export function showPopover( + x: number, + y: number, + content: string, + title?: string, + duration: number = 5000, +): void { + const popover = getOrCreatePopover(); + + const titleHtml = title + ? `
+ ${title} + +
` + : ''; + + popover.innerHTML = ` + ${titleHtml} +
${content}
+ `; + + popover.classList.remove('visible'); + + requestAnimationFrame(() => { + positionPopover(popover, x, y); + popover.classList.add('visible'); + }); + + if (hideTimeout) { + clearTimeout(hideTimeout); + } + + if (duration > 0) { + hideTimeout = setTimeout(() => { + hidePopover(); + }, duration); + } +} + +export function hidePopover(): void { + const popover = document.getElementById(POPOVER_ID); + if (popover) { + popover.classList.remove('visible'); + } + if (hideTimeout) { + clearTimeout(hideTimeout); + hideTimeout = null; + } +} + +export function showTimestampResult(x: number, y: number, timestamp: string, result: string): void { + const content = ` +
输入时间戳
+
${timestamp}
+
转换结果
+
${result}
+ `; + showPopover(x, y, content, '⏰ 时间戳转换'); +} + +export function showTextStatsResult( + x: number, + y: number, + text: string, + stats: { characters: number; words: number; lines: number; bytes: number }, +): void { + const truncatedText = text.length > 50 ? text.substring(0, 50) + '...' : text; + const content = ` +
选中文本
+
${truncatedText}
+
+
+
字符
+
${stats.characters}
+
+
+
单词
+
${stats.words}
+
+
+
行数
+
${stats.lines}
+
+
+
字节
+
${stats.bytes}
+
+
+ `; + showPopover(x, y, content, '📊 文本统计'); +} diff --git a/pages/Base64Converter/TextMode.tsx b/pages/Base64Converter/TextMode.tsx index 6d580fa..4400141 100644 --- a/pages/Base64Converter/TextMode.tsx +++ b/pages/Base64Converter/TextMode.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import CopyButton from '@/components/CopyButton'; import { textToBase64, base64ToText } from '@/utils/base64Converter'; import SwitchButtonGroup from '@/components/SwitchButtonGroup'; +import { useContextMenuData } from '@/utils/useContextMenuData'; const IMAGE_DATA_URI_PATTERN = /^\s*data:image\//i; @@ -26,6 +27,25 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) { const [error, setError] = useState(null); const [direction, setDirection] = useState<'encode' | 'decode'>('encode'); + const handleContextMenuData = useCallback( + (payload: string) => { + setInput(payload); + setDirection('decode'); + setError(null); + try { + const decoded = base64ToText(payload); + setOutput(decoded); + } catch (e) { + const message = e instanceof Error ? e.message : ''; + const i18nKey = ERROR_MESSAGE_TO_I18N[message]; + setError(i18nKey ? t(i18nKey) : message || t('conversionFailed')); + } + }, + [t], + ); + + useContextMenuData({ featureKey: 'base64Converter', onData: handleContextMenuData }); + const actionLabel = direction === 'encode' ? t('encode') : t('decode'); const placeholder = direction === 'encode' ? t('textInputPlaceholder') : t('base64InputPlaceholder'); diff --git a/pages/Jwt/index.tsx b/pages/Jwt/index.tsx index 03de6ec..4238a8b 100644 --- a/pages/Jwt/index.tsx +++ b/pages/Jwt/index.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { Box, Container, Paper, Stack, Typography } from '@mui/material'; import { useSnackbar } from '@/components/GlobalSnackbar'; import VpnKeyIcon from '@mui/icons-material/VpnKey'; @@ -7,6 +7,7 @@ import { stringifyJson, parseJwt } from '@/utils/jwt'; import CopyButton from '@/components/CopyButton'; import TextInputArea from '@/components/TextInputArea'; import { useLazyTranslation } from '@/utils/useLazyTranslation'; +import { useContextMenuData } from '@/utils/useContextMenuData'; interface SectionProps { title: string; @@ -61,6 +62,13 @@ export default function Index() { const { t } = useLazyTranslation('jwt'); const [jwtInput, setJwtInput] = useState(''); + const handleContextMenuData = useCallback((payload: string) => { + const cleaned = payload.replace(/^Bearer\s*/i, '').trim(); + setJwtInput(cleaned); + }, []); + + useContextMenuData({ featureKey: 'jwt', onData: handleContextMenuData }); + const result = useMemo(() => { if (!jwtInput.trim()) { return null; diff --git a/pages/QrCode/UrlToQrCodeSection.tsx b/pages/QrCode/UrlToQrCodeSection.tsx index dc7edfc..15d87ad 100644 --- a/pages/QrCode/UrlToQrCodeSection.tsx +++ b/pages/QrCode/UrlToQrCodeSection.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { Accordion, AccordionDetails, @@ -18,6 +18,7 @@ import QRious from 'qrious'; import { qrCodePageStyles } from '@/config/pageTheme'; import { useSnackbar } from '@/components/GlobalSnackbar'; import { useTranslation } from 'react-i18next'; +import { useContextMenuData } from '@/utils/useContextMenuData'; interface UrlToQrCodeSectionProps { expanded: boolean; @@ -38,6 +39,47 @@ const UrlToQrCodeSection = ({ const [qrCodeDataUrl, setQrCodeDataUrl] = useState(''); const [generating, setGenerating] = useState(false); + const generateQrCodeFromUrl = useCallback( + async (url: string) => { + try { + setGenerating(true); + setUrlError(''); + + if (!url.startsWith('http://') && !url.startsWith('https://')) { + url = 'https://' + url; + } + + const qr = new QRious({ + value: url, + size: 250, + level: 'H', + foreground: '#000000', + background: '#FFFFFF', + }); + + setQrCodeDataUrl(qr.toDataURL()); + showMessage(t('qrCode:qrCodeSuccess'), { severity: 'success', autoHideDuration: 1000 }); + } catch (error) { + console.error('生成二维码失败:', error); + showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 }); + } finally { + setGenerating(false); + } + }, + [t, showMessage], + ); + + const handleContextMenuData = useCallback( + (payload: string) => { + setUrlInput(payload); + onExpandedChange(true); + generateQrCodeFromUrl(payload); + }, + [onExpandedChange, generateQrCodeFromUrl], + ); + + useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData }); + const handleUrlInputChange = (e: React.ChangeEvent) => { setUrlInput(e.target.value); setUrlError(''); @@ -48,33 +90,7 @@ const UrlToQrCodeSection = ({ setUrlError(t('qrCode:enterUrlError')); return; } - - try { - setGenerating(true); - setUrlError(''); - - let url = urlInput; - if (!url.startsWith('http://') && !url.startsWith('https://')) { - url = 'https://' + url; - } - - // 使用 QRious 替代 qrcode 库,体积更小 - const qr = new QRious({ - value: url, - size: 250, - level: 'H', - foreground: '#000000', - background: '#FFFFFF', - }); - - setQrCodeDataUrl(qr.toDataURL()); - showMessage(t('qrCode:qrCodeSuccess'), { severity: 'success', autoHideDuration: 1000 }); - } catch (error) { - console.error('生成二维码失败:', error); - showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 }); - } finally { - setGenerating(false); - } + await generateQrCodeFromUrl(urlInput); }; const downloadQrCode = () => { diff --git a/pages/TextStatistics/index.tsx b/pages/TextStatistics/index.tsx index 20c58c7..2caa79e 100644 --- a/pages/TextStatistics/index.tsx +++ b/pages/TextStatistics/index.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { alpha, Box, Container, Grid, Paper, Typography } from '@mui/material'; import PageHeader from '@/components/PageHeader'; import TextInputArea from '@/components/TextInputArea'; @@ -6,6 +6,7 @@ import DescriptionIcon from '@mui/icons-material/Description'; import { formatByteSize, getTextStats } from '@/utils/textStatistics'; import { textStatisticsPageStyles } from '@/config/pageTheme'; import { useLazyTranslation } from '@/utils/useLazyTranslation'; +import { useContextMenuData } from '@/utils/useContextMenuData'; /** * 文本统计页面组件 @@ -16,6 +17,12 @@ export default function Index() { const { t } = useLazyTranslation('textStatistics'); const [text, setText] = useState(''); + const handleContextMenuData = useCallback((payload: string) => { + setText(payload); + }, []); + + useContextMenuData({ featureKey: 'textStatistics', onData: handleContextMenuData }); + // 实时计算统计信息,使用 useMemo 优化性能 // 对于 10,000 字符以上的文本,Intl.Segmenter 也能保持良好的性能 const stats = useMemo(() => getTextStats(text), [text]); diff --git a/pages/Timestamp/useTimestampConverter.ts b/pages/Timestamp/useTimestampConverter.ts index c86ba18..4b378a1 100644 --- a/pages/Timestamp/useTimestampConverter.ts +++ b/pages/Timestamp/useTimestampConverter.ts @@ -3,6 +3,7 @@ import dayjs from '@/utils/dayjs'; import type { UnitType, ZoneType } from '@/config/pageTheme'; import { DATE_FORMAT } from '@/config/pageTheme'; import { useTranslation } from 'react-i18next'; +import { useContextMenuData } from '@/utils/useContextMenuData'; export interface UseTimestampConverterReturn { // State @@ -23,6 +24,15 @@ export interface UseTimestampConverterReturn { convert: () => void; } +/** + * 判断输入是否为时间戳(纯数字或长度 >= 10 的数字字符串) + */ +function isTimestampLike(input: string): boolean { + const trimmed = input.trim(); + if (!/^\d+$/.test(trimmed)) return false; + return trimmed.length >= 10; +} + export function useTimestampConverter(): UseTimestampConverterReturn { const { t } = useTranslation(['timestamp']); const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt'); @@ -63,6 +73,48 @@ export function useTimestampConverter(): UseTimestampConverterReturn { } }, [mode, tsInput, dtInput, unit, zone, t]); + // 处理右键菜单传递的数据 + const handleContextMenuData = useCallback( + (payload: string) => { + const trimmed = payload.trim(); + if (isTimestampLike(trimmed)) { + // 看起来是时间戳,切换到 ts2dt 模式 + setMode('ts2dt'); + setTsInput(trimmed); + // 如果是 13 位毫秒级时间戳,自动选择 ms 单位 + const detectedUnit: UnitType = trimmed.length >= 13 ? 'ms' : 's'; + setUnit(detectedUnit); + // 直接执行转换 + const num = Number(trimmed); + if (!isNaN(num)) { + const d = detectedUnit === 'ms' ? dayjs(num) : dayjs.unix(num); + if (d.isValid()) { + setError(''); + setResult(d.tz(zone).format(DATE_FORMAT)); + } + } + } else { + // 尝试作为日期时间解析 + const d = dayjs(trimmed); + if (d.isValid()) { + setMode('dt2ts'); + setDtInput(d.format(DATE_FORMAT)); + // 直接执行转换 + const ms = d.valueOf(); + setError(''); + setResult(String(ms)); + } else { + // 无法识别,作为时间戳处理 + setMode('ts2dt'); + setTsInput(trimmed); + } + } + }, + [zone], + ); + + useContextMenuData({ featureKey: 'timestamp', onData: handleContextMenuData }); + const handleUseNow = useCallback( (now: number) => { if (mode === 'ts2dt') { diff --git a/providers/RouterProvider.tsx b/providers/RouterProvider.tsx index f87c650..10017d2 100644 --- a/providers/RouterProvider.tsx +++ b/providers/RouterProvider.tsx @@ -7,13 +7,14 @@ import { useRef, useState, } from 'react'; -import type { PageType, StorageSchema } from '@/types/storage'; +import type { PageType, StorageSchema, ContextMenuPendingData } from '@/types/storage'; import { storageUtil } from '@/utils/chromeStorage'; import { getAllFeatureKeys, getDefaultPageOrder, getDefaultVisibleFeatureKeys, } from '@/config/features'; +import { saveContextMenuData, CONTEXT_MENU_DATA_EXPIRY_MS } from '@/utils/useContextMenuData'; /** * 校验是否为合法的页面类型 @@ -187,6 +188,40 @@ export function RouterProvider({ .then(() => { if (!cancelled) { setIsLoaded(true); + + // 检查 URL 参数中的右键菜单数据 + if (typeof window !== 'undefined') { + const params = new URLSearchParams(window.location.search); + const feature = params.get('feature') as PageType | null; + const payload = params.get('payload'); + + if (feature && payload && isValidPage(feature)) { + saveContextMenuData({ featureKey: feature, payload }).catch(console.error); + navigateTo(feature); + + // 清理 URL 参数 + const url = new URL(window.location.href); + url.searchParams.delete('feature'); + url.searchParams.delete('payload'); + window.history.replaceState({}, '', url.toString()); + return; + } + } + + // 检查 storage 中的右键菜单待处理数据(用于 openPopup 场景) + storageUtil + .get('contextMenu/pendingData', undefined) + .then((pendingData) => { + if ( + pendingData && + isValidPage(pendingData.featureKey) && + Date.now() - pendingData.timestamp < CONTEXT_MENU_DATA_EXPIRY_MS + ) { + navigateTo(pendingData.featureKey as PageType); + // 不在这里清除数据,让目标页面的 useContextMenuData 来消费和清除 + } + }) + .catch(console.error); } }) .catch(console.error); @@ -253,6 +288,18 @@ export function RouterProvider({ setPageOrder(newOrder); } } + // 监听右键菜单数据变化,自动跳转到对应页面(用于 popup 已打开的场景) + if (changes['contextMenu/pendingData']) { + const newData = changes['contextMenu/pendingData'] + .newValue as ContextMenuPendingData | null; + if ( + newData && + isValidPage(newData.featureKey) && + Date.now() - newData.timestamp < CONTEXT_MENU_DATA_EXPIRY_MS + ) { + setCurrentPage(newData.featureKey as PageType); + } + } }; chrome.storage.onChanged.addListener(handleStorageChange); diff --git a/types/storage.d.ts b/types/storage.d.ts index 4b55efb..7d95029 100644 --- a/types/storage.d.ts +++ b/types/storage.d.ts @@ -126,6 +126,20 @@ export interface StorageSchema { 'htmlToMarkdown/previewMode': HtmlToMarkdownPreviewMode; /** 语言偏好设置 */ 'app/language': string; + /** 右键菜单待处理数据 */ + 'contextMenu/pendingData': ContextMenuPendingData; +} + +/** + * 右键菜单待处理数据 + */ +export interface ContextMenuPendingData { + /** 功能标识 */ + featureKey: PageType; + /** 捕获的文本或图片 URL */ + payload: string; + /** 数据创建时间戳 */ + timestamp: number; } /** diff --git a/utils/__tests__/contextMenu.test.ts b/utils/__tests__/contextMenu.test.ts new file mode 100644 index 0000000..49c09f9 --- /dev/null +++ b/utils/__tests__/contextMenu.test.ts @@ -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 => ({ + 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' }, + }); + }); + }); +}); diff --git a/utils/__tests__/useContextMenuData.test.ts b/utils/__tests__/useContextMenuData.test.ts new file mode 100644 index 0000000..e0c9b97 --- /dev/null +++ b/utils/__tests__/useContextMenuData.test.ts @@ -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(); + }); + }); +}); diff --git a/utils/contextMenu.ts b/utils/contextMenu.ts new file mode 100644 index 0000000..5452a9f --- /dev/null +++ b/utils/contextMenu.ts @@ -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 = { + '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: '无法获取有效数据' }; +} diff --git a/utils/messages.ts b/utils/messages.ts index 0589406..075b992 100644 --- a/utils/messages.ts +++ b/utils/messages.ts @@ -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(); diff --git a/utils/useContextMenuData.ts b/utils/useContextMenuData.ts new file mode 100644 index 0000000..71d353e --- /dev/null +++ b/utils/useContextMenuData.ts @@ -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, +): Promise { + const pendingData: ContextMenuPendingData = { + ...data, + timestamp: Date.now(), + }; + await storageUtil.set(STORAGE_KEY, pendingData); +} + +/** + * 清除右键菜单待处理数据 + */ +export async function clearContextMenuData(): Promise { + await storageUtil.remove(STORAGE_KEY); +} diff --git a/vitest.setup.ts b/vitest.setup.ts index c6fb4ec..6544534 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -70,6 +70,28 @@ Object.defineProperty(global, 'chrome', { getAll: vi.fn().mockResolvedValue([]), 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, }); diff --git a/wxt.config.ts b/wxt.config.ts index 3a04e50..f0218ba 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ 'tabs', 'cookies', 'sidePanel', + 'contextMenus', ], host_permissions: [''], action: {