feat: add 'pop out tab as new window' functionality

- Add createWindow IPC for creating secondary windows
- Add PopoutPage for content-only rendering in new windows
- Add multi-window management to electron state
- Add '在新窗口中打开' context menu to tabs
- Fix: Use standard URL path instead of hash for React Router routing
This commit is contained in:
2026-03-21 23:42:48 +08:00
parent 43828a87f0
commit f160adbdb1
9 changed files with 198 additions and 1 deletions

View File

@@ -98,6 +98,65 @@ async function createWindow() {
}
}
async function createSecondaryWindow(tabData: { route: string; title: string }): Promise<number> {
const initialSymbolColor = nativeTheme.shouldUseDarkColors ? '#ffffff' : '#000000';
const win = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
autoHideMenuBar: true,
titleBarStyle: 'hidden',
titleBarOverlay: {
color: '#00000000',
symbolColor: initialSymbolColor,
height: 32,
},
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: false,
webviewTag: true,
preload: path.join(__dirname, 'preload.cjs'),
},
});
electronState.addWindow(win);
win.setMenu(null);
win.on('closed', () => {
electronState.removeWindow(win.id);
});
win.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('http:') || url.startsWith('https:')) {
shell.openExternal(url);
return { action: 'deny' };
}
return { action: 'allow' };
});
const baseUrl = electronState.isDevelopment()
? 'http://localhost:5173'
: `http://localhost:${electronState.getServerPort()}`;
const fullUrl = `${baseUrl}${tabData.route}`;
log.info(`[PopOut] Loading secondary window with URL: ${fullUrl}`);
try {
await win.loadURL(fullUrl);
} catch (e) {
log.error('[PopOut] Failed to load URL:', e);
}
if (electronState.isDevelopment()) {
win.webContents.openDevTools();
}
return win.id;
}
ipcMain.handle('export-pdf', async (event, title, htmlContent) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return { success: false, error: 'No window found' };
@@ -421,6 +480,17 @@ ipcMain.handle('terminal-stop', async () => {
return await terminalService.stop();
});
ipcMain.handle('create-window', async (_event, tabData: { route: string; title: string }) => {
try {
log.info('[PopOut] Creating new window for:', tabData);
const windowId = await createSecondaryWindow(tabData);
return { success: true, windowId };
} catch (error: any) {
log.error('[PopOut] Failed to create window:', error);
return { success: false, error: error.message };
}
});
async function startServer() {
if (electronState.isDevelopment()) {
log.info('In dev mode, assuming external servers are running.');

View File

@@ -53,4 +53,5 @@ contextBridge.exposeInMainWorld('electronAPI', {
terminalStop: () => ipcRenderer.invoke('terminal-stop'),
terminalGetStatus: () => ipcRenderer.invoke('terminal-get-status'),
terminalGetPort: () => ipcRenderer.invoke('terminal-get-port'),
createWindow: (tabData: { route: string; title: string }) => ipcRenderer.invoke('create-window', tabData),
})

View File

@@ -13,6 +13,8 @@ class ElectronState {
isDev: false,
}
private windows = new Map<number, BrowserWindow>()
getMainWindow(): BrowserWindow | null {
return this.state.mainWindow
}
@@ -37,7 +39,24 @@ class ElectronState {
this.state.isDev = isDev
}
addWindow(window: BrowserWindow): void {
this.windows.set(window.id, window)
}
removeWindow(id: number): void {
this.windows.delete(id)
}
getWindow(id: number): BrowserWindow | undefined {
return this.windows.get(id)
}
getAllWindows(): BrowserWindow[] {
return Array.from(this.windows.values())
}
reset(): void {
this.windows.clear()
this.state = {
mainWindow: null,
serverPort: 3001,

View File

@@ -1,5 +1,6 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { NoteBrowser } from '@/pages/NoteBrowser'
import { PopoutPage } from '@/pages/PopoutPage'
import { SettingsSync } from '@/components/settings/SettingsSync'
import { ErrorBoundary } from '@/components/common/ErrorBoundary'
@@ -14,6 +15,7 @@ function App() {
<TimeTrackerProvider>
<SettingsSync />
<Routes>
<Route path="/popout" element={<PopoutPage />} />
<Route path="/*" element={<NoteBrowser />} />
</Routes>
</TimeTrackerProvider>

View File

@@ -15,12 +15,13 @@ export interface TabBarProps {
onTabClose: (file: FileItem, e: React.MouseEvent) => void
onCloseOther?: (file: FileItem) => void
onCloseAll?: () => void
onPopOut?: (file: FileItem) => void
className?: string
style?: React.CSSProperties
variant?: 'default' | 'titlebar'
}
export const TabBar = ({ openFiles, activeFile, onTabClick, onTabClose, onCloseOther, onCloseAll, className, style, variant = 'default' }: TabBarProps) => {
export const TabBar = ({ openFiles, activeFile, onTabClick, onTabClose, onCloseOther, onCloseAll, onPopOut, className, style, variant = 'default' }: TabBarProps) => {
const scrollContainerRef = useRef<HTMLDivElement>(null)
const { opacity } = useWallpaper()
const [contextMenu, setContextMenu] = useState<{
@@ -61,7 +62,15 @@ export const TabBar = ({ openFiles, activeFile, onTabClick, onTabClose, onCloseO
handleCloseContextMenu()
}
const handlePopOut = () => {
if (contextMenu.file && onPopOut) {
onPopOut(contextMenu.file)
}
handleCloseContextMenu()
}
const contextMenuItems = [
{ label: '在新窗口中打开', onClick: handlePopOut },
{ label: '关闭其他标签页', onClick: handleCloseOther },
{ label: '关闭所有标签页', onClick: handleCloseAll }
]

View File

@@ -8,6 +8,7 @@ export interface TitleBarProps {
onTabClose: (file: FileItem, e: React.MouseEvent) => void
onCloseOther?: (file: FileItem) => void
onCloseAll?: () => void
onPopOut?: (file: FileItem) => void
opacity: number
}
@@ -18,6 +19,7 @@ export const TitleBar = ({
onTabClose,
onCloseOther,
onCloseAll,
onPopOut,
opacity
}: TitleBarProps) => (
<div
@@ -35,6 +37,7 @@ export const TitleBar = ({
onTabClose={onTabClose}
onCloseOther={onCloseOther}
onCloseAll={onCloseAll}
onPopOut={onPopOut}
variant="titlebar"
className="h-full border-b-0"
style={{ backgroundColor: 'transparent' }}

View File

@@ -118,6 +118,11 @@ export const NoteBrowser = () => {
}
}, [selectFile])
const handlePopOut = useCallback(async (file: FileItem) => {
const route = `/popout?path=${encodeURIComponent(file.path)}&name=${encodeURIComponent(file.name)}`
await window.electronAPI?.createWindow({ route, title: file.name })
}, [])
useEffect(() => {
if (!isMarkdown && showTOC) {
handleTOCClose()
@@ -146,6 +151,7 @@ export const NoteBrowser = () => {
onTabClose={handleTabClose}
onCloseOther={closeOtherFiles}
onCloseAll={closeAllFiles}
onPopOut={handlePopOut}
opacity={opacity}
/>

86
src/pages/PopoutPage.tsx Normal file
View File

@@ -0,0 +1,86 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import type { FileItemDTO } from '@shared/types/file'
import { matchModule } from '@/lib/module-registry'
import { MarkdownTabPage } from '@/components/tabs/MarkdownTabPage'
import { RemoteTabPage } from '@/modules/remote/RemoteTabPage'
import { FileTransferPage } from '@/modules/remote/components/file-transfer/FileTransferPage'
export const PopoutPage = () => {
const [searchParams] = useSearchParams()
const [file, setFile] = useState<FileItemDTO | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const path = searchParams.get('path')
const name = searchParams.get('name')
if (!path || !name) {
setError('Missing path or name parameter')
return
}
setFile({
name: decodeURIComponent(name),
path: decodeURIComponent(path),
type: 'file',
size: 0,
modified: new Date().toISOString(),
})
}, [searchParams])
if (error) {
return (
<div className="flex items-center justify-center h-screen bg-white dark:bg-gray-900">
<div className="text-red-500">{error}</div>
</div>
)
}
if (!file) {
return (
<div className="flex items-center justify-center h-screen bg-white dark:bg-gray-900">
<div className="text-gray-500">Loading...</div>
</div>
)
}
const renderContent = () => {
if (file.path.startsWith('file-transfer-panel')) {
const queryString = file.path.includes('?') ? file.path.split('?')[1] : ''
const urlParams = new URLSearchParams(queryString)
const serverHost = urlParams.get('host') || ''
const port = parseInt(urlParams.get('port') || '3000', 10)
const password = urlParams.get('password') || undefined
return (
<FileTransferPage
serverHost={serverHost}
port={port}
password={password}
onClose={() => window.close()}
/>
)
}
if (file.path.startsWith('remote-desktop://') || file.path.startsWith('remote-git://')) {
const urlParams = new URLSearchParams(file.path.split('?')[1])
const url = urlParams.get('url') || 'https://www.baidu.com'
const deviceName = urlParams.get('device') || ''
return <RemoteTabPage url={url} title={file.name} deviceName={deviceName} />
}
const module = matchModule(file)
if (module) {
const Component = module.component
return <Component />
}
return <MarkdownTabPage file={file} onTocUpdated={() => {}} />
}
return (
<div className="h-screen w-screen overflow-hidden bg-white dark:bg-gray-900">
{renderContent()}
</div>
)
}

View File

@@ -53,6 +53,7 @@ export interface ElectronAPI {
terminalStop: () => Promise<{ success: boolean; error?: string }>
terminalGetStatus: () => Promise<{ running: boolean; port: number }>
terminalGetPort: () => Promise<{ port: number }>
createWindow: (tabData: { route: string; title: string }) => Promise<{ success: boolean; windowId?: number; error?: string }>
}
declare global {