From f160adbdb1acf42d14923011b80ab32e91d0710f Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Sat, 21 Mar 2026 23:42:48 +0800 Subject: [PATCH] feat: add 'pop out tab as new window' functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- electron/main.ts | 70 +++++++++++++++++ electron/preload.ts | 1 + electron/state.ts | 19 +++++ src/App.tsx | 2 + src/components/layout/TabBar/TabBar.tsx | 11 ++- src/components/layout/TitleBar/TitleBar.tsx | 3 + src/pages/NoteBrowser.tsx | 6 ++ src/pages/PopoutPage.tsx | 86 +++++++++++++++++++++ src/types/electron.d.ts | 1 + 9 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 src/pages/PopoutPage.tsx diff --git a/electron/main.ts b/electron/main.ts index 9d221eb..b3c58fe 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -98,6 +98,65 @@ async function createWindow() { } } +async function createSecondaryWindow(tabData: { route: string; title: string }): Promise { + 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.'); diff --git a/electron/preload.ts b/electron/preload.ts index 87c69bc..2988c7e 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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), }) diff --git a/electron/state.ts b/electron/state.ts index 57f69d3..7b41c6f 100644 --- a/electron/state.ts +++ b/electron/state.ts @@ -13,6 +13,8 @@ class ElectronState { isDev: false, } + private windows = new Map() + 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, diff --git a/src/App.tsx b/src/App.tsx index fcff281..d60869d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { + } /> } /> diff --git a/src/components/layout/TabBar/TabBar.tsx b/src/components/layout/TabBar/TabBar.tsx index 5bfa89e..7ad1a60 100644 --- a/src/components/layout/TabBar/TabBar.tsx +++ b/src/components/layout/TabBar/TabBar.tsx @@ -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(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 } ] diff --git a/src/components/layout/TitleBar/TitleBar.tsx b/src/components/layout/TitleBar/TitleBar.tsx index 702908e..d575f24 100644 --- a/src/components/layout/TitleBar/TitleBar.tsx +++ b/src/components/layout/TitleBar/TitleBar.tsx @@ -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) => (
{ } }, [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} /> diff --git a/src/pages/PopoutPage.tsx b/src/pages/PopoutPage.tsx new file mode 100644 index 0000000..23bd7e6 --- /dev/null +++ b/src/pages/PopoutPage.tsx @@ -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(null) + const [error, setError] = useState(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 ( +
+
{error}
+
+ ) + } + + if (!file) { + return ( +
+
Loading...
+
+ ) + } + + 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 ( + 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 + } + + const module = matchModule(file) + if (module) { + const Component = module.component + return + } + + return {}} /> + } + + return ( +
+ {renderContent()} +
+ ) +} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 8065ca5..c6a3c27 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -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 {