feat(窗口管理): 添加独立窗口功能并实现国际化支持

添加 detached 窗口模式,允许用户将 popup 内容弹出到独立窗口
实现窗口位置记忆和单例模式管理
更新国际化配置和界面标题
添加弹出按钮组件和窗口管理服务
这个提交包含在:
Zain 2025-11-13 16:48:07 +08:00
父节点 4c728be987
当前提交 5bd22ec579
共有 14 个文件被更改,包括 427 次插入 和 7 次删除

查看文件

@ -98,5 +98,17 @@
"clear_all_data": { "clear_all_data": {
"message": "Clear All Data", "message": "Clear All Data",
"description": "Clear all data button" "description": "Clear all data button"
},
"popup_title": {
"message": "SearchSyntax Pro",
"description": "Popup window title"
},
"detached_title": {
"message": "SearchSyntax Pro - Detached Window",
"description": "Detached window title"
},
"options_title": {
"message": "SearchSyntax Pro - Settings",
"description": "Options page title"
} }
} }

查看文件

@ -1,7 +1,7 @@
{ {
"app_name": { "app_name": {
"message": "搜索语法大师", "message": "SearchSyntax Pro",
"description": "搜索语法大师 - SearchSyntax Pro" "description": "SearchSyntax Pro - Advanced Search Syntax Tool"
}, },
"app_description": { "app_description": {
"message": "搜索语法可视化工具,降低高级搜索语法使用门槛", "message": "搜索语法可视化工具,降低高级搜索语法使用门槛",
@ -98,5 +98,17 @@
"clear_all_data": { "clear_all_data": {
"message": "清除所有数据", "message": "清除所有数据",
"description": "清除所有数据按钮" "description": "清除所有数据按钮"
},
"popup_title": {
"message": "SearchSyntax Pro",
"description": "Popup window title"
},
"detached_title": {
"message": "SearchSyntax Pro - 独立窗口",
"description": "Detached window title"
},
"options_title": {
"message": "SearchSyntax Pro - 设置",
"description": "Options page title"
} }
} }

查看文件

@ -66,7 +66,7 @@
"web_accessible_resources": [ "web_accessible_resources": [
{ {
"resources": ["icons/*"], "resources": ["icons/*", "src/detached/index.html"],
"matches": ["<all_urls>"] "matches": ["<all_urls>"]
} }
], ],

查看文件

@ -0,0 +1,93 @@
import { useState } from 'react'
import { WindowManager } from '@/services/window-manager'
import { useTranslation } from '@/i18n'
interface PopoutButtonProps {
className?: string
}
/**
* Popout Button Component
*
* 在 popup 窗口中显示的"弹出到新窗口"按钮
* 点击后将当前 popup 内容在独立窗口中打开
*/
export function PopoutButton({ className = '' }: PopoutButtonProps) {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(false)
// 只在 popup 模式下显示此按钮
if (WindowManager.isDetachedWindow()) {
return null
}
const handlePopout = async () => {
try {
setIsLoading(true)
await WindowManager.popoutToDetachedWindow()
} catch (error) {
console.error('弹出窗口失败:', error)
setIsLoading(false)
alert('打开独立窗口失败,请重试')
}
}
return (
<button
onClick={handlePopout}
disabled={isLoading}
className={`
popout-button
flex items-center justify-center
w-8 h-8
rounded-md
text-white hover:bg-white/20
transition-colors duration-200
disabled:opacity-50 disabled:cursor-not-allowed
${className}
`}
title={t('popup.popoutButtonTooltip')}
aria-label={t('popup.popoutButtonTitle')}
>
{isLoading ? (
// 加载动画
<svg
className="animate-spin h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : (
// 弹出窗口图标 (使用 SVG)
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
)}
</button>
)
}

18
src/detached/index.html 普通文件
查看文件

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="zh-CN" class="chrome-extension-detached-root">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SearchSyntax Pro - Detached Window</title>
<link rel="stylesheet" href="../../src/styles/globals.css">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
</style>
</head>
<body class="chrome-extension-detached-body">
<div id="app" class="chrome-extension-detached">
<!-- React应用将在这里渲染 -->
</div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

24
src/detached/main.tsx 普通文件
查看文件

@ -0,0 +1,24 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from '../popup/App'
import '../styles/globals.css'
/**
* Detached Window Entry Point
*
* 这是独立窗口的入口文件,完全复用 popup/App.tsx 组件
* 通过 window 对象标记当前运行环境为 detached 模式
*/
// 标记当前窗口为 detached 模式,供组件内部判断使用
;(window as any).__SSP_WINDOW_MODE__ = 'detached'
// 设置国际化标题
document.title = chrome.i18n.getMessage('detached_title')
// 渲染应用
ReactDOM.createRoot(document.getElementById('app')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

查看文件

@ -35,9 +35,11 @@ const translations: Record<Language, Record<string, string>> = {
'copyButton.tooltipCopy': '复制', 'copyButton.tooltipCopy': '复制',
'copyButton.tooltipCopied': '已复制!', 'copyButton.tooltipCopied': '已复制!',
'copyButton.copyError': '复制失败', 'copyButton.copyError': '复制失败',
'popup.headerTitle': '搜索语法大师', 'popup.headerTitle': 'SearchSyntax Pro',
'popup.footer.currentEngine': '当前引擎: {engine}', 'popup.footer.currentEngine': '当前引擎: {engine}',
'popup.settingsButtonTitle': '设置', 'popup.settingsButtonTitle': '设置',
'popup.popoutButtonTitle': '弹出到新窗口',
'popup.popoutButtonTooltip': '在独立窗口中使用,不会因浏览器操作而关闭',
'searchForm.keywordLabel': '搜索关键词', 'searchForm.keywordLabel': '搜索关键词',
'searchForm.keywordPlaceholder': '输入要搜索的关键词...', 'searchForm.keywordPlaceholder': '输入要搜索的关键词...',
'searchForm.engineLabel': '搜索引擎', 'searchForm.engineLabel': '搜索引擎',
@ -70,7 +72,7 @@ const translations: Record<Language, Record<string, string>> = {
'searchHistory.viewAllHint': '或前往设置页面查看完整历史记录', 'searchHistory.viewAllHint': '或前往设置页面查看完整历史记录',
'searchHistory.viewAllInSettings': '查看全部 {total} 条记录,请前往设置页面', 'searchHistory.viewAllInSettings': '查看全部 {total} 条记录,请前往设置页面',
'searchHistory.goToSettings': '打开设置页面', 'searchHistory.goToSettings': '打开设置页面',
'options.header.title': '搜索语法大师 - 设置', 'options.header.title': 'SearchSyntax Pro - 设置',
'options.header.subtitle': '设置和配置', 'options.header.subtitle': '设置和配置',
'options.tabs.settings': '设置', 'options.tabs.settings': '设置',
'options.tabs.shortcuts': '快捷键', 'options.tabs.shortcuts': '快捷键',
@ -406,6 +408,8 @@ const translations: Record<Language, Record<string, string>> = {
'popup.headerTitle': 'SearchSyntax Pro', 'popup.headerTitle': 'SearchSyntax Pro',
'popup.footer.currentEngine': 'Current engine: {engine}', 'popup.footer.currentEngine': 'Current engine: {engine}',
'popup.settingsButtonTitle': 'Settings', 'popup.settingsButtonTitle': 'Settings',
'popup.popoutButtonTitle': 'Pop out to new window',
'popup.popoutButtonTooltip': 'Use in a standalone window that won\'t close with browser actions',
'searchForm.keywordLabel': 'Search keywords', 'searchForm.keywordLabel': 'Search keywords',
'searchForm.keywordPlaceholder': 'Enter the keywords you want to search...', 'searchForm.keywordPlaceholder': 'Enter the keywords you want to search...',
'searchForm.engineLabel': 'Search engine', 'searchForm.engineLabel': 'Search engine',

查看文件

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSP智能搜索插件 - 设置</title> <title>SearchSyntax Pro - Settings</title>
<link rel="stylesheet" href="../../src/styles/globals.css"> <link rel="stylesheet" href="../../src/styles/globals.css">
<style> <style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');

查看文件

@ -3,6 +3,9 @@ import ReactDOM from 'react-dom/client'
import App from './App.tsx' import App from './App.tsx'
import '../styles/globals.css' import '../styles/globals.css'
// 设置国际化标题
document.title = chrome.i18n.getMessage('options_title')
// 初始化React应用 // 初始化React应用
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(
document.getElementById('app') as HTMLElement document.getElementById('app') as HTMLElement

查看文件

@ -3,6 +3,7 @@ import { SearchForm } from '@/components/SearchForm'
import { QueryPreview } from '@/components/QueryPreview' import { QueryPreview } from '@/components/QueryPreview'
import { SearchHistory as SearchHistoryComponent } from '@/components/SearchHistory' import { SearchHistory as SearchHistoryComponent } from '@/components/SearchHistory'
import { SettingsButton } from '@/components/SettingsButton' import { SettingsButton } from '@/components/SettingsButton'
import { PopoutButton } from '@/components/PopoutButton'
import { LogoIcon } from '@/components/Logo' import { LogoIcon } from '@/components/Logo'
import { TemplateSelector } from '@/components/TemplateSelector' import { TemplateSelector } from '@/components/TemplateSelector'
import { SuggestionPanel } from '@/components/SuggestionPanel' import { SuggestionPanel } from '@/components/SuggestionPanel'
@ -13,6 +14,7 @@ import { SearchAdapterFactory } from '@/services/adapters'
import { EnginePreferenceService } from '@/services/engine-preference' import { EnginePreferenceService } from '@/services/engine-preference'
import { templateManager } from '@/services/template-manager' import { templateManager } from '@/services/template-manager'
import { shortcutManager } from '@/services/shortcut-manager' import { shortcutManager } from '@/services/shortcut-manager'
import { WindowManager } from '@/services/window-manager'
import { TranslationProvider, useTranslation } from '@/i18n' import { TranslationProvider, useTranslation } from '@/i18n'
import { useExtensionVersion } from '@/utils/version' import { useExtensionVersion } from '@/utils/version'
import { autoMigrateStorage } from '@/utils/migration' import { autoMigrateStorage } from '@/utils/migration'
@ -304,6 +306,11 @@ function App() {
try { try {
await templateManager.initialize() await templateManager.initialize()
await shortcutManager.initialize('popup') await shortcutManager.initialize('popup')
// v1.6.2: 如果是 detached 窗口,启用窗口位置追踪
if (WindowManager.isDetachedWindow()) {
WindowManager.setupWindowPositionTracking()
}
} catch (error) { } catch (error) {
console.error('初始化服务失败:', error) console.error('初始化服务失败:', error)
} }
@ -480,6 +487,7 @@ function PopupContent({
<h1 className="text-lg font-semibold">{t('popup.headerTitle')}</h1> <h1 className="text-lg font-semibold">{t('popup.headerTitle')}</h1>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<PopoutButton />
<ShortcutHintTrigger onClick={() => setShowShortcutHint(true)} /> <ShortcutHintTrigger onClick={() => setShowShortcutHint(true)} />
<SettingsButton /> <SettingsButton />
</div> </div>

查看文件

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSP智能搜索插件</title> <title>SearchSyntax Pro</title>
<link rel="stylesheet" href="../../src/styles/globals.css"> <link rel="stylesheet" href="../../src/styles/globals.css">
<style> <style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');

查看文件

@ -3,6 +3,9 @@ import ReactDOM from 'react-dom/client'
import App from './App.tsx' import App from './App.tsx'
import '../styles/globals.css' import '../styles/globals.css'
// 设置国际化标题
document.title = chrome.i18n.getMessage('popup_title')
// 初始化React应用 // 初始化React应用
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(
document.getElementById('app') as HTMLElement document.getElementById('app') as HTMLElement

查看文件

@ -0,0 +1,242 @@
/**
* Window Manager Service
*
* 管理 popup 和 detached 窗口的创建、切换和状态同步
*/
export interface WindowPosition {
left?: number
top?: number
width?: number
height?: number
}
export interface DetachedWindowConfig {
url: string
position?: WindowPosition
focused?: boolean
}
const STORAGE_KEY_WINDOW_POSITION = 'detached_window_position'
const STORAGE_KEY_WINDOW_ID = 'detached_window_id'
const DEFAULT_WINDOW_SIZE = { width: 420, height: 700 }
export class WindowManager {
/**
* 获取当前窗口模式
*/
static getWindowMode(): 'popup' | 'detached' {
return (window as any).__SSP_WINDOW_MODE__ === 'detached' ? 'detached' : 'popup'
}
/**
* 判断是否为 detached 窗口
*/
static isDetachedWindow(): boolean {
return this.getWindowMode() === 'detached'
}
/**
* 获取保存的窗口位置
*/
static async getSavedWindowPosition(): Promise<WindowPosition> {
try {
const result = await chrome.storage.local.get(STORAGE_KEY_WINDOW_POSITION)
return result[STORAGE_KEY_WINDOW_POSITION] || {}
} catch (error) {
console.error('获取窗口位置失败:', error)
return {}
}
}
/**
* 保存窗口位置
*/
static async saveWindowPosition(position: WindowPosition): Promise<void> {
try {
await chrome.storage.local.set({
[STORAGE_KEY_WINDOW_POSITION]: position
})
} catch (error) {
console.error('保存窗口位置失败:', error)
}
}
/**
* 获取当前保存的 detached 窗口 ID
*/
static async getDetachedWindowId(): Promise<number | null> {
try {
const result = await chrome.storage.local.get(STORAGE_KEY_WINDOW_ID)
return result[STORAGE_KEY_WINDOW_ID] || null
} catch (error) {
console.error('获取窗口ID失败:', error)
return null
}
}
/**
* 保存 detached 窗口 ID
*/
static async saveDetachedWindowId(windowId: number): Promise<void> {
try {
await chrome.storage.local.set({
[STORAGE_KEY_WINDOW_ID]: windowId
})
} catch (error) {
console.error('保存窗口ID失败:', error)
}
}
/**
* 清除保存的 detached 窗口 ID
*/
static async clearDetachedWindowId(): Promise<void> {
try {
await chrome.storage.local.remove(STORAGE_KEY_WINDOW_ID)
} catch (error) {
console.error('清除窗口ID失败:', error)
}
}
/**
* 检查指定窗口是否存在
*/
static async isWindowExists(windowId: number): Promise<boolean> {
try {
const window = await chrome.windows.get(windowId)
return window !== null
} catch (error) {
// 窗口不存在会抛出异常
return false
}
}
/**
* 创建 detached 窗口(带单例模式检查)
*/
static async createDetachedWindow(config?: Partial<DetachedWindowConfig>): Promise<chrome.windows.Window> {
// 检查是否已有打开的 detached 窗口
const existingWindowId = await this.getDetachedWindowId()
if (existingWindowId) {
const exists = await this.isWindowExists(existingWindowId)
if (exists) {
// 窗口已存在,聚焦到该窗口
const window = await chrome.windows.update(existingWindowId, { focused: true })
console.log('Detached 窗口已存在,已聚焦:', existingWindowId)
return window
} else {
// 窗口已关闭,清除记录
await this.clearDetachedWindowId()
}
}
// 获取保存的窗口位置
const savedPosition = await this.getSavedWindowPosition()
const position = config?.position || savedPosition
// 计算窗口位置(如果没有保存的位置,居中显示)
const windowConfig: chrome.windows.CreateData = {
url: config?.url || chrome.runtime.getURL('src/detached/index.html'),
type: 'popup', // 无地址栏的弹出窗口
width: position.width || DEFAULT_WINDOW_SIZE.width,
height: position.height || DEFAULT_WINDOW_SIZE.height,
focused: config?.focused !== false,
}
// 如果有保存的位置,使用保存的位置
if (position.left !== undefined && position.top !== undefined) {
windowConfig.left = position.left
windowConfig.top = position.top
}
// 创建窗口
const newWindow = await chrome.windows.create(windowConfig)
if (newWindow.id) {
// 保存窗口 ID
await this.saveDetachedWindowId(newWindow.id)
// 监听窗口关闭事件,清除记录
chrome.windows.onRemoved.addListener(async (windowId) => {
if (windowId === newWindow.id) {
await this.clearDetachedWindowId()
}
})
console.log('成功创建 detached 窗口:', newWindow.id)
}
return newWindow
}
/**
* 从 popup 弹出到独立窗口
*/
static async popoutToDetachedWindow(): Promise<void> {
try {
// 创建 detached 窗口
await this.createDetachedWindow()
// 关闭当前 popup
window.close()
} catch (error) {
console.error('弹出到独立窗口失败:', error)
throw error
}
}
/**
* 保存当前窗口位置(在 detached 窗口中调用)
*/
static async saveCurrentWindowPosition(): Promise<void> {
if (!this.isDetachedWindow()) {
return
}
try {
const currentWindow = await chrome.windows.getCurrent()
if (currentWindow) {
const position: WindowPosition = {
left: currentWindow.left,
top: currentWindow.top,
width: currentWindow.width,
height: currentWindow.height,
}
await this.saveWindowPosition(position)
}
} catch (error) {
console.error('保存当前窗口位置失败:', error)
}
}
/**
* 监听窗口大小变化并自动保存位置(在 detached 窗口中调用)
*/
static setupWindowPositionTracking(): void {
if (!this.isDetachedWindow()) {
return
}
// 使用 debounce 避免频繁保存
let saveTimer: NodeJS.Timeout
const debouncedSave = () => {
clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
this.saveCurrentWindowPosition()
}, 500)
}
// 监听窗口大小和位置变化
chrome.windows.onBoundsChanged?.addListener(() => {
debouncedSave()
})
// 组件卸载或窗口关闭时保存
window.addEventListener('beforeunload', () => {
this.saveCurrentWindowPosition()
})
}
}
export const windowManager = new WindowManager()

查看文件

@ -9,6 +9,7 @@ export default defineConfig({
rollupOptions: { rollupOptions: {
input: { input: {
popup: resolve(__dirname, 'src/popup/index.html'), popup: resolve(__dirname, 'src/popup/index.html'),
detached: resolve(__dirname, 'src/detached/index.html'),
options: resolve(__dirname, 'src/options/index.html'), options: resolve(__dirname, 'src/options/index.html'),
background: resolve(__dirname, 'src/background/index.ts'), background: resolve(__dirname, 'src/background/index.ts'),
content: resolve(__dirname, 'src/content/index.ts'), content: resolve(__dirname, 'src/content/index.ts'),