new focus system part 1 (#290)

This commit is contained in:
Mike Sawka 2024-08-29 16:06:15 -07:00 committed by GitHub
parent 637eaa4206
commit a104a6e446
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 442 additions and 94 deletions

View File

@ -16,6 +16,7 @@ import { initGlobal } from "../frontend/app/store/global";
import * as services from "../frontend/app/store/services"; import * as services from "../frontend/app/store/services";
import { WSServerEndpointVarName, WebServerEndpointVarName, getWebServerEndpoint } from "../frontend/util/endpoints"; import { WSServerEndpointVarName, WebServerEndpointVarName, getWebServerEndpoint } from "../frontend/util/endpoints";
import { fetch } from "../frontend/util/fetchutil"; import { fetch } from "../frontend/util/fetchutil";
import * as keyutil from "../frontend/util/keyutil";
import { fireAndForget } from "../frontend/util/util"; import { fireAndForget } from "../frontend/util/util";
import { AuthKey, AuthKeyEnv, configureAuthKeyRequestInjection } from "./authkey"; import { AuthKey, AuthKeyEnv, configureAuthKeyRequestInjection } from "./authkey";
import { getAppMenu } from "./menu"; import { getAppMenu } from "./menu";
@ -54,6 +55,9 @@ let globalIsRelaunching = false;
let wasActive = true; let wasActive = true;
let wasInFg = 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; let waveSrvProc: child_process.ChildProcessWithoutNullStreams | null = null;
const waveHome = getWaveHomeDir(); const waveHome = getWaveHomeDir();
@ -104,6 +108,42 @@ function getWindowForEvent(event: Electron.IpcMainEvent): Electron.BrowserWindow
return electron.BrowserWindow.fromId(windowId); 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<boolean> { function runWaveSrv(): Promise<boolean> {
let pResolve: (value: boolean) => void; let pResolve: (value: boolean) => void;
let pReject: (reason?: any) => 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) => { 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()) { if (win.isFocused()) {
wasActive = true; wasActive = true;
} }
@ -392,6 +435,9 @@ function createBrowserWindow(clientId: string, waveWindow: WaveWindow, fullConfi
console.log("focus", waveWindow.oid); console.log("focus", waveWindow.oid);
services.ClientService.FocusWindow(waveWindow.oid); services.ClientService.FocusWindow(waveWindow.oid);
}); });
win.on("blur", () => {
handleCtrlShiftFocus(win.webContents, false);
});
win.on("enter-full-screen", async () => { win.on("enter-full-screen", async () => {
win.webContents.send("fullscreen-change", true); win.webContents.send("fullscreen-change", true);
}); });
@ -553,6 +599,51 @@ electron.ipcMain.on("get-env", (event, varName) => {
event.returnValue = process.env[varName] ?? null; event.returnValue = process.env[varName] ?? null;
}); });
const hasBeforeInputRegisteredMap = new Map<number, boolean>();
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") { if (unamePlatform !== "darwin") {
const fac = new FastAverageColor(); const fac = new FastAverageColor();

View File

@ -28,6 +28,11 @@ contextBridge.exposeInMainWorld("api", {
installAppUpdate: () => ipcRenderer.send("install-app-update"), installAppUpdate: () => ipcRenderer.send("install-app-update"),
onMenuItemAbout: (callback) => ipcRenderer.on("menu-item-about", callback), onMenuItemAbout: (callback) => ipcRenderer.on("menu-item-about", callback),
updateWindowControlsOverlay: (rect) => ipcRenderer.send("update-window-controls-overlay", rect), 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" // Custom event for "new-window"

View File

@ -1,18 +1,20 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { appHandleKeyDown, appHandleKeyUp } from "@/app/appkey";
import { useWaveObjectValue } from "@/app/store/wos"; import { useWaveObjectValue } from "@/app/store/wos";
import { Workspace } from "@/app/workspace/workspace"; import { Workspace } from "@/app/workspace/workspace";
import { ContextMenuModel } from "@/store/contextmenu"; import { ContextMenuModel } from "@/store/contextmenu";
import { PLATFORM, WOS, atoms, getApi, globalStore, useSettingsPrefixAtom } from "@/store/global"; import { PLATFORM, WOS, atoms, getApi, globalStore, useSettingsPrefixAtom } from "@/store/global";
import { appHandleKeyDown } from "@/store/keymodel";
import { getWebServerEndpoint } from "@/util/endpoints"; import { getWebServerEndpoint } from "@/util/endpoints";
import { getElemAsStr } from "@/util/focusutil";
import * as keyutil from "@/util/keyutil"; import * as keyutil from "@/util/keyutil";
import * as util from "@/util/util"; import * as util from "@/util/util";
import useResizeObserver from "@react-hook/resize-observer"; import useResizeObserver from "@react-hook/resize-observer";
import clsx from "clsx"; import clsx from "clsx";
import Color from "color"; import Color from "color";
import * as csstree from "css-tree"; import * as csstree from "css-tree";
import debug from "debug";
import * as jotai from "jotai"; import * as jotai from "jotai";
import "overlayscrollbars/overlayscrollbars.css"; import "overlayscrollbars/overlayscrollbars.css";
import * as React from "react"; import * as React from "react";
@ -22,6 +24,9 @@ import { debounce } from "throttle-debounce";
import "./app.less"; import "./app.less";
import { CenteredDiv } from "./element/quickelems"; import { CenteredDiv } from "./element/quickelems";
const dlog = debug("wave:app");
const focusLog = debug("wave:focus");
const App = () => { const App = () => {
let Provider = jotai.Provider; let Provider = jotai.Provider;
return ( return (
@ -104,6 +109,40 @@ function AppSettingsUpdater() {
return null; 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) { function encodeFileURL(file: string) {
const webEndpoint = getWebServerEndpoint(); const webEndpoint = getWebServerEndpoint();
return webEndpoint + `/wave/stream-file?path=${encodeURIComponent(file)}&no404=1`; return webEndpoint + `/wave/stream-file?path=${encodeURIComponent(file)}&no404=1`;
@ -202,12 +241,9 @@ const AppKeyHandlers = () => {
React.useEffect(() => { React.useEffect(() => {
const staticKeyDownHandler = keyutil.keydownWrapper(appHandleKeyDown); const staticKeyDownHandler = keyutil.keydownWrapper(appHandleKeyDown);
document.addEventListener("keydown", staticKeyDownHandler); document.addEventListener("keydown", staticKeyDownHandler);
const savedKeyUpHandler = appHandleKeyUp;
document.addEventListener("keyup", savedKeyUpHandler);
return () => { return () => {
document.removeEventListener("keydown", staticKeyDownHandler); document.removeEventListener("keydown", staticKeyDownHandler);
document.removeEventListener("keyup", savedKeyUpHandler);
}; };
}, []); }, []);
return null; return null;
@ -247,6 +283,7 @@ const AppInner = () => {
> >
<AppBackground /> <AppBackground />
<AppKeyHandlers /> <AppKeyHandlers />
<AppFocusHandler />
<AppSettingsUpdater /> <AppSettingsUpdater />
<DndProvider backend={HTML5Backend}> <DndProvider backend={HTML5Backend}>
<Workspace /> <Workspace />

View File

@ -27,7 +27,7 @@ type FullBlockProps = {
viewModel: ViewModel; viewModel: ViewModel;
}; };
function makeViewModel(blockId: string, blockView: string): ViewModel { function makeViewModel(blockId: string, blockView: string, nodeModel: NodeModel): ViewModel {
if (blockView === "term") { if (blockView === "term") {
return makeTerminalModel(blockId); return makeTerminalModel(blockId);
} }
@ -35,7 +35,7 @@ function makeViewModel(blockId: string, blockView: string): ViewModel {
return makePreviewModel(blockId); return makePreviewModel(blockId);
} }
if (blockView === "web") { if (blockView === "web") {
return makeWebViewModel(blockId); return makeWebViewModel(blockId, nodeModel);
} }
if (blockView === "waveai") { if (blockView === "waveai") {
return makeWaveAiViewModel(blockId); return makeWaveAiViewModel(blockId);
@ -250,7 +250,7 @@ const Block = React.memo((props: BlockProps) => {
const [blockData, loading] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", props.nodeModel.blockId)); const [blockData, loading] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", props.nodeModel.blockId));
let viewModel = getViewModel(props.nodeModel.blockId); let viewModel = getViewModel(props.nodeModel.blockId);
if (viewModel == null || viewModel.viewType != blockData?.meta?.view) { 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); registerViewModel(props.nodeModel.blockId, viewModel);
} }
React.useEffect(() => { React.useEffect(() => {

View File

@ -315,6 +315,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => {
"block-preview": preview, "block-preview": preview,
"block-no-highlight": numBlocksInTab === 1, "block-no-highlight": numBlocksInTab === 1,
})} })}
data-blockid={nodeModel.blockId}
onClick={blockModel?.onClick} onClick={blockModel?.onClick}
onFocusCapture={blockModel?.onFocusCapture} onFocusCapture={blockModel?.onFocusCapture}
ref={blockModel?.blockRef} ref={blockModel?.blockRef}

View File

@ -1,9 +1,10 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { atoms } from "@/store/global"; import { atoms, globalStore } from "@/store/global";
import { modalsModel } from "@/store/modalmodel"; import { modalsModel } from "@/store/modalmodel";
import * as jotai from "jotai"; import * as jotai from "jotai";
import { useEffect } from "react";
import { getModalComponent } from "./modalregistry"; import { getModalComponent } from "./modalregistry";
import { TosModal } from "./tos"; import { TosModal } from "./tos";
@ -20,6 +21,9 @@ const ModalsRenderer = () => {
if (!clientData.tosagreed) { if (!clientData.tosagreed) {
rtn.push(<TosModal key={TosModal.displayName} />); rtn.push(<TosModal key={TosModal.displayName} />);
} }
useEffect(() => {
globalStore.set(atoms.modalOpen, rtn.length > 0);
});
return <>{rtn}</>; return <>{rtn}</>;
}; };

View File

@ -126,6 +126,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
} }
const reducedMotionPreferenceAtom = jotai.atom((get) => get(settingsAtom)?.["window:reducedmotion"]); const reducedMotionPreferenceAtom = jotai.atom((get) => get(settingsAtom)?.["window:reducedmotion"]);
const typeAheadModalAtom = jotai.atom({}); const typeAheadModalAtom = jotai.atom({});
const modalOpen = jotai.atom(false);
atoms = { atoms = {
// initialized in wave.ts (will not be null inside of application) // initialized in wave.ts (will not be null inside of application)
windowId: windowIdAtom, windowId: windowIdAtom,
@ -143,6 +144,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
updaterStatusAtom, updaterStatusAtom,
reducedMotionPreferenceAtom, reducedMotionPreferenceAtom,
typeAheadModalAtom, typeAheadModalAtom,
modalOpen,
}; };
} }

View File

@ -1,7 +1,8 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // 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 { import {
deleteLayoutModelForTab, deleteLayoutModelForTab,
getLayoutModelForActiveTab, getLayoutModelForActiveTab,
@ -9,11 +10,15 @@ import {
getLayoutModelForTabById, getLayoutModelForTabById,
NavigateDirection, NavigateDirection,
} from "@/layout/index"; } from "@/layout/index";
import * as services from "@/store/services";
import * as keyutil from "@/util/keyutil"; import * as keyutil from "@/util/keyutil";
import * as jotai from "jotai"; import * as jotai from "jotai";
const simpleControlShiftAtom = jotai.atom(false); const simpleControlShiftAtom = jotai.atom(false);
const globalKeyMap = new Map<string, (waveEvent: WaveKeyboardEvent) => boolean>();
function getSimpleControlShiftAtom() {
return simpleControlShiftAtom;
}
function setControlShift() { function setControlShift() {
globalStore.set(simpleControlShiftAtom, true); globalStore.set(simpleControlShiftAtom, true);
@ -30,6 +35,22 @@ function unsetControlShift() {
globalStore.set(atoms.controlShiftDelayAtom, false); 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) { function genericClose(tabId: string) {
const tabORef = WOS.makeORef("tab", tabId); const tabORef = WOS.makeORef("tab", tabId);
const tabAtom = WOS.getWaveObjectAtom<Tab>(tabORef); const tabAtom = WOS.getWaveObjectAtom<Tab>(tabORef);
@ -88,18 +109,6 @@ function switchTab(offset: number) {
services.ObjectService.SetActiveTab(newActiveTabId); 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() { async function handleCmdN() {
const termBlockDef: BlockDef = { const termBlockDef: BlockDef = {
meta: { meta: {
@ -125,74 +134,130 @@ async function handleCmdN() {
} }
function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean { function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean {
if (waveEvent.key === "Control" || waveEvent.key === "Shift" || waveEvent.key === "Meta") { return handleGlobalWaveKeyboardEvents(waveEvent);
if (waveEvent.control && waveEvent.shift && !waveEvent.meta) { }
// Set the control and shift without the Meta key
function registerControlShiftStateUpdateHandler() {
getApi().onControlShiftStateUpdate((state: boolean) => {
if (state) {
setControlShift(); setControlShift();
} else { } else {
// Unset if Meta is pressed
unsetControlShift(); unsetControlShift();
} }
return false; });
} }
const tabId = globalStore.get(atoms.activeTabId);
// global key handler for now (refactor later) function registerElectronReinjectKeyHandler() {
if (keyutil.checkKeyPressed(waveEvent, "Cmd:]") || keyutil.checkKeyPressed(waveEvent, "Shift:Cmd:]")) { 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); switchTab(1);
return true; 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); switchTab(-1);
return true; return true;
} });
if (keyutil.checkKeyPressed(waveEvent, "Cmd:n")) { globalKeyMap.set("Shift:Cmd:[", () => {
switchTab(-1);
return true;
});
globalKeyMap.set("Cmd:n", () => {
handleCmdN(); handleCmdN();
return true; 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 workspace = globalStore.get(atoms.workspace);
const newTabName = `T${workspace.tabids.length + 1}`; const newTabName = `T${workspace.tabids.length + 1}`;
services.ObjectService.AddTabToWorkspace(newTabName, true); services.ObjectService.AddTabToWorkspace(newTabName, true);
return true; return true;
} });
for (let idx = 1; idx <= 9; idx++) { globalKeyMap.set("Cmd:w", () => {
if (keyutil.checkKeyPressed(waveEvent, `Cmd:${idx}`)) { const tabId = globalStore.get(atoms.activeTabId);
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
genericClose(tabId); genericClose(tabId);
return true; 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,
};

View File

@ -123,7 +123,7 @@ class WshServerType {
} }
// command "setconfig" [call] // command "setconfig" [call]
SetConfigCommand(data: MetaMapType, opts?: RpcOpts): Promise<void> { SetConfigCommand(data: MetaType, opts?: RpcOpts): Promise<void> {
return WOS.wshServerRpcHelper_call("setconfig", data, opts); return WOS.wshServerRpcHelper_call("setconfig", data, opts);
} }

View File

@ -1,9 +1,12 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // 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 * as services from "@/store/services";
import { checkKeyPressed } from "@/util/keyutil"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
import { fireAndForget } from "@/util/util"; import { fireAndForget } from "@/util/util";
import clsx from "clsx"; import clsx from "clsx";
import { WebviewTag } from "electron"; import { WebviewTag } from "electron";
@ -27,8 +30,10 @@ export class WebViewModel implements ViewModel {
refreshIcon: jotai.PrimitiveAtom<string>; refreshIcon: jotai.PrimitiveAtom<string>;
webviewRef: React.RefObject<WebviewTag>; webviewRef: React.RefObject<WebviewTag>;
urlInputRef: React.RefObject<HTMLInputElement>; urlInputRef: React.RefObject<HTMLInputElement>;
nodeModel: NodeModel;
constructor(blockId: string) { constructor(blockId: string, nodeModel: NodeModel) {
this.nodeModel = nodeModel;
this.viewType = "web"; this.viewType = "web";
this.blockId = blockId; this.blockId = blockId;
this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`); this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`);
@ -131,15 +136,19 @@ export class WebViewModel implements ViewModel {
} }
} }
handleBack(e: React.MouseEvent<HTMLDivElement, MouseEvent>) { handleBack(e?: React.MouseEvent<HTMLDivElement, MouseEvent>) {
e.preventDefault(); if (e) {
e.stopPropagation(); e.preventDefault();
e.stopPropagation();
}
this.webviewRef.current?.goBack(); this.webviewRef.current?.goBack();
} }
handleForward(e: React.MouseEvent<HTMLDivElement, MouseEvent>) { handleForward(e?: React.MouseEvent<HTMLDivElement, MouseEvent>) {
e.preventDefault(); if (e) {
e.stopPropagation(); e.preventDefault();
e.stopPropagation();
}
this.webviewRef.current?.goForward(); this.webviewRef.current?.goForward();
} }
@ -165,10 +174,15 @@ export class WebViewModel implements ViewModel {
} }
handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) { handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === "Enter") { const waveEvent = adaptFromReactOrNativeKeyEvent(event);
if (checkKeyPressed(waveEvent, "Enter")) {
const url = globalStore.get(this.url); const url = globalStore.get(this.url);
this.loadUrl(url); this.loadUrl(url);
this.urlInputRef.current?.blur(); this.urlInputRef.current?.blur();
return;
}
if (checkKeyPressed(waveEvent, "Escape")) {
this.webviewRef.current?.focus();
} }
} }
@ -268,10 +282,23 @@ export class WebViewModel implements ViewModel {
} }
giveFocus(): boolean { giveFocus(): boolean {
if (this.urlInputRef.current) { const ctrlShiftState = globalStore.get(getSimpleControlShiftAtom());
this.urlInputRef.current.focus({ preventScroll: true }); if (ctrlShiftState) {
return true; // 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 { keyDownHandler(e: WaveKeyboardEvent): boolean {
@ -284,12 +311,20 @@ export class WebViewModel implements ViewModel {
this.webviewRef?.current?.reload(); this.webviewRef?.current?.reload();
return true; return true;
} }
if (checkKeyPressed(e, "Cmd:ArrowLeft")) {
this.handleBack(null);
return true;
}
if (checkKeyPressed(e, "Cmd:ArrowRight")) {
this.handleForward(null);
return true;
}
return false; return false;
} }
} }
function makeWebViewModel(blockId: string): WebViewModel { function makeWebViewModel(blockId: string, nodeModel: NodeModel): WebViewModel {
const webviewModel = new WebViewModel(blockId); const webviewModel = new WebViewModel(blockId, nodeModel);
return webviewModel; return webviewModel;
} }
@ -342,6 +377,13 @@ const WebView = memo(({ model }: WebViewProps) => {
console.error(`Failed to load ${e.validatedURL}: ${e.errorDescription}`); 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-in-page", navigateListener);
webview.addEventListener("did-navigate", navigateListener); webview.addEventListener("did-navigate", navigateListener);
@ -350,6 +392,9 @@ const WebView = memo(({ model }: WebViewProps) => {
webview.addEventListener("new-window", newWindowHandler); webview.addEventListener("new-window", newWindowHandler);
webview.addEventListener("did-fail-load", failLoadHandler); webview.addEventListener("did-fail-load", failLoadHandler);
webview.addEventListener("focus", webviewFocus);
webview.addEventListener("blur", webviewBlur);
// Clean up event listeners on component unmount // Clean up event listeners on component unmount
return () => { return () => {
webview.removeEventListener("did-navigate", navigateListener); webview.removeEventListener("did-navigate", navigateListener);
@ -358,6 +403,8 @@ const WebView = memo(({ model }: WebViewProps) => {
webview.removeEventListener("did-fail-load", failLoadHandler); webview.removeEventListener("did-fail-load", failLoadHandler);
webview.removeEventListener("did-start-loading", startLoadingHandler); webview.removeEventListener("did-start-loading", startLoadingHandler);
webview.removeEventListener("did-stop-loading", stopLoadingHandler); webview.removeEventListener("did-stop-loading", stopLoadingHandler);
webview.addEventListener("focus", webviewFocus);
webview.addEventListener("blur", webviewBlur);
}; };
} }
}, []); }, []);

View File

@ -23,6 +23,7 @@
padding-top: 4px; padding-top: 4px;
padding-bottom: 4px; padding-bottom: 4px;
margin-left: -4px; margin-left: -4px;
user-select: none;
.widget { .widget {
display: flex; display: flex;

View File

@ -21,6 +21,7 @@ declare global {
reducedMotionPreferenceAtom: jotai.Atom<boolean>; reducedMotionPreferenceAtom: jotai.Atom<boolean>;
updaterStatusAtom: jotai.PrimitiveAtom<UpdaterStatus>; updaterStatusAtom: jotai.PrimitiveAtom<UpdaterStatus>;
typeAheadModalAtom: jotai.PrimitiveAtom<TypeAheadModalType>; typeAheadModalAtom: jotai.PrimitiveAtom<TypeAheadModalType>;
modalOpen: jotai.PrimitiveAtom<boolean>;
}; };
type WritableWaveObjectAtom<T extends WaveObj> = jotai.WritableAtom<T, [value: T], void>; type WritableWaveObjectAtom<T extends WaveObj> = jotai.WritableAtom<T, [value: T], void>;
@ -65,6 +66,10 @@ declare global {
installAppUpdate: () => void; installAppUpdate: () => void;
onMenuItemAbout: (callback: () => void) => void; onMenuItemAbout: (callback: () => void) => void;
updateWindowControlsOverlay: (rect: Dimensions) => 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 = { type ElectronContextMenuItem = {
@ -97,7 +102,7 @@ declare global {
}; };
interface WaveKeyboardEvent { interface WaveKeyboardEvent {
type: string; type: "keydown" | "keyup" | "keypress" | "unknown";
/** /**
* Equivalent to KeyboardEvent.key. * Equivalent to KeyboardEvent.key.
*/ */

View File

@ -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;
}

View File

@ -139,14 +139,24 @@ function adaptFromReactOrNativeKeyEvent(event: React.KeyboardEvent | KeyboardEve
rtn.code = event.code; rtn.code = event.code;
rtn.key = event.key; rtn.key = event.key;
rtn.location = event.location; 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; rtn.repeat = event.repeat;
return rtn; return rtn;
} }
function adaptFromElectronKeyEvent(event: any): WaveKeyboardEvent { function adaptFromElectronKeyEvent(event: any): WaveKeyboardEvent {
let rtn: WaveKeyboardEvent = {} as 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.control = event.control;
rtn.cmd = PLATFORM == PlatformMacOS ? event.meta : event.alt; rtn.cmd = PLATFORM == PlatformMacOS ? event.meta : event.alt;
rtn.option = PLATFORM == PlatformMacOS ? event.alt : event.meta; rtn.option = PLATFORM == PlatformMacOS ? event.alt : event.meta;

View File

@ -1,6 +1,11 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import {
registerControlShiftStateUpdateHandler,
registerElectronReinjectKeyHandler,
registerGlobalKeys,
} from "@/app/store/keymodel";
import { WshServer } from "@/app/store/wshserver"; import { WshServer } from "@/app/store/wshserver";
import { import {
atoms, atoms,
@ -57,6 +62,9 @@ document.addEventListener("DOMContentLoaded", async () => {
initWS(); initWS();
await loadConnStatus(); await loadConnStatus();
subscribeToConnEvents(); subscribeToConnEvents();
registerGlobalKeys();
registerElectronReinjectKeyHandler();
registerControlShiftStateUpdateHandler();
const fullConfig = await services.FileService.GetFullConfig(); const fullConfig = await services.FileService.GetFullConfig();
console.log("fullconfig", fullConfig); console.log("fullconfig", fullConfig);
globalStore.set(atoms.fullConfigAtom, fullConfig); globalStore.set(atoms.fullConfigAtom, fullConfig);

View File

@ -426,7 +426,8 @@ func GenerateWshServerMethod_ResponseStream(methodDecl *wshrpc.WshRpcMethodDecl,
} }
genRespType := fmt.Sprintf("AsyncGenerator<%s, void, boolean>", respType) genRespType := fmt.Sprintf("AsyncGenerator<%s, void, boolean>", respType)
if methodDecl.CommandDataType != nil { 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 { } else {
sb.WriteString(fmt.Sprintf(" %s(opts?: RpcOpts): %s {\n", methodDecl.MethodName, genRespType)) 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" dataName = "data"
} }
if methodDecl.CommandDataType != nil { 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 { } else {
sb.WriteString(fmt.Sprintf(" %s(opts?: RpcOpts): %s {\n", methodDecl.MethodName, rtnType)) sb.WriteString(fmt.Sprintf(" %s(opts?: RpcOpts): %s {\n", methodDecl.MethodName, rtnType))
} }