From a104a6e4460d4b380655443027dcdad414d56f45 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Thu, 29 Aug 2024 16:06:15 -0700 Subject: [PATCH] new focus system part 1 (#290) --- emain/emain.ts | 91 ++++++++ emain/preload.ts | 5 + frontend/app/app.tsx | 45 +++- frontend/app/block/block.tsx | 6 +- frontend/app/block/blockframe.tsx | 1 + frontend/app/modals/modalsrenderer.tsx | 6 +- frontend/app/store/global.ts | 2 + frontend/app/{appkey.ts => store/keymodel.ts} | 195 ++++++++++++------ frontend/app/store/wshserver.ts | 2 +- frontend/app/view/webview/webview.tsx | 77 +++++-- frontend/app/workspace/workspace.less | 1 + frontend/types/custom.d.ts | 7 +- frontend/util/focusutil.ts | 70 +++++++ frontend/util/keyutil.ts | 14 +- frontend/wave.ts | 8 + pkg/tsgen/tsgen.go | 6 +- 16 files changed, 442 insertions(+), 94 deletions(-) rename frontend/app/{appkey.ts => store/keymodel.ts} (54%) create mode 100644 frontend/util/focusutil.ts diff --git a/emain/emain.ts b/emain/emain.ts index 7766666b..988794d4 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -16,6 +16,7 @@ import { initGlobal } from "../frontend/app/store/global"; import * as services from "../frontend/app/store/services"; import { WSServerEndpointVarName, WebServerEndpointVarName, getWebServerEndpoint } from "../frontend/util/endpoints"; import { fetch } from "../frontend/util/fetchutil"; +import * as keyutil from "../frontend/util/keyutil"; import { fireAndForget } from "../frontend/util/util"; import { AuthKey, AuthKeyEnv, configureAuthKeyRequestInjection } from "./authkey"; import { getAppMenu } from "./menu"; @@ -54,6 +55,9 @@ let globalIsRelaunching = false; let wasActive = true; let wasInFg = true; +let webviewFocusId: number = null; // set to the getWebContentsId of the webview that has focus (null if not focused) +let webviewKeys: string[] = []; // the keys to trap when webview has focus + let waveSrvProc: child_process.ChildProcessWithoutNullStreams | null = null; const waveHome = getWaveHomeDir(); @@ -104,6 +108,42 @@ function getWindowForEvent(event: Electron.IpcMainEvent): Electron.BrowserWindow return electron.BrowserWindow.fromId(windowId); } +function setCtrlShift(wc: Electron.WebContents, state: boolean) { + wc.send("control-shift-state-update", state); +} + +function handleCtrlShiftState(sender: Electron.WebContents, waveEvent: WaveKeyboardEvent) { + if (waveEvent.type == "keyup") { + if (waveEvent.key === "Control" || waveEvent.key === "Shift") { + setCtrlShift(sender, false); + } + if (waveEvent.key == "Meta") { + if (waveEvent.control && waveEvent.shift) { + setCtrlShift(sender, true); + } + } + return; + } + if (waveEvent.type == "keydown") { + if (waveEvent.key === "Control" || waveEvent.key === "Shift" || waveEvent.key === "Meta") { + if (waveEvent.control && waveEvent.shift && !waveEvent.meta) { + // Set the control and shift without the Meta key + setCtrlShift(sender, true); + } else { + // Unset if Meta is pressed + setCtrlShift(sender, false); + } + } + return; + } +} + +function handleCtrlShiftFocus(sender: Electron.WebContents, focused: boolean) { + if (!focused) { + setCtrlShift(sender, false); + } +} + function runWaveSrv(): Promise { let pResolve: (value: boolean) => void; let pReject: (reason?: any) => void; @@ -371,6 +411,9 @@ function createBrowserWindow(clientId: string, waveWindow: WaveWindow, fullConfi }); }); win.webContents.on("before-input-event", (e, input) => { + const waveEvent = keyutil.adaptFromElectronKeyEvent(input); + // console.log("WIN bie", waveEvent.type, waveEvent.code); + handleCtrlShiftState(win.webContents, waveEvent); if (win.isFocused()) { wasActive = true; } @@ -392,6 +435,9 @@ function createBrowserWindow(clientId: string, waveWindow: WaveWindow, fullConfi console.log("focus", waveWindow.oid); services.ClientService.FocusWindow(waveWindow.oid); }); + win.on("blur", () => { + handleCtrlShiftFocus(win.webContents, false); + }); win.on("enter-full-screen", async () => { win.webContents.send("fullscreen-change", true); }); @@ -553,6 +599,51 @@ electron.ipcMain.on("get-env", (event, varName) => { event.returnValue = process.env[varName] ?? null; }); +const hasBeforeInputRegisteredMap = new Map(); + +electron.ipcMain.on("webview-focus", (event: Electron.IpcMainEvent, focusedId: number) => { + webviewFocusId = focusedId; + console.log("webview-focus", focusedId); + if (focusedId == null) { + return; + } + const parentWc = event.sender; + const webviewWc = electron.webContents.fromId(focusedId); + if (webviewWc == null) { + webviewFocusId = null; + return; + } + if (!hasBeforeInputRegisteredMap.get(focusedId)) { + hasBeforeInputRegisteredMap.set(focusedId, true); + webviewWc.on("before-input-event", (e, input) => { + let waveEvent = keyutil.adaptFromElectronKeyEvent(input); + // console.log(`WEB ${focusedId}`, waveEvent.type, waveEvent.code); + handleCtrlShiftState(parentWc, waveEvent); + if (webviewFocusId != focusedId) { + return; + } + if (input.type != "keyDown") { + return; + } + for (let keyDesc of webviewKeys) { + if (keyutil.checkKeyPressed(waveEvent, keyDesc)) { + e.preventDefault(); + parentWc.send("reinject-key", waveEvent); + console.log("webview reinject-key", keyDesc); + return; + } + } + }); + webviewWc.on("destroyed", () => { + hasBeforeInputRegisteredMap.delete(focusedId); + }); + } +}); + +electron.ipcMain.on("register-global-webview-keys", (event, keys: string[]) => { + webviewKeys = keys ?? []; +}); + if (unamePlatform !== "darwin") { const fac = new FastAverageColor(); diff --git a/emain/preload.ts b/emain/preload.ts index e86d52a2..6b5be313 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -28,6 +28,11 @@ contextBridge.exposeInMainWorld("api", { installAppUpdate: () => ipcRenderer.send("install-app-update"), onMenuItemAbout: (callback) => ipcRenderer.on("menu-item-about", callback), updateWindowControlsOverlay: (rect) => ipcRenderer.send("update-window-controls-overlay", rect), + onReinjectKey: (callback) => ipcRenderer.on("reinject-key", (_event, waveEvent) => callback(waveEvent)), + setWebviewFocus: (focused: number) => ipcRenderer.send("webview-focus", focused), + registerGlobalWebviewKeys: (keys) => ipcRenderer.send("register-global-webview-keys", keys), + onControlShiftStateUpdate: (callback) => + ipcRenderer.on("control-shift-state-update", (_event, state) => callback(state)), }); // Custom event for "new-window" diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx index 846e862c..135c91da 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -1,18 +1,20 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { appHandleKeyDown, appHandleKeyUp } from "@/app/appkey"; import { useWaveObjectValue } from "@/app/store/wos"; import { Workspace } from "@/app/workspace/workspace"; import { ContextMenuModel } from "@/store/contextmenu"; import { PLATFORM, WOS, atoms, getApi, globalStore, useSettingsPrefixAtom } from "@/store/global"; +import { appHandleKeyDown } from "@/store/keymodel"; import { getWebServerEndpoint } from "@/util/endpoints"; +import { getElemAsStr } from "@/util/focusutil"; import * as keyutil from "@/util/keyutil"; import * as util from "@/util/util"; import useResizeObserver from "@react-hook/resize-observer"; import clsx from "clsx"; import Color from "color"; import * as csstree from "css-tree"; +import debug from "debug"; import * as jotai from "jotai"; import "overlayscrollbars/overlayscrollbars.css"; import * as React from "react"; @@ -22,6 +24,9 @@ import { debounce } from "throttle-debounce"; import "./app.less"; import { CenteredDiv } from "./element/quickelems"; +const dlog = debug("wave:app"); +const focusLog = debug("wave:focus"); + const App = () => { let Provider = jotai.Provider; return ( @@ -104,6 +109,40 @@ function AppSettingsUpdater() { return null; } +function appFocusIn(e: FocusEvent) { + focusLog("focusin", getElemAsStr(e.target), "<=", getElemAsStr(e.relatedTarget)); +} + +function appFocusOut(e: FocusEvent) { + focusLog("focusout", getElemAsStr(e.target), "=>", getElemAsStr(e.relatedTarget)); +} + +function appSelectionChange(e: Event) { + const selection = document.getSelection(); + focusLog("selectionchange", getElemAsStr(selection.anchorNode)); +} + +function AppFocusHandler() { + React.useEffect(() => { + document.addEventListener("focusin", appFocusIn); + document.addEventListener("focusout", appFocusOut); + document.addEventListener("selectionchange", appSelectionChange); + const ivId = setInterval(() => { + const activeElement = document.activeElement; + if (activeElement instanceof HTMLElement) { + focusLog("activeElement", getElemAsStr(activeElement)); + } + }, 2000); + return () => { + document.removeEventListener("focusin", appFocusIn); + document.removeEventListener("focusout", appFocusOut); + document.removeEventListener("selectionchange", appSelectionChange); + clearInterval(ivId); + }; + }); + return null; +} + function encodeFileURL(file: string) { const webEndpoint = getWebServerEndpoint(); return webEndpoint + `/wave/stream-file?path=${encodeURIComponent(file)}&no404=1`; @@ -202,12 +241,9 @@ const AppKeyHandlers = () => { React.useEffect(() => { const staticKeyDownHandler = keyutil.keydownWrapper(appHandleKeyDown); document.addEventListener("keydown", staticKeyDownHandler); - const savedKeyUpHandler = appHandleKeyUp; - document.addEventListener("keyup", savedKeyUpHandler); return () => { document.removeEventListener("keydown", staticKeyDownHandler); - document.removeEventListener("keyup", savedKeyUpHandler); }; }, []); return null; @@ -247,6 +283,7 @@ const AppInner = () => { > + diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 68c64396..ae63f1f0 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -27,7 +27,7 @@ type FullBlockProps = { viewModel: ViewModel; }; -function makeViewModel(blockId: string, blockView: string): ViewModel { +function makeViewModel(blockId: string, blockView: string, nodeModel: NodeModel): ViewModel { if (blockView === "term") { return makeTerminalModel(blockId); } @@ -35,7 +35,7 @@ function makeViewModel(blockId: string, blockView: string): ViewModel { return makePreviewModel(blockId); } if (blockView === "web") { - return makeWebViewModel(blockId); + return makeWebViewModel(blockId, nodeModel); } if (blockView === "waveai") { return makeWaveAiViewModel(blockId); @@ -250,7 +250,7 @@ const Block = React.memo((props: BlockProps) => { const [blockData, loading] = WOS.useWaveObjectValue(WOS.makeORef("block", props.nodeModel.blockId)); let viewModel = getViewModel(props.nodeModel.blockId); if (viewModel == null || viewModel.viewType != blockData?.meta?.view) { - viewModel = makeViewModel(props.nodeModel.blockId, blockData?.meta?.view); + viewModel = makeViewModel(props.nodeModel.blockId, blockData?.meta?.view, props.nodeModel); registerViewModel(props.nodeModel.blockId, viewModel); } React.useEffect(() => { diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index 2b3f12f0..8c2af6ee 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -315,6 +315,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => { "block-preview": preview, "block-no-highlight": numBlocksInTab === 1, })} + data-blockid={nodeModel.blockId} onClick={blockModel?.onClick} onFocusCapture={blockModel?.onFocusCapture} ref={blockModel?.blockRef} diff --git a/frontend/app/modals/modalsrenderer.tsx b/frontend/app/modals/modalsrenderer.tsx index 720b4080..20a6ee1e 100644 --- a/frontend/app/modals/modalsrenderer.tsx +++ b/frontend/app/modals/modalsrenderer.tsx @@ -1,9 +1,10 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { atoms } from "@/store/global"; +import { atoms, globalStore } from "@/store/global"; import { modalsModel } from "@/store/modalmodel"; import * as jotai from "jotai"; +import { useEffect } from "react"; import { getModalComponent } from "./modalregistry"; import { TosModal } from "./tos"; @@ -20,6 +21,9 @@ const ModalsRenderer = () => { if (!clientData.tosagreed) { rtn.push(); } + useEffect(() => { + globalStore.set(atoms.modalOpen, rtn.length > 0); + }); return <>{rtn}; }; diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index fbb7fb4f..33eee30c 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -126,6 +126,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { } const reducedMotionPreferenceAtom = jotai.atom((get) => get(settingsAtom)?.["window:reducedmotion"]); const typeAheadModalAtom = jotai.atom({}); + const modalOpen = jotai.atom(false); atoms = { // initialized in wave.ts (will not be null inside of application) windowId: windowIdAtom, @@ -143,6 +144,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { updaterStatusAtom, reducedMotionPreferenceAtom, typeAheadModalAtom, + modalOpen, }; } diff --git a/frontend/app/appkey.ts b/frontend/app/store/keymodel.ts similarity index 54% rename from frontend/app/appkey.ts rename to frontend/app/store/keymodel.ts index afd92294..b536bc63 100644 --- a/frontend/app/appkey.ts +++ b/frontend/app/store/keymodel.ts @@ -1,7 +1,8 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { atoms, createBlock, globalStore, WOS } from "@/app/store/global"; +import { atoms, createBlock, getApi, getViewModel, globalStore, WOS } from "@/app/store/global"; +import * as services from "@/app/store/services"; import { deleteLayoutModelForTab, getLayoutModelForActiveTab, @@ -9,11 +10,15 @@ import { getLayoutModelForTabById, NavigateDirection, } from "@/layout/index"; -import * as services from "@/store/services"; import * as keyutil from "@/util/keyutil"; import * as jotai from "jotai"; const simpleControlShiftAtom = jotai.atom(false); +const globalKeyMap = new Map boolean>(); + +function getSimpleControlShiftAtom() { + return simpleControlShiftAtom; +} function setControlShift() { globalStore.set(simpleControlShiftAtom, true); @@ -30,6 +35,22 @@ function unsetControlShift() { globalStore.set(atoms.controlShiftDelayAtom, false); } +function shouldDispatchToBlock(): boolean { + if (globalStore.get(atoms.modalOpen)) { + return false; + } + const activeElem = document.activeElement; + if (activeElem != null && activeElem instanceof HTMLElement) { + if (activeElem.tagName == "INPUT" || activeElem.tagName == "TEXTAREA") { + return false; + } + if (activeElem.contentEditable == "true") { + return false; + } + } + return true; +} + function genericClose(tabId: string) { const tabORef = WOS.makeORef("tab", tabId); const tabAtom = WOS.getWaveObjectAtom(tabORef); @@ -88,18 +109,6 @@ function switchTab(offset: number) { services.ObjectService.SetActiveTab(newActiveTabId); } -function appHandleKeyUp(event: KeyboardEvent) { - const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event); - if (waveEvent.key === "Control" || waveEvent.key === "Shift") { - unsetControlShift(); - } - if (waveEvent.key == "Meta") { - if (waveEvent.control && waveEvent.shift) { - setControlShift(); - } - } -} - async function handleCmdN() { const termBlockDef: BlockDef = { meta: { @@ -125,74 +134,130 @@ async function handleCmdN() { } function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean { - if (waveEvent.key === "Control" || waveEvent.key === "Shift" || waveEvent.key === "Meta") { - if (waveEvent.control && waveEvent.shift && !waveEvent.meta) { - // Set the control and shift without the Meta key + return handleGlobalWaveKeyboardEvents(waveEvent); +} + +function registerControlShiftStateUpdateHandler() { + getApi().onControlShiftStateUpdate((state: boolean) => { + if (state) { setControlShift(); } else { - // Unset if Meta is pressed unsetControlShift(); } - return false; - } - const tabId = globalStore.get(atoms.activeTabId); + }); +} - // global key handler for now (refactor later) - if (keyutil.checkKeyPressed(waveEvent, "Cmd:]") || keyutil.checkKeyPressed(waveEvent, "Shift:Cmd:]")) { +function registerElectronReinjectKeyHandler() { + getApi().onReinjectKey((event: WaveKeyboardEvent) => { + console.log("reinject key event", event); + const handled = handleGlobalWaveKeyboardEvents(event); + if (handled) { + return; + } + const layoutModel = getLayoutModelForActiveTab(); + const focusedNode = globalStore.get(layoutModel.focusedNode); + const blockId = focusedNode?.data?.blockId; + if (blockId != null && shouldDispatchToBlock()) { + const viewModel = getViewModel(blockId); + viewModel?.keyDownHandler?.(event); + } + }); +} + +function registerGlobalKeys() { + globalKeyMap.set("Cmd:]", () => { switchTab(1); return true; - } - if (keyutil.checkKeyPressed(waveEvent, "Cmd:[") || keyutil.checkKeyPressed(waveEvent, "Shift:Cmd:[")) { + }); + globalKeyMap.set("Shift:Cmd:]", () => { + switchTab(1); + return true; + }); + globalKeyMap.set("Cmd:[", () => { switchTab(-1); return true; - } - if (keyutil.checkKeyPressed(waveEvent, "Cmd:n")) { + }); + globalKeyMap.set("Shift:Cmd:[", () => { + switchTab(-1); + return true; + }); + globalKeyMap.set("Cmd:n", () => { handleCmdN(); return true; - } - if (keyutil.checkKeyPressed(waveEvent, "Cmd:t")) { + }); + globalKeyMap.set("Cmd:i", () => { + // TODO + return true; + }); + globalKeyMap.set("Cmd:t", () => { const workspace = globalStore.get(atoms.workspace); const newTabName = `T${workspace.tabids.length + 1}`; services.ObjectService.AddTabToWorkspace(newTabName, true); return true; - } - for (let idx = 1; idx <= 9; idx++) { - if (keyutil.checkKeyPressed(waveEvent, `Cmd:${idx}`)) { - switchTabAbs(idx); - return true; - } - } - for (let idx = 1; idx <= 9; idx++) { - if ( - keyutil.checkKeyPressed(waveEvent, `Ctrl:Shift:c{Digit${idx}}`) || - keyutil.checkKeyPressed(waveEvent, `Ctrl:Shift:c{Numpad${idx}}`) - ) { - switchBlockByBlockNum(idx); - return true; - } - } - if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:ArrowUp")) { - switchBlockInDirection(tabId, NavigateDirection.Up); - return true; - } - if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:ArrowDown")) { - switchBlockInDirection(tabId, NavigateDirection.Down); - return true; - } - if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:ArrowLeft")) { - switchBlockInDirection(tabId, NavigateDirection.Left); - return true; - } - if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:ArrowRight")) { - switchBlockInDirection(tabId, NavigateDirection.Right); - return true; - } - if (keyutil.checkKeyPressed(waveEvent, "Cmd:w")) { - // close block, if no more blocks, close tab + }); + globalKeyMap.set("Cmd:w", () => { + const tabId = globalStore.get(atoms.activeTabId); genericClose(tabId); return true; + }); + globalKeyMap.set("Ctrl:Shift:ArrowUp", () => { + const tabId = globalStore.get(atoms.activeTabId); + switchBlockInDirection(tabId, NavigateDirection.Up); + return true; + }); + globalKeyMap.set("Ctrl:Shift:ArrowDown", () => { + const tabId = globalStore.get(atoms.activeTabId); + switchBlockInDirection(tabId, NavigateDirection.Down); + return true; + }); + globalKeyMap.set("Ctrl:Shift:ArrowLeft", () => { + const tabId = globalStore.get(atoms.activeTabId); + switchBlockInDirection(tabId, NavigateDirection.Left); + return true; + }); + globalKeyMap.set("Ctrl:Shift:ArrowRight", () => { + const tabId = globalStore.get(atoms.activeTabId); + switchBlockInDirection(tabId, NavigateDirection.Right); + return true; + }); + for (let idx = 1; idx <= 9; idx++) { + globalKeyMap.set(`Cmd:${idx}`, () => { + switchTabAbs(idx); + return true; + }); + globalKeyMap.set(`Ctrl:Shift:c{Digit${idx}}`, () => { + switchBlockByBlockNum(idx); + return true; + }); + globalKeyMap.set(`Ctrl:Shift:c{Numpad${idx}}`, () => { + switchBlockByBlockNum(idx); + return true; + }); } - return false; + const allKeys = Array.from(globalKeyMap.keys()); + // special case keys, handled by web view + allKeys.push("Cmd:l", "Cmd:r", "Cmd:ArrowRight", "Cmd:ArrowLeft"); + getApi().registerGlobalWebviewKeys(allKeys); } -export { appHandleKeyDown, appHandleKeyUp }; +// these keyboard events happen *anywhere*, even if you have focus in an input or somewhere else. +function handleGlobalWaveKeyboardEvents(waveEvent: WaveKeyboardEvent): boolean { + for (const key of globalKeyMap.keys()) { + if (keyutil.checkKeyPressed(waveEvent, key)) { + const handler = globalKeyMap.get(key); + if (handler == null) { + return false; + } + return handler(waveEvent); + } + } +} + +export { + appHandleKeyDown, + getSimpleControlShiftAtom, + registerControlShiftStateUpdateHandler, + registerElectronReinjectKeyHandler, + registerGlobalKeys, + unsetControlShift, +}; diff --git a/frontend/app/store/wshserver.ts b/frontend/app/store/wshserver.ts index d98cc441..f193ccbc 100644 --- a/frontend/app/store/wshserver.ts +++ b/frontend/app/store/wshserver.ts @@ -123,7 +123,7 @@ class WshServerType { } // command "setconfig" [call] - SetConfigCommand(data: MetaMapType, opts?: RpcOpts): Promise { + SetConfigCommand(data: MetaType, opts?: RpcOpts): Promise { return WOS.wshServerRpcHelper_call("setconfig", data, opts); } diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index 69b36e02..f07e1307 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -1,9 +1,12 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { WOS, globalStore, openLink } from "@/store/global"; +import { getApi, openLink } from "@/app/store/global"; +import { getSimpleControlShiftAtom } from "@/app/store/keymodel"; +import { NodeModel } from "@/layout/index"; +import { WOS, globalStore } from "@/store/global"; import * as services from "@/store/services"; -import { checkKeyPressed } from "@/util/keyutil"; +import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { fireAndForget } from "@/util/util"; import clsx from "clsx"; import { WebviewTag } from "electron"; @@ -27,8 +30,10 @@ export class WebViewModel implements ViewModel { refreshIcon: jotai.PrimitiveAtom; webviewRef: React.RefObject; urlInputRef: React.RefObject; + nodeModel: NodeModel; - constructor(blockId: string) { + constructor(blockId: string, nodeModel: NodeModel) { + this.nodeModel = nodeModel; this.viewType = "web"; this.blockId = blockId; this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); @@ -131,15 +136,19 @@ export class WebViewModel implements ViewModel { } } - handleBack(e: React.MouseEvent) { - e.preventDefault(); - e.stopPropagation(); + handleBack(e?: React.MouseEvent) { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } this.webviewRef.current?.goBack(); } - handleForward(e: React.MouseEvent) { - e.preventDefault(); - e.stopPropagation(); + handleForward(e?: React.MouseEvent) { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } this.webviewRef.current?.goForward(); } @@ -165,10 +174,15 @@ export class WebViewModel implements ViewModel { } handleKeyDown(event: React.KeyboardEvent) { - if (event.key === "Enter") { + const waveEvent = adaptFromReactOrNativeKeyEvent(event); + if (checkKeyPressed(waveEvent, "Enter")) { const url = globalStore.get(this.url); this.loadUrl(url); this.urlInputRef.current?.blur(); + return; + } + if (checkKeyPressed(waveEvent, "Escape")) { + this.webviewRef.current?.focus(); } } @@ -268,10 +282,23 @@ export class WebViewModel implements ViewModel { } giveFocus(): boolean { - if (this.urlInputRef.current) { - this.urlInputRef.current.focus({ preventScroll: true }); - return true; + const ctrlShiftState = globalStore.get(getSimpleControlShiftAtom()); + if (ctrlShiftState) { + // this is really weird, we don't get keyup events from webview + const unsubFn = globalStore.sub(getSimpleControlShiftAtom(), () => { + const state = globalStore.get(getSimpleControlShiftAtom()); + if (!state) { + unsubFn(); + const isStillFocused = globalStore.get(this.nodeModel.isFocused); + if (isStillFocused) { + this.webviewRef.current?.focus(); + } + } + }); + return false; } + this.webviewRef.current?.focus(); + return true; } keyDownHandler(e: WaveKeyboardEvent): boolean { @@ -284,12 +311,20 @@ export class WebViewModel implements ViewModel { this.webviewRef?.current?.reload(); return true; } + if (checkKeyPressed(e, "Cmd:ArrowLeft")) { + this.handleBack(null); + return true; + } + if (checkKeyPressed(e, "Cmd:ArrowRight")) { + this.handleForward(null); + return true; + } return false; } } -function makeWebViewModel(blockId: string): WebViewModel { - const webviewModel = new WebViewModel(blockId); +function makeWebViewModel(blockId: string, nodeModel: NodeModel): WebViewModel { + const webviewModel = new WebViewModel(blockId, nodeModel); return webviewModel; } @@ -342,6 +377,13 @@ const WebView = memo(({ model }: WebViewProps) => { console.error(`Failed to load ${e.validatedURL}: ${e.errorDescription}`); } }; + const webviewFocus = () => { + getApi().setWebviewFocus(webview.getWebContentsId()); + model.nodeModel.focusNode(); + }; + const webviewBlur = () => { + getApi().setWebviewFocus(null); + }; webview.addEventListener("did-navigate-in-page", navigateListener); webview.addEventListener("did-navigate", navigateListener); @@ -350,6 +392,9 @@ const WebView = memo(({ model }: WebViewProps) => { webview.addEventListener("new-window", newWindowHandler); webview.addEventListener("did-fail-load", failLoadHandler); + webview.addEventListener("focus", webviewFocus); + webview.addEventListener("blur", webviewBlur); + // Clean up event listeners on component unmount return () => { webview.removeEventListener("did-navigate", navigateListener); @@ -358,6 +403,8 @@ const WebView = memo(({ model }: WebViewProps) => { webview.removeEventListener("did-fail-load", failLoadHandler); webview.removeEventListener("did-start-loading", startLoadingHandler); webview.removeEventListener("did-stop-loading", stopLoadingHandler); + webview.addEventListener("focus", webviewFocus); + webview.addEventListener("blur", webviewBlur); }; } }, []); diff --git a/frontend/app/workspace/workspace.less b/frontend/app/workspace/workspace.less index d8c22afb..19294d6d 100644 --- a/frontend/app/workspace/workspace.less +++ b/frontend/app/workspace/workspace.less @@ -23,6 +23,7 @@ padding-top: 4px; padding-bottom: 4px; margin-left: -4px; + user-select: none; .widget { display: flex; diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 6c6b5195..d3a92e77 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -21,6 +21,7 @@ declare global { reducedMotionPreferenceAtom: jotai.Atom; updaterStatusAtom: jotai.PrimitiveAtom; typeAheadModalAtom: jotai.PrimitiveAtom; + modalOpen: jotai.PrimitiveAtom; }; type WritableWaveObjectAtom = jotai.WritableAtom; @@ -65,6 +66,10 @@ declare global { installAppUpdate: () => void; onMenuItemAbout: (callback: () => void) => void; updateWindowControlsOverlay: (rect: Dimensions) => void; + onReinjectKey: (callback: (waveEvent: WaveKeyboardEvent) => void) => void; + setWebviewFocus: (focusedId: number) => void; // focusedId si the getWebContentsId of the webview + registerGlobalWebviewKeys: (keys: string[]) => void; + onControlShiftStateUpdate: (callback: (state: boolean) => void) => void; }; type ElectronContextMenuItem = { @@ -97,7 +102,7 @@ declare global { }; interface WaveKeyboardEvent { - type: string; + type: "keydown" | "keyup" | "keypress" | "unknown"; /** * Equivalent to KeyboardEvent.key. */ diff --git a/frontend/util/focusutil.ts b/frontend/util/focusutil.ts new file mode 100644 index 00000000..a5c9bb16 --- /dev/null +++ b/frontend/util/focusutil.ts @@ -0,0 +1,70 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0s + +import * as util from "./util"; + +export function findBlockId(element: HTMLElement): string | null { + let current: HTMLElement = element; + while (current) { + if (current.hasAttribute("data-blockid")) { + return current.getAttribute("data-blockid"); + } + current = current.parentElement; + } + return null; +} + +export function getElemAsStr(elem: EventTarget) { + if (elem == null) { + return "null"; + } + if (!(elem instanceof HTMLElement)) { + if (elem instanceof Text) { + elem = elem.parentElement; + } + if (!(elem instanceof HTMLElement)) { + return "unknown"; + } + } + const blockId = findBlockId(elem); + let rtn = elem.tagName.toLowerCase(); + if (!util.isBlank(elem.id)) { + rtn += "#" + elem.id; + } + if (!util.isBlank(elem.className)) { + rtn += "." + elem.className; + } + if (blockId != null) { + rtn += ` [${blockId.substring(0, 8)}]`; + } + return rtn; +} + +export function hasSelection() { + const sel = document.getSelection(); + return sel && sel.rangeCount > 0 && !sel.isCollapsed; +} + +export function focusedBlockId(): string { + const focused = document.activeElement; + if (focused instanceof HTMLElement) { + const blockId = findBlockId(focused); + if (blockId) { + return blockId; + } + } + const sel = document.getSelection(); + if (sel && sel.anchorNode) { + let anchor = sel.anchorNode; + if (anchor instanceof Text) { + anchor = anchor.parentElement; + } + if (anchor instanceof HTMLElement) { + const blockId = findBlockId(anchor); + if (blockId) { + return blockId; + } + } + } + return null; +} diff --git a/frontend/util/keyutil.ts b/frontend/util/keyutil.ts index df2af40b..c28b1d62 100644 --- a/frontend/util/keyutil.ts +++ b/frontend/util/keyutil.ts @@ -139,14 +139,24 @@ function adaptFromReactOrNativeKeyEvent(event: React.KeyboardEvent | KeyboardEve rtn.code = event.code; rtn.key = event.key; rtn.location = event.location; - rtn.type = event.type; + if (event.type == "keydown" || event.type == "keyup" || event.type == "keypress") { + rtn.type = event.type; + } else { + rtn.type = "unknown"; + } rtn.repeat = event.repeat; return rtn; } function adaptFromElectronKeyEvent(event: any): WaveKeyboardEvent { let rtn: WaveKeyboardEvent = {} as WaveKeyboardEvent; - rtn.type = event.type; + if (event.type == "keyUp") { + rtn.type = "keyup"; + } else if (event.type == "keyDown") { + rtn.type = "keydown"; + } else { + rtn.type = "unknown"; + } rtn.control = event.control; rtn.cmd = PLATFORM == PlatformMacOS ? event.meta : event.alt; rtn.option = PLATFORM == PlatformMacOS ? event.alt : event.meta; diff --git a/frontend/wave.ts b/frontend/wave.ts index 35c06ecd..8362ae5a 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -1,6 +1,11 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { + registerControlShiftStateUpdateHandler, + registerElectronReinjectKeyHandler, + registerGlobalKeys, +} from "@/app/store/keymodel"; import { WshServer } from "@/app/store/wshserver"; import { atoms, @@ -57,6 +62,9 @@ document.addEventListener("DOMContentLoaded", async () => { initWS(); await loadConnStatus(); subscribeToConnEvents(); + registerGlobalKeys(); + registerElectronReinjectKeyHandler(); + registerControlShiftStateUpdateHandler(); const fullConfig = await services.FileService.GetFullConfig(); console.log("fullconfig", fullConfig); globalStore.set(atoms.fullConfigAtom, fullConfig); diff --git a/pkg/tsgen/tsgen.go b/pkg/tsgen/tsgen.go index c9efc204..37fc7ba2 100644 --- a/pkg/tsgen/tsgen.go +++ b/pkg/tsgen/tsgen.go @@ -426,7 +426,8 @@ func GenerateWshServerMethod_ResponseStream(methodDecl *wshrpc.WshRpcMethodDecl, } genRespType := fmt.Sprintf("AsyncGenerator<%s, void, boolean>", respType) if methodDecl.CommandDataType != nil { - sb.WriteString(fmt.Sprintf(" %s(data: %s, opts?: RpcOpts): %s {\n", methodDecl.MethodName, methodDecl.CommandDataType.Name(), genRespType)) + cmdDataTsName, _ := TypeToTSType(methodDecl.CommandDataType, tsTypesMap) + sb.WriteString(fmt.Sprintf(" %s(data: %s, opts?: RpcOpts): %s {\n", methodDecl.MethodName, cmdDataTsName, genRespType)) } else { sb.WriteString(fmt.Sprintf(" %s(opts?: RpcOpts): %s {\n", methodDecl.MethodName, genRespType)) } @@ -448,7 +449,8 @@ func GenerateWshServerMethod_Call(methodDecl *wshrpc.WshRpcMethodDecl, tsTypesMa dataName = "data" } if methodDecl.CommandDataType != nil { - sb.WriteString(fmt.Sprintf(" %s(data: %s, opts?: RpcOpts): %s {\n", methodDecl.MethodName, methodDecl.CommandDataType.Name(), rtnType)) + cmdDataTsName, _ := TypeToTSType(methodDecl.CommandDataType, tsTypesMap) + sb.WriteString(fmt.Sprintf(" %s(data: %s, opts?: RpcOpts): %s {\n", methodDecl.MethodName, cmdDataTsName, rtnType)) } else { sb.WriteString(fmt.Sprintf(" %s(opts?: RpcOpts): %s {\n", methodDecl.MethodName, rtnType)) }