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.
This commit is contained in:
@@ -643,6 +643,7 @@ function buildAndShowContextMenu(sender, params = {}) {
|
|||||||
template.push(
|
template.push(
|
||||||
{ label: 'Open Image in New Tab', click: () => embedder.send('context-menu-command', { cmd: 'open-image-new-tab', url: imageURL }) },
|
{ 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: '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' }
|
{ type: 'separator' }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -688,3 +689,59 @@ app.on('web-contents-created', (event, contents) => {
|
|||||||
buildAndShowContextMenu(contents, params);
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
+3
-1
@@ -70,7 +70,9 @@ const electronAPI = {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('IPC showContextMenu error:', 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
|
// Cache for bookmarks to reduce IPC calls
|
||||||
|
|||||||
+36
-2
@@ -87,6 +87,9 @@ function getTabLabel(tab) {
|
|||||||
if (tab.title && tab.title !== 'New Tab') return tab.title;
|
if (tab.title && tab.title !== 'New Tab') return tab.title;
|
||||||
const u = tab.url || '';
|
const u = tab.url || '';
|
||||||
try {
|
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('http')) return new URL(u).hostname;
|
||||||
if (u.startsWith('browser://')) return u.replace('browser://', '');
|
if (u.startsWith('browser://')) return u.replace('browser://', '');
|
||||||
return u || 'New Tab';
|
return u || 'New Tab';
|
||||||
@@ -156,7 +159,17 @@ function createTab(inputUrl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For all other URLs, use webview
|
// 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 = `<html><body style="margin:0;background:#111;display:flex;align-items:center;justify-content:center;">`+
|
||||||
|
`<img src="${resolvedUrl}" style="max-width:100%;max-height:100%;object-fit:contain;"/>`+
|
||||||
|
`</body></html>`;
|
||||||
|
const blob = new Blob([html], { type: 'text/html' });
|
||||||
|
resolvedUrl = URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
debug('[DEBUG] createTab() resolvedUrl =', resolvedUrl);
|
debug('[DEBUG] createTab() resolvedUrl =', resolvedUrl);
|
||||||
|
|
||||||
const webview = document.createElement('webview');
|
const webview = document.createElement('webview');
|
||||||
@@ -271,7 +284,9 @@ function resolveInternalUrl(url) {
|
|||||||
if (allowedInternalPages.includes(page)) return `${page}.html`;
|
if (allowedInternalPages.includes(page)) return `${page}.html`;
|
||||||
else return '404.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':
|
case 'open-image-new-tab':
|
||||||
if (url) createTab(url);
|
if (url) createTab(url);
|
||||||
break;
|
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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user