Skip to content

right click save image in webview #962

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Oct 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export default defineConfig({
rollupOptions: {
input: {
index: "emain/preload.ts",
"preload-webview": "emain/preload-webview.ts",
},
output: {
format: "cjs",
Expand Down
156 changes: 156 additions & 0 deletions emain/emain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as path from "path";
import { PNG } from "pngjs";
import * as readline from "readline";
import { sprintf } from "sprintf-js";
import { Readable } from "stream";
import { debounce } from "throttle-debounce";
import * as util from "util";
import winston from "winston";
Expand Down Expand Up @@ -581,6 +582,110 @@ electron.ipcMain.on("open-external", (event, url) => {
}
});

type UrlInSessionResult = {
stream: Readable;
mimeType: string;
fileName: string;
};

function getSingleHeaderVal(headers: Record<string, string | string[]>, key: string): string {
const val = headers[key];
if (val == null) {
return null;
}
if (Array.isArray(val)) {
return val[0];
}
return val;
}

function cleanMimeType(mimeType: string): string {
if (mimeType == null) {
return null;
}
const parts = mimeType.split(";");
return parts[0].trim();
}

function getFileNameFromUrl(url: string): string {
try {
const pathname = new URL(url).pathname;
const filename = pathname.substring(pathname.lastIndexOf("/") + 1);
return filename;
} catch (e) {
return null;
}
}

function getUrlInSession(session: Electron.Session, url: string): Promise<UrlInSessionResult> {
return new Promise((resolve, reject) => {
// Handle data URLs directly
if (url.startsWith("data:")) {
const parts = url.split(",");
if (parts.length < 2) {
return reject(new Error("Invalid data URL"));
}
const header = parts[0]; // Get the data URL header (e.g., data:image/png;base64)
const base64Data = parts[1]; // Get the base64 data part
const mimeType = header.split(";")[0].slice(5); // Extract the MIME type (after "data:")
const buffer = Buffer.from(base64Data, "base64");
const readable = Readable.from(buffer);
resolve({ stream: readable, mimeType, fileName: "image" });
return;
}
const request = electron.net.request({
url,
method: "GET",
session, // Attach the session directly to the request
});
const readable = new Readable({
read() {}, // No-op, we'll push data manually
});
request.on("response", (response) => {
const mimeType = cleanMimeType(getSingleHeaderVal(response.headers, "content-type"));
const fileName = getFileNameFromUrl(url) || "image";
response.on("data", (chunk) => {
readable.push(chunk); // Push data to the readable stream
});
response.on("end", () => {
readable.push(null); // Signal the end of the stream
resolve({ stream: readable, mimeType, fileName });
});
});
request.on("error", (err) => {
readable.destroy(err); // Destroy the stream on error
reject(err);
});
request.end();
});
}

electron.ipcMain.on("webview-image-contextmenu", (event: electron.IpcMainEvent, payload: { src: string }) => {
const menu = new electron.Menu();
const win = electron.BrowserWindow.fromWebContents(event.sender.hostWebContents);
if (win == null) {
return;
}
menu.append(
new electron.MenuItem({
label: "Save Image",
click: () => {
const resultP = getUrlInSession(event.sender.session, payload.src);
resultP
.then((result) => {
saveImageFileWithNativeDialog(result.fileName, result.mimeType, result.stream);
})
.catch((e) => {
console.log("error getting image", e);
});
},
})
);
const { x, y } = electron.screen.getCursorScreenPoint();
const windowPos = win.getPosition();
menu.popup({ window: win, x: x - windowPos[0], y: y - windowPos[1] });
});

electron.ipcMain.on("download", (event, payload) => {
const window = electron.BrowserWindow.fromWebContents(event.sender);
const streamingUrl = getWebServerEndpoint() + "/wave/stream-file?path=" + encodeURIComponent(payload.filePath);
Expand Down Expand Up @@ -702,6 +807,57 @@ async function createNewWaveWindow(): Promise<void> {
newBrowserWindow.show();
}

function saveImageFileWithNativeDialog(defaultFileName: string, mimeType: string, readStream: Readable) {
if (defaultFileName == null || defaultFileName == "") {
defaultFileName = "image";
}
const window = electron.BrowserWindow.getFocusedWindow(); // Get the current window context
const mimeToExtension: { [key: string]: string } = {
"image/png": "png",
"image/jpeg": "jpg",
"image/gif": "gif",
"image/webp": "webp",
"image/bmp": "bmp",
"image/tiff": "tiff",
"image/heic": "heic",
};
function addExtensionIfNeeded(fileName: string, mimeType: string): string {
const extension = mimeToExtension[mimeType];
if (!path.extname(fileName) && extension) {
return `${fileName}.${extension}`;
}
return fileName;
}
defaultFileName = addExtensionIfNeeded(defaultFileName, mimeType);
electron.dialog
.showSaveDialog(window, {
title: "Save Image",
defaultPath: defaultFileName,
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "heic"] }],
})
.then((file) => {
if (file.canceled) {
return;
}
const writeStream = fs.createWriteStream(file.filePath);
readStream.pipe(writeStream);
writeStream.on("finish", () => {
console.log("saved file", file.filePath);
});
writeStream.on("error", (err) => {
console.log("error saving file (writeStream)", err);
readStream.destroy();
});
readStream.on("error", (err) => {
console.error("error saving file (readStream)", err);
writeStream.destroy(); // Stop the write stream
});
})
.catch((err) => {
console.log("error trying to save file", err);
});
}

electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow));

electron.ipcMain.on("contextmenu-show", (event, menuDefArr?: ElectronContextMenuItem[]) => {
Expand Down
32 changes: 27 additions & 5 deletions emain/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ type AppMenuCallbacks = {
relaunchBrowserWindows: () => Promise<void>;
};

function getWindowWebContents(window: electron.BaseWindow): electron.WebContents {
if (window == null) {
return null;
}
if (window instanceof electron.BrowserWindow) {
return window.webContents;
}
return null;
}

function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
const fileMenu: Electron.MenuItemConstructorOptions[] = [
{
Expand All @@ -30,7 +40,7 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
{
label: "About Wave Terminal",
click: (_, window) => {
window?.webContents.send("menu-item-about");
getWindowWebContents(window)?.send("menu-item-about");
},
},
{
Expand Down Expand Up @@ -122,21 +132,29 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
label: "Actual Size",
accelerator: "CommandOrControl+0",
click: (_, window) => {
window.webContents.setZoomFactor(1);
getWindowWebContents(window)?.setZoomFactor(1);
},
},
{
label: "Zoom In",
accelerator: "CommandOrControl+=",
click: (_, window) => {
window.webContents.setZoomFactor(window.webContents.getZoomFactor() + 0.2);
const wc = getWindowWebContents(window);
if (wc == null) {
return;
}
wc.setZoomFactor(wc.getZoomFactor() + 0.2);
},
},
{
label: "Zoom In (hidden)",
accelerator: "CommandOrControl+Shift+=",
click: (_, window) => {
window.webContents.setZoomFactor(window.webContents.getZoomFactor() + 0.2);
const wc = getWindowWebContents(window);
if (wc == null) {
return;
}
wc.setZoomFactor(wc.getZoomFactor() + 0.2);
},
visible: false,
acceleratorWorksWhenHidden: true,
Expand All @@ -145,7 +163,11 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
label: "Zoom Out",
accelerator: "CommandOrControl+-",
click: (_, window) => {
window.webContents.setZoomFactor(window.webContents.getZoomFactor() - 0.2);
const wc = getWindowWebContents(window);
if (wc == null) {
return;
}
wc.setZoomFactor(wc.getZoomFactor() - 0.2);
},
},
{
Expand Down
3 changes: 3 additions & 0 deletions emain/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ ipcMain.on("get-user-name", (event) => {
ipcMain.on("get-host-name", (event) => {
event.returnValue = os.hostname();
});
ipcMain.on("get-webview-preload", (event) => {
event.returnValue = path.join(getElectronAppBasePath(), "preload", "preload-webview.cjs");
});

// must match golang
function getWaveHomeDir() {
Expand Down
28 changes: 28 additions & 0 deletions emain/preload-webview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

const { ipcRenderer } = require("electron");

document.addEventListener("contextmenu", (event) => {
console.log("contextmenu event", event);
if (event.target == null) {
return;
}
const targetElement = event.target as HTMLElement;
// Check if the right-click is on an image
if (targetElement.tagName === "IMG") {
setTimeout(() => {
if (event.defaultPrevented) {
return;
}
event.preventDefault();
const imgElem = targetElement as HTMLImageElement;
const imageUrl = imgElem.src;
ipcRenderer.send("webview-image-contextmenu", { src: imageUrl });
}, 50);
return;
}
// do nothing
});

console.log("loaded wave preload-webview.ts");
1 change: 1 addition & 0 deletions emain/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ contextBridge.exposeInMainWorld("api", {
getHostName: () => ipcRenderer.sendSync("get-host-name"),
getAboutModalDetails: () => ipcRenderer.sendSync("get-about-modal-details"),
getDocsiteUrl: () => ipcRenderer.sendSync("get-docsite-url"),
getWebviewPreload: () => ipcRenderer.sendSync("get-webview-preload"),
openNewWindow: () => ipcRenderer.send("open-new-window"),
showContextMenu: (menu, position) => ipcRenderer.send("contextmenu-show", menu, position),
onContextMenuClick: (callback) => ipcRenderer.on("contextmenu-click", (_event, id) => callback(id)),
Expand Down
14 changes: 14 additions & 0 deletions frontend/app/view/webview/webview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ import * as jotai from "jotai";
import React, { memo, useEffect, useState } from "react";
import "./webview.less";

let webviewPreloadUrl = null;

function getWebviewPreloadUrl() {
if (webviewPreloadUrl == null) {
webviewPreloadUrl = getApi().getWebviewPreload();
console.log("webviewPreloadUrl", webviewPreloadUrl);
}
if (webviewPreloadUrl == null) {
return null;
}
return "file://" + webviewPreloadUrl;
}

export class WebViewModel implements ViewModel {
viewType: string;
blockId: string;
Expand Down Expand Up @@ -501,6 +514,7 @@ const WebView = memo(({ model }: WebViewProps) => {
src={metaUrlInitial}
data-blockid={model.blockId}
data-webcontentsid={webContentsId} // needed for emain
preload={getWebviewPreloadUrl()}
// @ts-ignore This is a discrepancy between the React typing and the Chromium impl for webviewTag. Chrome webviewTag expects a string, while React expects a boolean.
allowpopups="true"
></webview>
Expand Down
1 change: 1 addition & 0 deletions frontend/types/custom.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ declare global {
getEnv: (varName: string) => string;
getUserName: () => string;
getHostName: () => string;
getWebviewPreload: () => string;
getAboutModalDetails: () => AboutModalDetails;
getDocsiteUrl: () => string;
showContextMenu: (menu?: ElectronContextMenuItem[]) => void;
Expand Down