Compare commits
12 Commits
feat/copy-
...
copilot/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
984eb7ac5f | ||
|
|
0ae9c12cb1 | ||
|
|
92d558feff | ||
|
|
ac8d28687d | ||
|
|
6ec0985ae5 | ||
|
|
f47673a153 | ||
|
|
5545921b8b | ||
|
|
70d8a8ac28 | ||
|
|
345b7cf231 | ||
|
|
71d924854e | ||
|
|
2a51ec628e | ||
|
|
9b8402da0c |
@@ -54,6 +54,8 @@ export enum IpcChannel {
|
|||||||
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
|
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
|
||||||
Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled',
|
Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled',
|
||||||
Webview_SearchHotkey = 'webview:search-hotkey',
|
Webview_SearchHotkey = 'webview:search-hotkey',
|
||||||
|
Webview_PrintToPDF = 'webview:print-to-pdf',
|
||||||
|
Webview_SaveAsHTML = 'webview:save-as-html',
|
||||||
|
|
||||||
// Open
|
// Open
|
||||||
Open_Path = 'open:path',
|
Open_Path = 'open:path',
|
||||||
|
|||||||
@@ -809,6 +809,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
webview.session.setSpellCheckerEnabled(isEnable)
|
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
|
// store sync
|
||||||
storeSyncService.registerIpcHandler()
|
storeSyncService.registerIpcHandler()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { IpcChannel } from '@shared/IpcChannel'
|
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
|
* init the useragent of the webview session
|
||||||
@@ -53,11 +54,17 @@ const attachKeyboardHandler = (contents: Electron.WebContents) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const isFindShortcut = (input.control || input.meta) && key === 'f'
|
// Helper to check if this is a shortcut we handle
|
||||||
const isEscape = key === 'escape'
|
const isHandledShortcut = (k: string) => {
|
||||||
const isEnter = key === 'enter'
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,11 +73,20 @@ const attachKeyboardHandler = (contents: Electron.WebContents) => {
|
|||||||
return
|
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
|
// Always prevent Cmd/Ctrl+F to override the guest page's native find dialog
|
||||||
if (isFindShortcut) {
|
if (isFindShortcut) {
|
||||||
event.preventDefault()
|
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
|
// Send the hotkey event to the renderer
|
||||||
// The renderer will decide whether to preventDefault for Escape and Enter
|
// The renderer will decide whether to preventDefault for Escape and Enter
|
||||||
// based on whether the search bar is visible
|
// based on whether the search bar is visible
|
||||||
@@ -100,3 +116,129 @@ export function initWebviewHotkeys() {
|
|||||||
attachKeyboardHandler(contents)
|
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<string | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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 = '<!DOCTYPE ' + (dt.name || 'html');
|
||||||
|
|
||||||
|
// Add PUBLIC identifier if publicId is present
|
||||||
|
if (dt.publicId) {
|
||||||
|
// Escape single quotes in publicId
|
||||||
|
const escapedPublicId = String(dt.publicId).replace(/'/g, "\\'");
|
||||||
|
doctype += " PUBLIC '" + escapedPublicId + "'";
|
||||||
|
|
||||||
|
// Add systemId if present (required when publicId is present)
|
||||||
|
if (dt.systemId) {
|
||||||
|
const escapedSystemId = String(dt.systemId).replace(/'/g, "\\'");
|
||||||
|
doctype += " '" + escapedSystemId + "'";
|
||||||
|
}
|
||||||
|
} else if (dt.systemId) {
|
||||||
|
// SYSTEM identifier (without PUBLIC)
|
||||||
|
const escapedSystemId = String(dt.systemId).replace(/'/g, "\\'");
|
||||||
|
doctype += " SYSTEM '" + escapedSystemId + "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -406,6 +406,8 @@ const api = {
|
|||||||
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal),
|
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal),
|
||||||
setSpellCheckEnabled: (webviewId: number, isEnable: boolean) =>
|
setSpellCheckEnabled: (webviewId: number, isEnable: boolean) =>
|
||||||
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable),
|
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) => {
|
onFindShortcut: (callback: (payload: WebviewKeyEvent) => void) => {
|
||||||
const listener = (_event: Electron.IpcRendererEvent, payload: WebviewKeyEvent) => {
|
const listener = (_event: Electron.IpcRendererEvent, payload: WebviewKeyEvent) => {
|
||||||
callback(payload)
|
callback(payload)
|
||||||
|
|||||||
@@ -106,6 +106,51 @@ const WebviewContainer = memo(
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [appid, url])
|
}, [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
|
// Update webview settings when they change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!webviewRef.current) return
|
if (!webviewRef.current) return
|
||||||
|
|||||||
Reference in New Issue
Block a user