From a91c69982c603b26e925263372dc99634004fb23 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 16:42:33 +0800 Subject: [PATCH] feat: Add print to PDF and save as HTML for mini program webviews (#11104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * ✨ feat: Add print to PDF and save as HTML for mini program webviews - Add IPC channels for Webview_PrintToPDF and Webview_SaveAsHTML - Implement printWebviewToPDF and saveWebviewAsHTML functions in WebviewService - Add keyboard shortcuts handlers (Cmd/Ctrl+P for print, Cmd/Ctrl+S for save) - Update WebviewContainer to handle keyboard shortcuts and trigger print/save actions - Add preload API methods for webview print and save operations Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> * ♻️ refactor: Improve webview ID handling and remove unused import - Get webviewId dynamically when shortcuts are triggered instead of caching - Remove unused 'join' import from WebviewService - Add null check for webviewId in shortcut handler Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> * 🐛 fix: Improve doctype serialization for better cross-browser compatibility - Use direct DOCTYPE construction instead of XMLSerializer - More reliable across different browser contexts - Based on code review feedback Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> * ✨ enhance: Complete DOCTYPE support with publicId and systemId - Add full DOCTYPE construction including publicId and systemId - Fix grammar in comment (custom -> with custom logic) - Ensures proper HTML5 and XHTML document types are preserved Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> * 🔒 security: Add input validation and improve code structure - Extract shortcut checking logic into isHandledShortcut helper - Add try-catch in executeJavaScript for safety - Escape quotes in DOCTYPE publicId and systemId - Add optional chaining for documentElement - Provide fallback if doctype properties are unexpected Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> * 🐛 fix: Correct DOCTYPE declaration format and quote escaping - Use single quotes and proper escaping for DOCTYPE attributes - Add SYSTEM keyword for systemId-only cases - Ensure PUBLIC is only used with publicId - Fix quote escaping to use backslashes instead of HTML entities - Properly handle all DOCTYPE declaration formats per HTML/SGML spec Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> * 🐛 fix: Correct JavaScript string escaping for quotes - Fix quote escaping from \\\\\' to \\' - Ensures proper backslash escaping in JavaScript template string - Produces correct \' in the final DOCTYPE string Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> * fix(i18n): Auto update translations for PR #11104 * ✨ enhance: Use page title for default filename and improve PDF pagination - Get page title from webview and use it as default filename for both PDF and HTML export - Sanitize filename by removing invalid characters and limiting length - Add preferCSSPageSize option to printToPDF for better multi-page support - Fallback to timestamp-based filename if title is unavailable Addresses reviewer feedback and fixes PDF export only capturing first page issue. Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> * 🎨 style: Fix formatting issues - Remove trailing whitespace in WebviewContainer.tsx - Run biome format and lint to ensure code style compliance Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> Co-authored-by: GitHub Action Co-authored-by: SuYao --- packages/shared/IpcChannel.ts | 2 + src/main/ipc.ts | 11 ++ src/main/services/WebviewService.ts | 152 +++++++++++++++++- src/preload/index.ts | 2 + .../components/MinApp/WebviewContainer.tsx | 45 ++++++ 5 files changed, 207 insertions(+), 5 deletions(-) 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