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:
@@ -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.');
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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
86
src/pages/PopoutPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/types/electron.d.ts
vendored
1
src/types/electron.d.ts
vendored
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user