From 38972b0f35dd7852d0402742ff37676ee38cbe16 Mon Sep 17 00:00:00 2001 From: Andrew Zambazos Date: Fri, 15 Aug 2025 18:53:15 +1200 Subject: [PATCH] Add 'Save Image As...' to context menu and image saving support Introduces a 'Save Image As...' option to the context menu for images. Implements IPC handlers in main and preload scripts to save images from URLs, data URLs, and file URLs. Updates renderer logic to handle saving images from various sources, including blob URLs via webview conversion. --- main.js | 57 ++++++++++++++++++++++++++++++++++++++++++++++ preload.js | 4 +++- renderer/script.js | 38 +++++++++++++++++++++++++++++-- 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/main.js b/main.js index 2958f6c..38b1256 100644 --- a/main.js +++ b/main.js @@ -643,6 +643,7 @@ function buildAndShowContextMenu(sender, params = {}) { template.push( { label: 'Open Image in New Tab', click: () => embedder.send('context-menu-command', { cmd: 'open-image-new-tab', url: imageURL }) }, { label: 'Copy Image Address', click: () => clipboard.writeText(imageURL) }, + { label: 'Save Image As...', click: () => embedder.send('context-menu-command', { cmd: 'save-image', url: imageURL, mime: params.mediaType === 'image' ? params.mimeType : undefined }) }, { type: 'separator' } ); } @@ -688,3 +689,59 @@ app.on('web-contents-created', (event, contents) => { buildAndShowContextMenu(contents, params); }); }); + +// --- Image save handlers --- +ipcMain.handle('save-image-from-dataurl', async (event, { suggestedName = 'image', dataUrl }) => { + try { + if (!dataUrl || !dataUrl.startsWith('data:')) return false; + const match = /^data:(.*?);base64,(.*)$/.exec(dataUrl); + if (!match) return false; + const mime = match[1] || 'application/octet-stream'; + const ext = (mime.split('/')[1] || 'png').split(';')[0]; + const buf = Buffer.from(match[2], 'base64'); + const win = BrowserWindow.fromWebContents(event.sender.hostWebContents || event.sender); + const { canceled, filePath } = await dialog.showSaveDialog(win, { defaultPath: `${suggestedName}.${ext}` }); + if (canceled || !filePath) return false; + await fs.promises.writeFile(filePath, buf); + return true; + } catch (err) { + console.error('save-image-from-dataurl failed:', err); + return false; + } +}); + +ipcMain.handle('save-image-from-url', async (event, { url }) => { + if (!url) return false; + const win = BrowserWindow.fromWebContents(event.sender.hostWebContents || event.sender); + try { + let dataBuf; + if (url.startsWith('http')) { + const res = await fetch(url); + if (!res.ok) throw new Error('HTTP '+res.status); + const arrayBuf = await res.arrayBuffer(); + dataBuf = Buffer.from(arrayBuf); + const ctype = res.headers.get('content-type') || 'application/octet-stream'; + const ext = (ctype.split('/')[1] || 'png').split(';')[0]; + const { canceled, filePath } = await dialog.showSaveDialog(win, { defaultPath: `image.${ext}` }); + if (canceled || !filePath) return false; + await fs.promises.writeFile(filePath, dataBuf); + return true; + } else if (url.startsWith('data:')) { + // Forward to dataURL handler path – easier to keep logic single + return ipcMain.emit('save-image-from-dataurl', event, { dataUrl: url }); + } else if (url.startsWith('file:')) { + // Copy file to chosen destination + const filePathSrc = new URL(url).pathname.replace(/^\//, ''); + const base = path.basename(filePathSrc); + const { canceled, filePath } = await dialog.showSaveDialog(win, { defaultPath: base }); + if (canceled || !filePath) return false; + await fs.promises.copyFile(filePathSrc, filePath); + return true; + } else { + return false; + } + } catch (err) { + console.error('save-image-from-url failed:', err); + return false; + } +}); diff --git a/preload.js b/preload.js index 91d7b49..fafa572 100644 --- a/preload.js +++ b/preload.js @@ -70,7 +70,9 @@ const electronAPI = { } catch (err) { console.error('IPC showContextMenu error:', err); } - } + }, + saveImageToDisk: async (suggestedName, dataUrl) => ipcRenderer.invoke('save-image-from-dataurl', { suggestedName, dataUrl }), + saveImageFromNet: async (url) => ipcRenderer.invoke('save-image-from-url', { url }) }; // Cache for bookmarks to reduce IPC calls diff --git a/renderer/script.js b/renderer/script.js index 31e6f9b..4787289 100644 --- a/renderer/script.js +++ b/renderer/script.js @@ -87,6 +87,9 @@ function getTabLabel(tab) { if (tab.title && tab.title !== 'New Tab') return tab.title; const u = tab.url || ''; try { + if (u.startsWith('data:image')) return 'Image'; + if (u.startsWith('data:')) return 'Data'; + if (u.startsWith('blob:')) return 'Resource'; if (u.startsWith('http')) return new URL(u).hostname; if (u.startsWith('browser://')) return u.replace('browser://', ''); return u || 'New Tab'; @@ -156,7 +159,17 @@ function createTab(inputUrl) { } // For all other URLs, use webview - const resolvedUrl = resolveInternalUrl(inputUrl); + let resolvedUrl = resolveInternalUrl(inputUrl); + // If it's a raw data: URL (image) keep as is; blob: will only resolve within its origin context (may fail) + // For very long data URLs we could embed them in a minimal viewer page for cleaner rendering. + if (resolvedUrl.startsWith('data:') && resolvedUrl.length > 4096) { + // Create a simple object URL page to avoid huge URL in the address bar (cannot easily persist across restarts). + const html = ``+ + ``+ + ``; + const blob = new Blob([html], { type: 'text/html' }); + resolvedUrl = URL.createObjectURL(blob); + } debug('[DEBUG] createTab() resolvedUrl =', resolvedUrl); const webview = document.createElement('webview'); @@ -271,7 +284,9 @@ function resolveInternalUrl(url) { if (allowedInternalPages.includes(page)) return `${page}.html`; else return '404.html'; } - return url.startsWith('http') ? url : `https://${url}`; + // Allow direct loading of common schemes without forcing https:// + if (/^(https?:|file:|data:|blob:)/i.test(url)) return url; + return `https://${url}`; } @@ -1023,5 +1038,24 @@ window.addEventListener('nebula-context-command', (e) => { case 'open-image-new-tab': if (url) createTab(url); break; + case 'save-image': + if (!url) return; + // Try direct network save first (http/file/data) + if (/^(https?:|file:|data:)/i.test(url)) { + window.electronAPI.saveImageFromNet(url); + return; + } + // For blob: URLs we need to resolve inside the active webview by converting to dataURL + if (url.startsWith('blob:')) { + const webview = document.getElementById(`tab-${activeTabId}`); + if (webview) { + webview.executeJavaScript(`(async()=>{try{const r=await fetch('${url}');const b=await r.blob();return new Promise(res=>{const fr=new FileReader();fr.onload=()=>res(fr.result);fr.readAsDataURL(b);});}catch(e){return null;}})();`).then(dataUrl=>{ + if (dataUrl) { + window.electronAPI.saveImageToDisk('image', dataUrl); + } + }); + } + } + break; } });