merge: 合并 feature-context-menu 分支
功能特性: - 右键菜单支持选中文本/图片触发对应工具 - 支持 JWT 解析、Base64 解码、文本统计、时间戳转换 - 网页链接转二维码 - popup/sidepanel 双入口支持 - 智能识别输入类型自动转换 修复: - 时区硬编码问题 - openPopup 失败后数据残留 - 统一数据过期时间常量 - 类型断言安全性改进
This commit is contained in:
@@ -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> = {},
|
||||||
|
): 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,51 @@
|
|||||||
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';
|
||||||
|
import { saveContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
|
||||||
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.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) => {
|
browser.action.onClicked.addListener(async (tab) => {
|
||||||
if (tab.id) {
|
if (tab.id) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1 +1,5 @@
|
|||||||
export function initMessageHandler() {}
|
import { initContextMenuHandler } from './contextMenuHandler';
|
||||||
|
|
||||||
|
export function initMessageHandler(): void {
|
||||||
|
initContextMenuHandler();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
export function showPopover(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
content: string,
|
||||||
|
title?: string,
|
||||||
|
duration: number = 5000,
|
||||||
|
): void {
|
||||||
|
const popover = getOrCreatePopover();
|
||||||
|
|
||||||
|
const titleHtml = title
|
||||||
|
? `<div class="popover-header">
|
||||||
|
<span class="popover-title">${title}</span>
|
||||||
|
<button class="popover-close" onclick="this.closest('#${POPOVER_ID}').classList.remove('visible')">×</button>
|
||||||
|
</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
popover.innerHTML = `
|
||||||
|
${titleHtml}
|
||||||
|
<div class="popover-content">${content}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="popover-label">输入时间戳</div>
|
||||||
|
<div class="popover-value">${timestamp}</div>
|
||||||
|
<div class="popover-label">转换结果</div>
|
||||||
|
<div class="popover-value">${result}</div>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<div class="popover-label">选中文本</div>
|
||||||
|
<div class="popover-value">${truncatedText}</div>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">字符</div>
|
||||||
|
<div class="stat-value">${stats.characters}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">单词</div>
|
||||||
|
<div class="stat-value">${stats.words}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">行数</div>
|
||||||
|
<div class="stat-value">${stats.lines}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">字节</div>
|
||||||
|
<div class="stat-value">${stats.bytes}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
showPopover(x, y, content, '📊 文本统计');
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import { textToBase64, base64ToText } from '@/utils/base64Converter';
|
import { textToBase64, base64ToText } from '@/utils/base64Converter';
|
||||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||||
|
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
|
||||||
const IMAGE_DATA_URI_PATTERN = /^\s*data:image\//i;
|
const IMAGE_DATA_URI_PATTERN = /^\s*data:image\//i;
|
||||||
|
|
||||||
@@ -26,6 +27,25 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [direction, setDirection] = useState<'encode' | 'decode'>('encode');
|
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 actionLabel = direction === 'encode' ? t('encode') : t('decode');
|
||||||
const placeholder =
|
const placeholder =
|
||||||
direction === 'encode' ? t('textInputPlaceholder') : t('base64InputPlaceholder');
|
direction === 'encode' ? t('textInputPlaceholder') : t('base64InputPlaceholder');
|
||||||
|
|||||||
+9
-1
@@ -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 { Box, Container, Paper, Stack, Typography } from '@mui/material';
|
||||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||||
import VpnKeyIcon from '@mui/icons-material/VpnKey';
|
import VpnKeyIcon from '@mui/icons-material/VpnKey';
|
||||||
@@ -7,6 +7,7 @@ import { stringifyJson, parseJwt } from '@/utils/jwt';
|
|||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
|
||||||
interface SectionProps {
|
interface SectionProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -61,6 +62,13 @@ export default function Index() {
|
|||||||
const { t } = useLazyTranslation('jwt');
|
const { t } = useLazyTranslation('jwt');
|
||||||
const [jwtInput, setJwtInput] = useState('');
|
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(() => {
|
const result = useMemo(() => {
|
||||||
if (!jwtInput.trim()) {
|
if (!jwtInput.trim()) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionDetails,
|
AccordionDetails,
|
||||||
@@ -18,6 +18,7 @@ import QRious from 'qrious';
|
|||||||
import { qrCodePageStyles } from '@/config/pageTheme';
|
import { qrCodePageStyles } from '@/config/pageTheme';
|
||||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
|
||||||
interface UrlToQrCodeSectionProps {
|
interface UrlToQrCodeSectionProps {
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
@@ -38,27 +39,16 @@ const UrlToQrCodeSection = ({
|
|||||||
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
|
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
|
|
||||||
const handleUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const generateQrCodeFromUrl = useCallback(
|
||||||
setUrlInput(e.target.value);
|
async (url: string) => {
|
||||||
setUrlError('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateQrCode = async () => {
|
|
||||||
if (!urlInput) {
|
|
||||||
setUrlError(t('qrCode:enterUrlError'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
setUrlError('');
|
setUrlError('');
|
||||||
|
|
||||||
let url = urlInput;
|
|
||||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
url = 'https://' + url;
|
url = 'https://' + url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 QRious 替代 qrcode 库,体积更小
|
|
||||||
const qr = new QRious({
|
const qr = new QRious({
|
||||||
value: url,
|
value: url,
|
||||||
size: 250,
|
size: 250,
|
||||||
@@ -75,6 +65,32 @@ const UrlToQrCodeSection = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setGenerating(false);
|
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<HTMLInputElement>) => {
|
||||||
|
setUrlInput(e.target.value);
|
||||||
|
setUrlError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateQrCode = async () => {
|
||||||
|
if (!urlInput) {
|
||||||
|
setUrlError(t('qrCode:enterUrlError'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await generateQrCodeFromUrl(urlInput);
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadQrCode = () => {
|
const downloadQrCode = () => {
|
||||||
|
|||||||
@@ -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 { alpha, Box, Container, Grid, Paper, Typography } from '@mui/material';
|
||||||
import PageHeader from '@/components/PageHeader';
|
import PageHeader from '@/components/PageHeader';
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
@@ -6,6 +6,7 @@ import DescriptionIcon from '@mui/icons-material/Description';
|
|||||||
import { formatByteSize, getTextStats } from '@/utils/textStatistics';
|
import { formatByteSize, getTextStats } from '@/utils/textStatistics';
|
||||||
import { textStatisticsPageStyles } from '@/config/pageTheme';
|
import { textStatisticsPageStyles } from '@/config/pageTheme';
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文本统计页面组件
|
* 文本统计页面组件
|
||||||
@@ -16,6 +17,12 @@ export default function Index() {
|
|||||||
const { t } = useLazyTranslation('textStatistics');
|
const { t } = useLazyTranslation('textStatistics');
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
|
|
||||||
|
const handleContextMenuData = useCallback((payload: string) => {
|
||||||
|
setText(payload);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useContextMenuData({ featureKey: 'textStatistics', onData: handleContextMenuData });
|
||||||
|
|
||||||
// 实时计算统计信息,使用 useMemo 优化性能
|
// 实时计算统计信息,使用 useMemo 优化性能
|
||||||
// 对于 10,000 字符以上的文本,Intl.Segmenter 也能保持良好的性能
|
// 对于 10,000 字符以上的文本,Intl.Segmenter 也能保持良好的性能
|
||||||
const stats = useMemo(() => getTextStats(text), [text]);
|
const stats = useMemo(() => getTextStats(text), [text]);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import dayjs from '@/utils/dayjs';
|
|||||||
import type { UnitType, ZoneType } from '@/config/pageTheme';
|
import type { UnitType, ZoneType } from '@/config/pageTheme';
|
||||||
import { DATE_FORMAT } from '@/config/pageTheme';
|
import { DATE_FORMAT } from '@/config/pageTheme';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
|
||||||
export interface UseTimestampConverterReturn {
|
export interface UseTimestampConverterReturn {
|
||||||
// State
|
// State
|
||||||
@@ -23,6 +24,15 @@ export interface UseTimestampConverterReturn {
|
|||||||
convert: () => void;
|
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 {
|
export function useTimestampConverter(): UseTimestampConverterReturn {
|
||||||
const { t } = useTranslation(['timestamp']);
|
const { t } = useTranslation(['timestamp']);
|
||||||
const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt');
|
const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt');
|
||||||
@@ -63,6 +73,48 @@ export function useTimestampConverter(): UseTimestampConverterReturn {
|
|||||||
}
|
}
|
||||||
}, [mode, tsInput, dtInput, unit, zone, t]);
|
}, [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(
|
const handleUseNow = useCallback(
|
||||||
(now: number) => {
|
(now: number) => {
|
||||||
if (mode === 'ts2dt') {
|
if (mode === 'ts2dt') {
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import type { PageType, StorageSchema } from '@/types/storage';
|
import type { PageType, StorageSchema, ContextMenuPendingData } from '@/types/storage';
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
import { storageUtil } from '@/utils/chromeStorage';
|
||||||
import {
|
import {
|
||||||
getAllFeatureKeys,
|
getAllFeatureKeys,
|
||||||
getDefaultPageOrder,
|
getDefaultPageOrder,
|
||||||
getDefaultVisibleFeatureKeys,
|
getDefaultVisibleFeatureKeys,
|
||||||
} from '@/config/features';
|
} from '@/config/features';
|
||||||
|
import { saveContextMenuData, CONTEXT_MENU_DATA_EXPIRY_MS } from '@/utils/useContextMenuData';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验是否为合法的页面类型
|
* 校验是否为合法的页面类型
|
||||||
@@ -187,6 +188,40 @@ export function RouterProvider({
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setIsLoaded(true);
|
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);
|
.catch(console.error);
|
||||||
@@ -253,6 +288,18 @@ export function RouterProvider({
|
|||||||
setPageOrder(newOrder);
|
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);
|
chrome.storage.onChanged.addListener(handleStorageChange);
|
||||||
|
|||||||
Vendored
+14
@@ -126,6 +126,20 @@ export interface StorageSchema {
|
|||||||
'htmlToMarkdown/previewMode': HtmlToMarkdownPreviewMode;
|
'htmlToMarkdown/previewMode': HtmlToMarkdownPreviewMode;
|
||||||
/** 语言偏好设置 */
|
/** 语言偏好设置 */
|
||||||
'app/language': string;
|
'app/language': string;
|
||||||
|
/** 右键菜单待处理数据 */
|
||||||
|
'contextMenu/pendingData': ContextMenuPendingData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 右键菜单待处理数据
|
||||||
|
*/
|
||||||
|
export interface ContextMenuPendingData {
|
||||||
|
/** 功能标识 */
|
||||||
|
featureKey: PageType;
|
||||||
|
/** 捕获的文本或图片 URL */
|
||||||
|
payload: string;
|
||||||
|
/** 数据创建时间戳 */
|
||||||
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 {
|
export enum MessageAction {
|
||||||
RELOAD_TAB = 'reloadTab',
|
RELOAD_TAB = 'reloadTab',
|
||||||
SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged',
|
SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged',
|
||||||
|
CONTEXT_MENU_CLICKED = 'contextMenuClicked',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageResponse {
|
export interface MessageResponse {
|
||||||
@@ -11,9 +12,15 @@ export interface MessageResponse {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuClickedPayload {
|
||||||
|
featureKey: string;
|
||||||
|
payload: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProtocolMap {
|
export interface ProtocolMap {
|
||||||
[MessageAction.RELOAD_TAB](data: { tabId: number; delay?: number }): MessageResponse;
|
[MessageAction.RELOAD_TAB](data: { tabId: number; delay?: number }): MessageResponse;
|
||||||
[MessageAction.SIDE_PANEL_STATE_CHANGED](data: { isOpen: boolean }): void;
|
[MessageAction.SIDE_PANEL_STATE_CHANGED](data: { isOpen: boolean }): void;
|
||||||
|
[MessageAction.CONTEXT_MENU_CLICKED](data: ContextMenuClickedPayload): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>();
|
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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export default defineConfig({
|
|||||||
'tabs',
|
'tabs',
|
||||||
'cookies',
|
'cookies',
|
||||||
'sidePanel',
|
'sidePanel',
|
||||||
|
'contextMenus',
|
||||||
],
|
],
|
||||||
host_permissions: ['<all_urls>'],
|
host_permissions: ['<all_urls>'],
|
||||||
action: {
|
action: {
|
||||||
|
|||||||
Reference in New Issue
Block a user