From 5c6cfbc11202b5022bf3fb96b6955999aeb80ba0 Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 19 Jun 2024 15:42:19 -0700 Subject: [PATCH] terminal context menu --- emain/emain.ts | 60 ++++++++++++++++++++++++++++++++++++-- emain/preload.ts | 4 +++ frontend/app/app.tsx | 28 ++++++++++++++++-- frontend/types/custom.d.ts | 25 ++++++++++++++++ 4 files changed, 112 insertions(+), 5 deletions(-) diff --git a/emain/emain.ts b/emain/emain.ts index a152cde0..cd5ac4b8 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -21,6 +21,8 @@ const AuthKeyFile = "waveterm.authkey"; const DevServerEndpoint = "http://127.0.0.1:8190"; const ProdServerEndpoint = "http://127.0.0.1:1719"; +type WaveBrowserWindow = Electron.BrowserWindow & { waveWindowId: string }; + let waveSrvReadyResolve = (value: boolean) => {}; let waveSrvReady: Promise = new Promise((resolve, _) => { waveSrvReadyResolve = resolve; @@ -130,7 +132,7 @@ function runWaveSrv(): Promise { return rtnPromise; } -async function mainResizeHandler(_: any, windowId: string, win: Electron.BrowserWindow) { +async function mainResizeHandler(_: any, windowId: string, win: WaveBrowserWindow) { if (win == null || win.isDestroyed() || win.fullScreen) { return; } @@ -187,7 +189,7 @@ function shFrameNavHandler(event: Electron.Event primaryDisplay.workAreaSize.height) { winY = Math.floor((primaryDisplay.workAreaSize.height - winHeight) / 2); } - const win = new electron.BrowserWindow({ + const bwin = new electron.BrowserWindow({ x: winX, y: winY, titleBarStyle: "hiddenInset", @@ -224,6 +226,8 @@ function createWindow(client: Client, waveWindow: WaveWindow): Electron.BrowserW autoHideMenuBar: true, backgroundColor: "#000000", }); + (bwin as any).waveWindowId = waveWindow.oid; + const win: WaveBrowserWindow = bwin as WaveBrowserWindow; win.once("ready-to-show", () => { win.show(); }); @@ -283,6 +287,56 @@ electron.ipcMain.on("getCursorPoint", (event) => { event.returnValue = retVal; }); +electron.ipcMain.on("openNewWindow", (event) => {}); + +electron.ipcMain.on("context-editmenu", (_, { x, y }, opts) => { + if (opts == null) { + opts = {}; + } + console.log("context-editmenu"); + const menu = new electron.Menu(); + if (!opts.onlyPaste) { + if (opts.showCut) { + const menuItem = new electron.MenuItem({ label: "Cut", role: "cut" }); + menu.append(menuItem); + } + const menuItem = new electron.MenuItem({ label: "Copy", role: "copy" }); + menu.append(menuItem); + } + const menuItem = new electron.MenuItem({ label: "Paste", role: "paste" }); + menu.append(menuItem); + menu.popup({ x, y }); +}); + +electron.ipcMain.on("contextmenu-show", (event, menuDefArr: ElectronContextMenuItem[], { x, y }) => { + if (menuDefArr == null || menuDefArr.length == 0) { + return; + } + const menu = convertMenuDefArrToMenu(menuDefArr); + menu.popup({ x, y }); + event.returnValue = true; +}); + +function convertMenuDefArrToMenu(menuDefArr: ElectronContextMenuItem[]): electron.Menu { + const menuItems: electron.MenuItem[] = []; + for (const menuDef of menuDefArr) { + const menuItemTemplate: electron.MenuItemConstructorOptions = { + role: menuDef.role as any, + label: menuDef.label, + type: menuDef.type, + click: (_, window) => { + window?.webContents.send("contextmenu-click", menuDef.id); + }, + }; + if (menuDef.submenu != null) { + menuItemTemplate.submenu = convertMenuDefArrToMenu(menuDef.submenu); + } + const menuItem = new electron.MenuItem(menuItemTemplate); + menuItems.push(menuItem); + } + return electron.Menu.buildFromTemplate(menuItems); +} + (async () => { const startTs = Date.now(); const instanceLock = electronApp.requestSingleInstanceLock(); diff --git a/emain/preload.ts b/emain/preload.ts index fdef8d93..4814b571 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -7,4 +7,8 @@ contextBridge.exposeInMainWorld("api", { isDev: () => ipcRenderer.sendSync("isDev"), isDevServer: () => ipcRenderer.sendSync("isDevServer"), getCursorPoint: () => ipcRenderer.sendSync("getCursorPoint"), + openNewWindow: () => ipcRenderer.send("openNewWindow"), + contextEditMenu: (position, opts) => ipcRenderer.send("context-editmenu", position, opts), + showContextMenu: (menu, position) => ipcRenderer.send("contextmenu-show", menu, position), + onContextMenuClick: (callback) => ipcRenderer.on("contextmenu-click", callback), }); diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx index e1a86261..33d17811 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { Workspace } from "@/app/workspace/workspace"; -import { atoms, globalStore } from "@/store/global"; +import { atoms, getApi, globalStore } from "@/store/global"; +import * as util from "@/util/util"; import * as jotai from "jotai"; import { Provider } from "jotai"; @@ -19,6 +20,29 @@ const App = () => { ); }; +function handleContextMenu(e: React.MouseEvent) { + let isInNonTermInput = false; + const activeElem = document.activeElement; + if (activeElem != null && activeElem.nodeName == "TEXTAREA") { + if (!activeElem.classList.contains("xterm-helper-textarea")) { + isInNonTermInput = true; + } + } + if (activeElem != null && activeElem.nodeName == "INPUT" && activeElem.getAttribute("type") == "text") { + isInNonTermInput = true; + } + const opts: ContextMenuOpts = {}; + if (isInNonTermInput) { + opts.showCut = true; + } + const sel = window.getSelection(); + if (!util.isBlank(sel?.toString()) || isInNonTermInput) { + getApi().contextEditMenu({ x: e.clientX, y: e.clientY }, opts); + } else { + getApi().contextEditMenu({ x: e.clientX, y: e.clientY }, { onlyPaste: true }); + } +} + const AppInner = () => { const client = jotai.useAtomValue(atoms.client); const windowData = jotai.useAtomValue(atoms.waveWindow); @@ -31,7 +55,7 @@ const AppInner = () => { ); } return ( -
+
diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index c8ca77f1..51680117 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -6,6 +6,11 @@ declare global { blockId: string; }; + type ContextMenuOpts = { + showCut?: boolean; + onlyPaste?: boolean; + }; + type ElectronApi = { /** * Determines whether the current app instance is a development build. @@ -22,6 +27,26 @@ declare global { * @returns A point value. */ getCursorPoint: () => Electron.Point; + + contextEditMenu: (position: { x: number; y: number }, opts: ContextMenuOpts) => void; + showContextMenu: (menu: ElectronContextMenuItem[], position: { x: number; y: number }) => void; + onContextMenuClick: (callback: (id: string) => void) => void; + }; + + type ElectronContextMenuItem = { + id: string; // unique id, used for communication + label: string; + role?: string; // electron role (optional) + type?: "separator" | "normal" | "submenu"; + submenu?: ElectronContextMenuItem[]; + }; + + type ContextMenuItem = { + label?: string; + type?: "separator" | "normal" | "submenu"; + role?: string; // electron role (optional) + click?: () => void; // not required if role is set + submenu?: ContextMenuItem[]; }; type SubjectWithRef = rxjs.Subject & { refCount: number; release: () => void };