diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index f3cf112fe0..0ebe48266d 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -55,6 +55,8 @@ export enum IpcChannel { Webview_SetOpenLinkExternal = 'webview:set-open-link-external', Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled', Webview_SearchHotkey = 'webview:search-hotkey', + Webview_PrintToPDF = 'webview:print-to-pdf', + Webview_SaveAsHTML = 'webview:save-as-html', // Open Open_Path = 'open:path', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index a960eb7dc0..d7e82ff875 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -874,6 +874,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { webview.session.setSpellCheckerEnabled(isEnable) }) + // Webview print and save handlers + ipcMain.handle(IpcChannel.Webview_PrintToPDF, async (_, webviewId: number) => { + const { printWebviewToPDF } = await import('./services/WebviewService') + return await printWebviewToPDF(webviewId) + }) + + ipcMain.handle(IpcChannel.Webview_SaveAsHTML, async (_, webviewId: number) => { + const { saveWebviewAsHTML } = await import('./services/WebviewService') + return await saveWebviewAsHTML(webviewId) + }) + // store sync storeSyncService.registerIpcHandler() diff --git a/src/main/services/WebviewService.ts b/src/main/services/WebviewService.ts index fb2049de74..22c9f183fa 100644 --- a/src/main/services/WebviewService.ts +++ b/src/main/services/WebviewService.ts @@ -1,5 +1,6 @@ import { IpcChannel } from '@shared/IpcChannel' -import { app, session, shell, webContents } from 'electron' +import { app, dialog, session, shell, webContents } from 'electron' +import { promises as fs } from 'fs' /** * init the useragent of the webview session @@ -53,11 +54,17 @@ const attachKeyboardHandler = (contents: Electron.WebContents) => { return } - const isFindShortcut = (input.control || input.meta) && key === 'f' - const isEscape = key === 'escape' - const isEnter = key === 'enter' + // Helper to check if this is a shortcut we handle + const isHandledShortcut = (k: string) => { + const isFindShortcut = (input.control || input.meta) && k === 'f' + const isPrintShortcut = (input.control || input.meta) && k === 'p' + const isSaveShortcut = (input.control || input.meta) && k === 's' + const isEscape = k === 'escape' + const isEnter = k === 'enter' + return isFindShortcut || isPrintShortcut || isSaveShortcut || isEscape || isEnter + } - if (!isFindShortcut && !isEscape && !isEnter) { + if (!isHandledShortcut(key)) { return } @@ -66,11 +73,20 @@ const attachKeyboardHandler = (contents: Electron.WebContents) => { return } + const isFindShortcut = (input.control || input.meta) && key === 'f' + const isPrintShortcut = (input.control || input.meta) && key === 'p' + const isSaveShortcut = (input.control || input.meta) && key === 's' + // Always prevent Cmd/Ctrl+F to override the guest page's native find dialog if (isFindShortcut) { event.preventDefault() } + // Prevent default print/save dialogs and handle them with custom logic + if (isPrintShortcut || isSaveShortcut) { + event.preventDefault() + } + // Send the hotkey event to the renderer // The renderer will decide whether to preventDefault for Escape and Enter // based on whether the search bar is visible @@ -100,3 +116,129 @@ export function initWebviewHotkeys() { attachKeyboardHandler(contents) }) } + +/** + * Print webview content to PDF + * @param webviewId The webview webContents id + * @returns Path to saved PDF file or null if user cancelled + */ +export async function printWebviewToPDF(webviewId: number): Promise { + const webview = webContents.fromId(webviewId) + if (!webview) { + throw new Error('Webview not found') + } + + try { + // Get the page title for default filename + const pageTitle = await webview.executeJavaScript('document.title || "webpage"').catch(() => 'webpage') + // Sanitize filename by removing invalid characters + const sanitizedTitle = pageTitle.replace(/[<>:"/\\|?*]/g, '-').substring(0, 100) + const defaultFilename = sanitizedTitle ? `${sanitizedTitle}.pdf` : `webpage-${Date.now()}.pdf` + + // Show save dialog + const { canceled, filePath } = await dialog.showSaveDialog({ + title: 'Save as PDF', + defaultPath: defaultFilename, + filters: [{ name: 'PDF Files', extensions: ['pdf'] }] + }) + + if (canceled || !filePath) { + return null + } + + // Generate PDF with settings to capture full page + const pdfData = await webview.printToPDF({ + marginsType: 0, + printBackground: true, + printSelectionOnly: false, + landscape: false, + pageSize: 'A4', + preferCSSPageSize: true + }) + + // Save PDF to file + await fs.writeFile(filePath, pdfData) + + return filePath + } catch (error) { + throw new Error(`Failed to print to PDF: ${(error as Error).message}`) + } +} + +/** + * Save webview content as HTML + * @param webviewId The webview webContents id + * @returns Path to saved HTML file or null if user cancelled + */ +export async function saveWebviewAsHTML(webviewId: number): Promise { + const webview = webContents.fromId(webviewId) + if (!webview) { + throw new Error('Webview not found') + } + + try { + // Get the page title for default filename + const pageTitle = await webview.executeJavaScript('document.title || "webpage"').catch(() => 'webpage') + // Sanitize filename by removing invalid characters + const sanitizedTitle = pageTitle.replace(/[<>:"/\\|?*]/g, '-').substring(0, 100) + const defaultFilename = sanitizedTitle ? `${sanitizedTitle}.html` : `webpage-${Date.now()}.html` + + // Show save dialog + const { canceled, filePath } = await dialog.showSaveDialog({ + title: 'Save as HTML', + defaultPath: defaultFilename, + filters: [ + { name: 'HTML Files', extensions: ['html', 'htm'] }, + { name: 'All Files', extensions: ['*'] } + ] + }) + + if (canceled || !filePath) { + return null + } + + // Get the HTML content with safe error handling + const html = await webview.executeJavaScript(` + (() => { + try { + // Build complete DOCTYPE string if present + let doctype = ''; + if (document.doctype) { + const dt = document.doctype; + doctype = ''; + } + return doctype + (document.documentElement?.outerHTML || ''); + } catch (error) { + // Fallback: just return the HTML without DOCTYPE if there's an error + return document.documentElement?.outerHTML || ''; + } + })() + `) + + // Save HTML to file + await fs.writeFile(filePath, html, 'utf-8') + + return filePath + } catch (error) { + throw new Error(`Failed to save as HTML: ${(error as Error).message}`) + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index fda288f68e..117bec3b91 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -437,6 +437,8 @@ const api = { ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal), setSpellCheckEnabled: (webviewId: number, isEnable: boolean) => ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable), + printToPDF: (webviewId: number) => ipcRenderer.invoke(IpcChannel.Webview_PrintToPDF, webviewId), + saveAsHTML: (webviewId: number) => ipcRenderer.invoke(IpcChannel.Webview_SaveAsHTML, webviewId), onFindShortcut: (callback: (payload: WebviewKeyEvent) => void) => { const listener = (_event: Electron.IpcRendererEvent, payload: WebviewKeyEvent) => { callback(payload) diff --git a/src/renderer/src/components/MinApp/WebviewContainer.tsx b/src/renderer/src/components/MinApp/WebviewContainer.tsx index 66bb9e554d..dea8243e42 100644 --- a/src/renderer/src/components/MinApp/WebviewContainer.tsx +++ b/src/renderer/src/components/MinApp/WebviewContainer.tsx @@ -106,6 +106,51 @@ const WebviewContainer = memo( // eslint-disable-next-line react-hooks/exhaustive-deps }, [appid, url]) + // Setup keyboard shortcuts handler for print and save + useEffect(() => { + if (!webviewRef.current) return + + const unsubscribe = window.api?.webview?.onFindShortcut?.(async (payload) => { + // Get webviewId when event is triggered + const webviewId = webviewRef.current?.getWebContentsId() + + // Only handle events for this webview + if (!webviewId || payload.webviewId !== webviewId) return + + const key = payload.key?.toLowerCase() + const isModifier = payload.control || payload.meta + + if (!isModifier || !key) return + + try { + if (key === 'p') { + // Print to PDF + logger.info(`Printing webview ${appid} to PDF`) + const filePath = await window.api.webview.printToPDF(webviewId) + if (filePath) { + window.toast?.success?.(`PDF saved to: ${filePath}`) + logger.info(`PDF saved to: ${filePath}`) + } + } else if (key === 's') { + // Save as HTML + logger.info(`Saving webview ${appid} as HTML`) + const filePath = await window.api.webview.saveAsHTML(webviewId) + if (filePath) { + window.toast?.success?.(`HTML saved to: ${filePath}`) + logger.info(`HTML saved to: ${filePath}`) + } + } + } catch (error) { + logger.error(`Failed to handle shortcut for webview ${appid}:`, error as Error) + window.toast?.error?.(`Failed: ${(error as Error).message}`) + } + }) + + return () => { + unsubscribe?.() + } + }, [appid]) + // Update webview settings when they change useEffect(() => { if (!webviewRef.current) return