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; } });