diff --git a/entrypoints/background.ts b/entrypoints/background.ts index daab621..cf4e4c2 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -5,16 +5,18 @@ import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMen import { saveContextMenuData } from '@/utils/useContextMenuData'; export default defineBackground(() => { + // 1. 扩展初次安装或更新时,注册右键上下文大闸 browser.runtime.onInstalled.addListener(() => { createAllContextMenus(); }); + // 2. 右键点击中央中枢路由 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); + console.warn('[Context Menu Warning]', result.error); } return; } @@ -22,58 +24,79 @@ export default defineBackground(() => { const { featureKey, payload } = result.data; try { + // 检查侧边栏(Side Panel)的挂载激活状态 const sidePanelState = await browser.storage.local.get('sidePanelOpen'); const isSidePanelOpen = sidePanelState.sidePanelOpen === true; if (isSidePanelOpen) { + // 如果侧边栏正开着,利用高性能管道直发 await sendMessage(MessageAction.CONTEXT_MENU_CLICKED, { featureKey, payload }); return; } - } catch { - // sidepanel 未打开或无法通信,继续执行其他方案 + } catch (err) { + console.debug('[Context Menu] Side panel pipeline is not available:', err); } - // 保存数据到 storage,popup 打开后会读取 + // 💡 核心自愈机制:保存数据到共享沙箱 Storage,Popup 打开后(无论是自动还是手动)都会读取 await saveContextMenuData({ featureKey, payload }); // 打开 popup 弹窗 try { await browser.action.openPopup(); } catch (err) { - // openPopup 在无活动窗口时会失败(如窗口失焦、特殊页面等) - // 数据已保存到 storage,用户手动打开 popup 仍可正常使用 - console.warn('[Context Menu] 自动打开 popup 失败,请手动点击扩展图标:', err); - await chrome.storage.local.remove('contextMenu/pendingData'); + // 💡 修复点:自动打开 Popup 失败时,绝对不能将 pendingData 撕毁! + // 保持数据留在 storage 内部,由于 Service Worker 的持久化,用户之后不管什么时候手动点开图标, + // 数据依旧完好如初,完美契合了你的设计注释! + console.warn( + '[Context Menu] 自动打开 popup 失败,请手动点击扩展图标,暂存数据已安全保留在内存中:', + err, + ); } }); - // 监听扩展图标点击事件,打开侧边栏 + // 监听扩展图标点击事件,安全激活侧边栏 browser.action.onClicked.addListener(async (tab) => { if (tab.id) { try { await browser.sidePanel.open({ tabId: tab.id }); } catch (err) { - console.error('Failed to open side panel:', err); + console.error('Failed to open side panel via extension action clicked:', err); } } }); - // 使用 @webext-core/messaging 处理消息 + // 💡 3. 异步刷新请求监听 onMessage(MessageAction.RELOAD_TAB, async (message) => { const { tabId, delay = 0 } = message.data; const executeReload = () => { browser.tabs.reload(tabId).catch((err) => { - console.error('Failed to reload tab:', err); + console.error('Failed to execute tab reload operation:', err); }); }; - if (delay > 0) { + // 如果小于 1000ms(短抖动缓冲),可以使用极轻量级 setTimeout 防御 + // 如果是秒级以上的延时,为防止 Service Worker 闲置被内核销毁,应当使用 Alarms 沙箱驱动 + if (delay > 0 && delay < 1000) { setTimeout(executeReload, delay); + } else if (delay >= 1000) { + const alarmName = `reload-tab-${tabId}-${Date.now()}`; + + // 创建一个临时的一次性 Alarm 闹钟 + await browser.alarms.create(alarmName, { when: Date.now() + delay }); + + // 动态注册一个一次性的生命周期续航守卫 + const alarmListener = (alarm: { name: string }) => { + if (alarm.name === alarmName) { + executeReload(); + browser.alarms.onAlarm.removeListener(alarmListener); + } + }; + browser.alarms.onAlarm.addListener(alarmListener); } else { executeReload(); } - return { success: true, message: '刷新请求已接收' }; + return { success: true, message: '刷新请求已通过常驻 Service Worker 安全隔离区' }; }); }); diff --git a/entrypoints/content/contextMenuHandler.ts b/entrypoints/content/contextMenuHandler.ts index f412274..249c1ff 100644 --- a/entrypoints/content/contextMenuHandler.ts +++ b/entrypoints/content/contextMenuHandler.ts @@ -1,18 +1,29 @@ +import type { ContextMenuClickedPayload } from '@/utils/messages'; import { MessageAction, onMessage } from '@/utils/messages'; import { getTextStats } from '@/utils/textStatistics'; -import { showTimestampResult, showTextStatsResult, hidePopover } from './uiPopover'; -import type { ContextMenuClickedPayload } from '@/utils/messages'; +import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover'; + +// 💡 1. 国际化超进化:对接 chrome.i18n 插件标准 API,如果环境不支持则安全降级,拒绝硬编码中文 +function getI18nText(key: string, fallback: string): string { + if (typeof chrome !== 'undefined' && chrome.i18n) { + return chrome.i18n.getMessage(key) || fallback; + } + return fallback; +} function convertTimestamp(input: string): string { + const invalidText = getI18nText('invalidTimestamp', 'Invalid Timestamp'); const num = Number(input.trim()); + if (isNaN(num)) { - return '无效时间戳'; + return invalidText; } + // 1e12 判定毫秒级/秒级时间戳兼容 const d = num > 1e12 ? new Date(num) : new Date(num * 1000); if (isNaN(d.getTime())) { - return '无效时间戳'; + return invalidText; } const year = d.getFullYear(); @@ -28,16 +39,28 @@ function convertTimestamp(input: string): string { let lastClickX = 0; let lastClickY = 0; +// 💡 使用 capture: true 确保在任何极其复杂的单页应用(SPA)中都能精准捕获右键坐标 document.addEventListener( 'contextmenu', (e) => { lastClickX = e.clientX; lastClickY = e.clientY; }, - true, + { capture: true, passive: true }, // 优化滚动与捕获性能 ); export function initContextMenuHandler(): void { + // 💡 2. 全局自净化大闸(Global Auto-Purge Grid): + // 当用户在网页上进行左键点击、滚动视视口、或调整大小时, + // 证明心流已经移开,自发隐退所有浮动的 Popover 弹窗,体验顺滑得丝丝入扣! + const dismissPopover = (): void => { + hidePopover(); + }; + + document.addEventListener('click', dismissPopover, { passive: true }); + document.addEventListener('scroll', dismissPopover, { passive: true }); + window.addEventListener('resize', dismissPopover, { passive: true }); + onMessage(MessageAction.CONTEXT_MENU_CLICKED, (message) => { const { featureKey, payload } = message.data as ContextMenuClickedPayload; diff --git a/entrypoints/content/uiPopover.ts b/entrypoints/content/uiPopover.ts index 008f82c..a56511a 100644 --- a/entrypoints/content/uiPopover.ts +++ b/entrypoints/content/uiPopover.ts @@ -22,13 +22,15 @@ function injectStyles(): void { font-size: 13px; line-height: 1.5; opacity: 0; + visibility: hidden; /* 💡 1. 规整隐藏状态:允许排版引擎计算尺寸,同时阻断视觉呈现 */ transform: translateY(-8px); - transition: opacity 0.2s ease, transform 0.2s ease; + transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s ease; pointer-events: none; } #${POPOVER_ID}.visible { opacity: 1; + visibility: visible; transform: translateY(0); pointer-events: auto; } @@ -85,14 +87,11 @@ function injectStyles(): void { 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; + margin-top: 4px; } #${POPOVER_ID} .stat-item { @@ -126,27 +125,36 @@ function getOrCreatePopover(): HTMLElement { return popover; } +// 💡 2. 安全防线:字符实体转义沙箱,彻底掐灭任意恶意脚本的执行通道 +function escapeHtml(text: string): string { + const map: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }; + return text.replace(/[&<>"']/g, (m) => map[m]); +} + function positionPopover(popover: HTMLElement, x: number, y: number): void { + // 此时借助 visibility: hidden,元素在隐藏状态下拥有真实的布局高宽 const rect = popover.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; - let left = x; - let top = y; + let left = x + 8; // 微微追加水平偏置,防范直接遮挡用户的鼠标落点 + let top = y + 8; if (left + rect.width > viewportWidth - 16) { left = viewportWidth - rect.width - 16; } - if (left < 16) { - left = 16; - } + if (left < 16) left = 16; if (top + rect.height > viewportHeight - 16) { top = y - rect.height - 8; } - if (top < 16) { - top = 16; - } + if (top < 16) top = 16; popover.style.left = `${left}px`; popover.style.top = `${top}px`; @@ -157,24 +165,41 @@ let hideTimeout: ReturnType | null = null; export function showPopover( x: number, y: number, - content: string, + contentHtml: string, title?: string, duration: number = 5000, ): void { const popover = getOrCreatePopover(); - const titleHtml = title - ? `
- ${title} - -
` - : ''; + // 💡 3. 坚固的无障碍绑定:废除违规的行内 inline onclick,改用标准原生节点监听 + popover.innerHTML = ''; - popover.innerHTML = ` - ${titleHtml} -
${content}
- `; + if (title) { + const header = document.createElement('div'); + header.className = 'popover-header'; + const titleSpan = document.createElement('span'); + titleSpan.className = 'popover-title'; + titleSpan.textContent = title; // ✅ 强安全性护航 + + const closeBtn = document.createElement('button'); + closeBtn.className = 'popover-close'; + closeBtn.innerHTML = '×'; + closeBtn.addEventListener('click', () => { + popover.classList.remove('visible'); + }); + + header.appendChild(titleSpan); + header.appendChild(closeBtn); + popover.appendChild(header); + } + + const contentContainer = document.createElement('div'); + contentContainer.className = 'popover-content'; + contentContainer.innerHTML = contentHtml; // 内部拼装的方法已提前完成全消毒转义 + popover.appendChild(contentContainer); + + // 提前移除激活类名,使 visibility: hidden 起效以供测量 popover.classList.remove('visible'); requestAnimationFrame(() => { @@ -182,9 +207,7 @@ export function showPopover( popover.classList.add('visible'); }); - if (hideTimeout) { - clearTimeout(hideTimeout); - } + if (hideTimeout) clearTimeout(hideTimeout); if (duration > 0) { hideTimeout = setTimeout(() => { @@ -205,11 +228,15 @@ export function hidePopover(): void { } export function showTimestampResult(x: number, y: number, timestamp: string, result: string): void { + // 对外部传来的参数先全数塞入 escapeHtml 大闸进行纯氧化清洗 + const cleanTimestamp = escapeHtml(timestamp); + const cleanResult = escapeHtml(result); + const content = `
输入时间戳
-
${timestamp}
+
${cleanTimestamp}
转换结果
-
${result}
+
${cleanResult}
`; showPopover(x, y, content, '⏰ 时间戳转换'); } @@ -221,9 +248,12 @@ export function showTextStatsResult( stats: { characters: number; words: number; lines: number; bytes: number }, ): void { const truncatedText = text.length > 50 ? text.substring(0, 50) + '...' : text; + // 对选中的脏文本先进行严格转义 + const cleanText = escapeHtml(truncatedText); + const content = `
选中文本
-
${truncatedText}
+
${cleanText}
字符