镜像自地址
https://github.com/lhly/search-syntax-pro.git
已同步 2026-10-20 14:27:47 +08:00
feat(窗口管理): 添加独立窗口功能并实现国际化支持
添加 detached 窗口模式,允许用户将 popup 内容弹出到独立窗口 实现窗口位置记忆和单例模式管理 更新国际化配置和界面标题 添加弹出按钮组件和窗口管理服务
这个提交包含在:
父节点
4c728be987
当前提交
5bd22ec579
@ -98,5 +98,17 @@
|
||||
"clear_all_data": {
|
||||
"message": "Clear All Data",
|
||||
"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": {
|
||||
"message": "搜索语法大师",
|
||||
"description": "搜索语法大师 - SearchSyntax Pro"
|
||||
"message": "SearchSyntax Pro",
|
||||
"description": "SearchSyntax Pro - Advanced Search Syntax Tool"
|
||||
},
|
||||
"app_description": {
|
||||
"message": "搜索语法可视化工具,降低高级搜索语法使用门槛",
|
||||
@ -98,5 +98,17 @@
|
||||
"clear_all_data": {
|
||||
"message": "清除所有数据",
|
||||
"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": [
|
||||
{
|
||||
"resources": ["icons/*"],
|
||||
"resources": ["icons/*", "src/detached/index.html"],
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
],
|
||||
|
||||
93
src/components/PopoutButton.tsx
普通文件
93
src/components/PopoutButton.tsx
普通文件
@ -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
普通文件
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
普通文件
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.tooltipCopied': '已复制!',
|
||||
'copyButton.copyError': '复制失败',
|
||||
'popup.headerTitle': '搜索语法大师',
|
||||
'popup.headerTitle': 'SearchSyntax Pro',
|
||||
'popup.footer.currentEngine': '当前引擎: {engine}',
|
||||
'popup.settingsButtonTitle': '设置',
|
||||
'popup.popoutButtonTitle': '弹出到新窗口',
|
||||
'popup.popoutButtonTooltip': '在独立窗口中使用,不会因浏览器操作而关闭',
|
||||
'searchForm.keywordLabel': '搜索关键词',
|
||||
'searchForm.keywordPlaceholder': '输入要搜索的关键词...',
|
||||
'searchForm.engineLabel': '搜索引擎',
|
||||
@ -70,7 +72,7 @@ const translations: Record<Language, Record<string, string>> = {
|
||||
'searchHistory.viewAllHint': '或前往设置页面查看完整历史记录',
|
||||
'searchHistory.viewAllInSettings': '查看全部 {total} 条记录,请前往设置页面',
|
||||
'searchHistory.goToSettings': '打开设置页面',
|
||||
'options.header.title': '搜索语法大师 - 设置',
|
||||
'options.header.title': 'SearchSyntax Pro - 设置',
|
||||
'options.header.subtitle': '设置和配置',
|
||||
'options.tabs.settings': '设置',
|
||||
'options.tabs.shortcuts': '快捷键',
|
||||
@ -406,6 +408,8 @@ const translations: Record<Language, Record<string, string>> = {
|
||||
'popup.headerTitle': 'SearchSyntax Pro',
|
||||
'popup.footer.currentEngine': 'Current engine: {engine}',
|
||||
'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.keywordPlaceholder': 'Enter the keywords you want to search...',
|
||||
'searchForm.engineLabel': 'Search engine',
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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">
|
||||
<style>
|
||||
@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 '../styles/globals.css'
|
||||
|
||||
// 设置国际化标题
|
||||
document.title = chrome.i18n.getMessage('options_title')
|
||||
|
||||
// 初始化React应用
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('app') as HTMLElement
|
||||
|
||||
@ -3,6 +3,7 @@ import { SearchForm } from '@/components/SearchForm'
|
||||
import { QueryPreview } from '@/components/QueryPreview'
|
||||
import { SearchHistory as SearchHistoryComponent } from '@/components/SearchHistory'
|
||||
import { SettingsButton } from '@/components/SettingsButton'
|
||||
import { PopoutButton } from '@/components/PopoutButton'
|
||||
import { LogoIcon } from '@/components/Logo'
|
||||
import { TemplateSelector } from '@/components/TemplateSelector'
|
||||
import { SuggestionPanel } from '@/components/SuggestionPanel'
|
||||
@ -13,6 +14,7 @@ import { SearchAdapterFactory } from '@/services/adapters'
|
||||
import { EnginePreferenceService } from '@/services/engine-preference'
|
||||
import { templateManager } from '@/services/template-manager'
|
||||
import { shortcutManager } from '@/services/shortcut-manager'
|
||||
import { WindowManager } from '@/services/window-manager'
|
||||
import { TranslationProvider, useTranslation } from '@/i18n'
|
||||
import { useExtensionVersion } from '@/utils/version'
|
||||
import { autoMigrateStorage } from '@/utils/migration'
|
||||
@ -304,6 +306,11 @@ function App() {
|
||||
try {
|
||||
await templateManager.initialize()
|
||||
await shortcutManager.initialize('popup')
|
||||
|
||||
// v1.6.2: 如果是 detached 窗口,启用窗口位置追踪
|
||||
if (WindowManager.isDetachedWindow()) {
|
||||
WindowManager.setupWindowPositionTracking()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('初始化服务失败:', error)
|
||||
}
|
||||
@ -480,6 +487,7 @@ function PopupContent({
|
||||
<h1 className="text-lg font-semibold">{t('popup.headerTitle')}</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<PopoutButton />
|
||||
<ShortcutHintTrigger onClick={() => setShowShortcutHint(true)} />
|
||||
<SettingsButton />
|
||||
</div>
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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">
|
||||
<style>
|
||||
@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 '../styles/globals.css'
|
||||
|
||||
// 设置国际化标题
|
||||
document.title = chrome.i18n.getMessage('popup_title')
|
||||
|
||||
// 初始化React应用
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('app') as HTMLElement
|
||||
|
||||
242
src/services/window-manager.ts
普通文件
242
src/services/window-manager.ts
普通文件
@ -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: {
|
||||
input: {
|
||||
popup: resolve(__dirname, 'src/popup/index.html'),
|
||||
detached: resolve(__dirname, 'src/detached/index.html'),
|
||||
options: resolve(__dirname, 'src/options/index.html'),
|
||||
background: resolve(__dirname, 'src/background/index.ts'),
|
||||
content: resolve(__dirname, 'src/content/index.ts'),
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户