mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-31 23:11:28 +01:00
right click save image in webview (#962)
This commit is contained in:
parent
b4582fcaff
commit
1bd2fe83cb
@ -38,6 +38,7 @@ export default defineConfig({
|
|||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
index: "emain/preload.ts",
|
index: "emain/preload.ts",
|
||||||
|
"preload-webview": "emain/preload-webview.ts",
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
format: "cjs",
|
format: "cjs",
|
||||||
|
156
emain/emain.ts
156
emain/emain.ts
@ -9,6 +9,7 @@ import * as path from "path";
|
|||||||
import { PNG } from "pngjs";
|
import { PNG } from "pngjs";
|
||||||
import * as readline from "readline";
|
import * as readline from "readline";
|
||||||
import { sprintf } from "sprintf-js";
|
import { sprintf } from "sprintf-js";
|
||||||
|
import { Readable } from "stream";
|
||||||
import { debounce } from "throttle-debounce";
|
import { debounce } from "throttle-debounce";
|
||||||
import * as util from "util";
|
import * as util from "util";
|
||||||
import winston from "winston";
|
import winston from "winston";
|
||||||
@ -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) => {
|
electron.ipcMain.on("download", (event, payload) => {
|
||||||
const window = electron.BrowserWindow.fromWebContents(event.sender);
|
const window = electron.BrowserWindow.fromWebContents(event.sender);
|
||||||
const streamingUrl = getWebServerEndpoint() + "/wave/stream-file?path=" + encodeURIComponent(payload.filePath);
|
const streamingUrl = getWebServerEndpoint() + "/wave/stream-file?path=" + encodeURIComponent(payload.filePath);
|
||||||
@ -702,6 +807,57 @@ async function createNewWaveWindow(): Promise<void> {
|
|||||||
newBrowserWindow.show();
|
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("open-new-window", () => fireAndForget(createNewWaveWindow));
|
||||||
|
|
||||||
electron.ipcMain.on("contextmenu-show", (event, menuDefArr?: ElectronContextMenuItem[]) => {
|
electron.ipcMain.on("contextmenu-show", (event, menuDefArr?: ElectronContextMenuItem[]) => {
|
||||||
|
@ -11,6 +11,16 @@ type AppMenuCallbacks = {
|
|||||||
relaunchBrowserWindows: () => Promise<void>;
|
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 {
|
function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
|
||||||
const fileMenu: Electron.MenuItemConstructorOptions[] = [
|
const fileMenu: Electron.MenuItemConstructorOptions[] = [
|
||||||
{
|
{
|
||||||
@ -30,7 +40,7 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
|
|||||||
{
|
{
|
||||||
label: "About Wave Terminal",
|
label: "About Wave Terminal",
|
||||||
click: (_, window) => {
|
click: (_, window) => {
|
||||||
window?.webContents.send("menu-item-about");
|
getWindowWebContents(window)?.send("menu-item-about");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -122,21 +132,29 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
|
|||||||
label: "Actual Size",
|
label: "Actual Size",
|
||||||
accelerator: "CommandOrControl+0",
|
accelerator: "CommandOrControl+0",
|
||||||
click: (_, window) => {
|
click: (_, window) => {
|
||||||
window.webContents.setZoomFactor(1);
|
getWindowWebContents(window)?.setZoomFactor(1);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Zoom In",
|
label: "Zoom In",
|
||||||
accelerator: "CommandOrControl+=",
|
accelerator: "CommandOrControl+=",
|
||||||
click: (_, window) => {
|
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)",
|
label: "Zoom In (hidden)",
|
||||||
accelerator: "CommandOrControl+Shift+=",
|
accelerator: "CommandOrControl+Shift+=",
|
||||||
click: (_, window) => {
|
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,
|
visible: false,
|
||||||
acceleratorWorksWhenHidden: true,
|
acceleratorWorksWhenHidden: true,
|
||||||
@ -145,7 +163,11 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
|
|||||||
label: "Zoom Out",
|
label: "Zoom Out",
|
||||||
accelerator: "CommandOrControl+-",
|
accelerator: "CommandOrControl+-",
|
||||||
click: (_, window) => {
|
click: (_, window) => {
|
||||||
window.webContents.setZoomFactor(window.webContents.getZoomFactor() - 0.2);
|
const wc = getWindowWebContents(window);
|
||||||
|
if (wc == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wc.setZoomFactor(wc.getZoomFactor() - 0.2);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -36,6 +36,9 @@ ipcMain.on("get-user-name", (event) => {
|
|||||||
ipcMain.on("get-host-name", (event) => {
|
ipcMain.on("get-host-name", (event) => {
|
||||||
event.returnValue = os.hostname();
|
event.returnValue = os.hostname();
|
||||||
});
|
});
|
||||||
|
ipcMain.on("get-webview-preload", (event) => {
|
||||||
|
event.returnValue = path.join(getElectronAppBasePath(), "preload", "preload-webview.cjs");
|
||||||
|
});
|
||||||
|
|
||||||
// must match golang
|
// must match golang
|
||||||
function getWaveHomeDir() {
|
function getWaveHomeDir() {
|
||||||
|
28
emain/preload-webview.ts
Normal file
28
emain/preload-webview.ts
Normal file
@ -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");
|
@ -12,6 +12,7 @@ contextBridge.exposeInMainWorld("api", {
|
|||||||
getHostName: () => ipcRenderer.sendSync("get-host-name"),
|
getHostName: () => ipcRenderer.sendSync("get-host-name"),
|
||||||
getAboutModalDetails: () => ipcRenderer.sendSync("get-about-modal-details"),
|
getAboutModalDetails: () => ipcRenderer.sendSync("get-about-modal-details"),
|
||||||
getDocsiteUrl: () => ipcRenderer.sendSync("get-docsite-url"),
|
getDocsiteUrl: () => ipcRenderer.sendSync("get-docsite-url"),
|
||||||
|
getWebviewPreload: () => ipcRenderer.sendSync("get-webview-preload"),
|
||||||
openNewWindow: () => ipcRenderer.send("open-new-window"),
|
openNewWindow: () => ipcRenderer.send("open-new-window"),
|
||||||
showContextMenu: (menu, position) => ipcRenderer.send("contextmenu-show", menu, position),
|
showContextMenu: (menu, position) => ipcRenderer.send("contextmenu-show", menu, position),
|
||||||
onContextMenuClick: (callback) => ipcRenderer.on("contextmenu-click", (_event, id) => callback(id)),
|
onContextMenuClick: (callback) => ipcRenderer.on("contextmenu-click", (_event, id) => callback(id)),
|
||||||
|
@ -16,6 +16,19 @@ import * as jotai from "jotai";
|
|||||||
import React, { memo, useEffect, useState } from "react";
|
import React, { memo, useEffect, useState } from "react";
|
||||||
import "./webview.less";
|
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 {
|
export class WebViewModel implements ViewModel {
|
||||||
viewType: string;
|
viewType: string;
|
||||||
blockId: string;
|
blockId: string;
|
||||||
@ -501,6 +514,7 @@ const WebView = memo(({ model }: WebViewProps) => {
|
|||||||
src={metaUrlInitial}
|
src={metaUrlInitial}
|
||||||
data-blockid={model.blockId}
|
data-blockid={model.blockId}
|
||||||
data-webcontentsid={webContentsId} // needed for emain
|
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.
|
// @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"
|
allowpopups="true"
|
||||||
></webview>
|
></webview>
|
||||||
|
1
frontend/types/custom.d.ts
vendored
1
frontend/types/custom.d.ts
vendored
@ -57,6 +57,7 @@ declare global {
|
|||||||
getEnv: (varName: string) => string;
|
getEnv: (varName: string) => string;
|
||||||
getUserName: () => string;
|
getUserName: () => string;
|
||||||
getHostName: () => string;
|
getHostName: () => string;
|
||||||
|
getWebviewPreload: () => string;
|
||||||
getAboutModalDetails: () => AboutModalDetails;
|
getAboutModalDetails: () => AboutModalDetails;
|
||||||
getDocsiteUrl: () => string;
|
getDocsiteUrl: () => string;
|
||||||
showContextMenu: (menu?: ElectronContextMenuItem[]) => void;
|
showContextMenu: (menu?: ElectronContextMenuItem[]) => void;
|
||||||
|
Loading…
Reference in New Issue
Block a user