Skip to content

Commit

Permalink
right click save image in webview (#962)
Browse files Browse the repository at this point in the history
  • Loading branch information
sawka authored Oct 4, 2024
1 parent b4582fc commit 1bd2fe8
Show file tree
Hide file tree
Showing 8 changed files with 231 additions and 5 deletions.
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

0 comments on commit 1bd2fe8

Please sign in to comment.