waveterm/frontend/app/view/webview/webview.tsx

450 lines
16 KiB
TypeScript
Raw Normal View History

2024-06-26 18:39:41 +02:00
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
2024-08-30 01:06:15 +02:00
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";
2024-08-30 01:06:15 +02:00
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
import { fireAndForget } from "@/util/util";
2024-07-18 08:41:33 +02:00
import clsx from "clsx";
2024-06-26 18:39:41 +02:00
import { WebviewTag } from "electron";
import * as jotai from "jotai";
import React, { memo, useEffect, useState } from "react";
import { debounce } from "throttle-debounce";
2024-06-26 18:39:41 +02:00
import "./webview.less";
2024-07-15 18:40:28 +02:00
export class WebViewModel implements ViewModel {
2024-08-23 01:25:53 +02:00
viewType: string;
2024-07-09 00:04:48 +02:00
blockId: string;
2024-07-15 18:40:28 +02:00
blockAtom: jotai.Atom<Block>;
viewIcon: jotai.Atom<string | IconButtonDecl>;
2024-07-15 18:40:28 +02:00
viewName: jotai.Atom<string>;
viewText: jotai.Atom<HeaderElem[]>;
url: jotai.PrimitiveAtom<string>;
isUrlDirty: jotai.PrimitiveAtom<boolean>;
2024-07-15 18:40:28 +02:00
urlInput: jotai.PrimitiveAtom<string>;
urlInputFocused: jotai.PrimitiveAtom<boolean>;
isLoading: jotai.PrimitiveAtom<boolean>;
urlWrapperClassName: jotai.PrimitiveAtom<string>;
refreshIcon: jotai.PrimitiveAtom<string>;
webviewRef: React.RefObject<WebviewTag>;
urlInputRef: React.RefObject<HTMLInputElement>;
2024-08-30 01:06:15 +02:00
nodeModel: NodeModel;
2024-07-15 18:40:28 +02:00
2024-08-30 01:06:15 +02:00
constructor(blockId: string, nodeModel: NodeModel) {
this.nodeModel = nodeModel;
2024-08-23 01:25:53 +02:00
this.viewType = "web";
2024-07-15 18:40:28 +02:00
this.blockId = blockId;
this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`);
this.url = jotai.atom("");
this.isUrlDirty = jotai.atom(false);
2024-07-15 18:40:28 +02:00
this.urlInput = jotai.atom("");
this.urlWrapperClassName = jotai.atom("");
this.urlInputFocused = jotai.atom(false);
this.isLoading = jotai.atom(false);
this.refreshIcon = jotai.atom("rotate-right");
this.viewIcon = jotai.atom("globe");
2024-07-15 18:40:28 +02:00
this.viewName = jotai.atom("Web");
this.urlInputRef = React.createRef<HTMLInputElement>();
this.webviewRef = React.createRef<WebviewTag>();
2024-06-28 03:09:30 +02:00
2024-07-15 18:40:28 +02:00
this.viewText = jotai.atom((get) => {
let url = get(this.blockAtom)?.meta?.url || "";
if (url && !get(this.url)) {
globalStore.set(this.url, url);
2024-06-26 18:39:41 +02:00
}
const urlIsDirty = get(this.isUrlDirty);
if (urlIsDirty) {
const currUrl = get(this.url);
2024-07-15 18:40:28 +02:00
url = currUrl;
}
return [
{
elemtype: "iconbutton",
icon: "chevron-left",
click: this.handleBack.bind(this),
disabled: this.shouldDisabledBackButton(),
2024-07-15 18:40:28 +02:00
},
{
elemtype: "iconbutton",
icon: "chevron-right",
click: this.handleForward.bind(this),
disabled: this.shouldDisabledForwardButton(),
2024-07-15 18:40:28 +02:00
},
{
elemtype: "div",
2024-07-18 08:41:33 +02:00
className: clsx("block-frame-div-url", get(this.urlWrapperClassName)),
2024-07-15 18:40:28 +02:00
onMouseOver: this.handleUrlWrapperMouseOver.bind(this),
onMouseOut: this.handleUrlWrapperMouseOut.bind(this),
children: [
{
elemtype: "input",
value: url,
ref: this.urlInputRef,
className: "url-input",
onChange: this.handleUrlChange.bind(this),
onKeyDown: this.handleKeyDown.bind(this),
onFocus: this.handleFocus.bind(this),
onBlur: this.handleBlur.bind(this),
},
{
elemtype: "iconbutton",
icon: get(this.refreshIcon),
click: this.handleRefresh.bind(this),
},
],
},
] as HeaderElem[];
});
}
2024-06-26 18:39:41 +02:00
/**
* Whether the back button in the header should be disabled.
* @returns True if the WebView cannot go back or if the WebView call fails. False otherwise.
*/
2024-07-15 18:40:28 +02:00
shouldDisabledBackButton() {
try {
return !this.webviewRef.current?.canGoBack();
} catch (_) {}
return true;
2024-07-15 18:40:28 +02:00
}
2024-06-26 18:39:41 +02:00
/**
* Whether the forward button in the header should be disabled.
* @returns True if the WebView cannot go forward or if the WebView call fails. False otherwise.
*/
2024-07-15 18:40:28 +02:00
shouldDisabledForwardButton() {
try {
return !this.webviewRef.current?.canGoForward();
} catch (_) {}
return true;
2024-07-15 18:40:28 +02:00
}
2024-06-26 18:39:41 +02:00
2024-07-15 18:40:28 +02:00
handleUrlWrapperMouseOver(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
const urlInputFocused = globalStore.get(this.urlInputFocused);
if (e.type === "mouseover" && !urlInputFocused) {
globalStore.set(this.urlWrapperClassName, "hovered");
}
}
2024-06-26 18:39:41 +02:00
2024-07-15 18:40:28 +02:00
handleUrlWrapperMouseOut(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
const urlInputFocused = globalStore.get(this.urlInputFocused);
if (e.type === "mouseout" && !urlInputFocused) {
globalStore.set(this.urlWrapperClassName, "");
2024-06-26 18:39:41 +02:00
}
2024-07-15 18:40:28 +02:00
}
2024-06-26 18:39:41 +02:00
2024-08-30 01:06:15 +02:00
handleBack(e?: React.MouseEvent<HTMLDivElement, MouseEvent>) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
this.webviewRef.current?.goBack();
2024-07-15 18:40:28 +02:00
}
2024-06-26 18:39:41 +02:00
2024-08-30 01:06:15 +02:00
handleForward(e?: React.MouseEvent<HTMLDivElement, MouseEvent>) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
this.webviewRef.current?.goForward();
2024-07-15 18:40:28 +02:00
}
handleRefresh(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
e.preventDefault();
e.stopPropagation();
try {
if (this.webviewRef.current) {
if (globalStore.get(this.isLoading)) {
this.webviewRef.current.stop();
} else {
this.webviewRef.current.reload();
}
2024-07-15 18:40:28 +02:00
}
} catch (e) {
console.warn("handleRefresh catch", e);
2024-07-15 18:40:28 +02:00
}
}
2024-06-28 03:09:30 +02:00
2024-07-15 18:40:28 +02:00
handleUrlChange(event: React.ChangeEvent<HTMLInputElement>) {
globalStore.set(this.url, event.target.value);
globalStore.set(this.isUrlDirty, true);
2024-07-15 18:40:28 +02:00
}
handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
2024-08-30 01:06:15 +02:00
const waveEvent = adaptFromReactOrNativeKeyEvent(event);
if (checkKeyPressed(waveEvent, "Enter")) {
const url = globalStore.get(this.url);
2024-09-08 03:22:13 +02:00
this.loadUrl(url, "enter");
2024-07-15 18:40:28 +02:00
this.urlInputRef.current?.blur();
2024-08-30 01:06:15 +02:00
return;
}
if (checkKeyPressed(waveEvent, "Escape")) {
this.webviewRef.current?.focus();
2024-07-15 18:40:28 +02:00
}
}
2024-06-26 18:39:41 +02:00
2024-07-15 18:40:28 +02:00
handleFocus(event: React.FocusEvent<HTMLInputElement>) {
globalStore.set(this.urlWrapperClassName, "focused");
globalStore.set(this.urlInputFocused, true);
this.urlInputRef.current.focus();
event.target.select();
}
handleBlur(event: React.FocusEvent<HTMLInputElement>) {
globalStore.set(this.urlWrapperClassName, "");
globalStore.set(this.urlInputFocused, false);
}
/**
* Update the URL in the state when a navigation event has occurred.
* @param url The URL that has been navigated to.
*/
handleNavigate(url: string) {
services.ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { url });
globalStore.set(this.url, url);
}
2024-07-15 18:40:28 +02:00
ensureUrlScheme(url: string) {
if (/^(http|https):/.test(url)) {
// If the URL starts with http: or https:, return it as is
return url;
}
// Check if the URL looks like a local URL
const isLocal = /^(localhost|(\d{1,3}\.){3}\d{1,3})(:\d+)?$/.test(url.split("/")[0]);
if (isLocal) {
// If it is a local URL, ensure it has http:// scheme
return `http://${url}`;
}
// Check if the URL looks like a domain
const domainRegex = /^[a-z0-9.-]+\.[a-z]{2,}$/i;
const isDomain = domainRegex.test(url.split("/")[0]);
if (isDomain) {
// If it looks like a domain, ensure it has https:// scheme
2024-06-26 18:39:41 +02:00
return `https://${url}`;
}
// Otherwise, treat it as a search query
return `https://www.google.com/search?q=${encodeURIComponent(url)}`;
2024-07-15 18:40:28 +02:00
}
normalizeUrl(url: string) {
if (!url) {
return url;
}
2024-06-26 18:39:41 +02:00
try {
const parsedUrl = new URL(url);
if (parsedUrl.hostname.startsWith("www.")) {
parsedUrl.hostname = parsedUrl.hostname.slice(4);
}
return parsedUrl.href;
} catch (e) {
return url.replace(/\/+$/, "") + "/";
2024-06-26 18:39:41 +02:00
}
2024-07-15 18:40:28 +02:00
}
2024-06-26 18:39:41 +02:00
/**
* Load a new URL in the webview.
* @param newUrl The new URL to load in the webview.
*/
2024-09-08 03:22:13 +02:00
loadUrl(newUrl: string, reason: string) {
const nextUrl = this.ensureUrlScheme(newUrl);
2024-09-08 03:22:13 +02:00
console.log("webview loadUrl", reason, nextUrl, "cur=", this.webviewRef?.current.getURL());
if (newUrl != nextUrl) {
globalStore.set(this.url, nextUrl);
}
if (!this.webviewRef.current) {
return;
}
if (this.webviewRef.current.getURL() != nextUrl) {
this.webviewRef.current.loadURL(nextUrl);
2024-06-26 18:39:41 +02:00
}
2024-07-15 18:40:28 +02:00
}
2024-06-26 18:39:41 +02:00
/**
* Get the current URL from the state.
* @returns The URL from the state.
*/
getUrl() {
return globalStore.get(this.url);
2024-07-15 18:40:28 +02:00
}
2024-06-26 18:39:41 +02:00
2024-07-15 18:40:28 +02:00
setRefreshIcon(refreshIcon: string) {
globalStore.set(this.refreshIcon, refreshIcon);
}
2024-06-26 18:39:41 +02:00
2024-07-15 18:40:28 +02:00
setIsLoading(isLoading: boolean) {
globalStore.set(this.isLoading, isLoading);
}
2024-06-26 18:39:41 +02:00
giveFocus(): boolean {
2024-08-30 01:06:15 +02:00
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;
}
2024-08-30 01:06:15 +02:00
this.webviewRef.current?.focus();
return true;
}
keyDownHandler(e: WaveKeyboardEvent): boolean {
if (checkKeyPressed(e, "Cmd:l")) {
this.urlInputRef?.current?.focus();
this.urlInputRef?.current?.select();
return true;
}
if (checkKeyPressed(e, "Cmd:r")) {
this.webviewRef?.current?.reload();
return true;
}
2024-08-30 01:06:15 +02:00
if (checkKeyPressed(e, "Cmd:ArrowLeft")) {
this.handleBack(null);
return true;
}
if (checkKeyPressed(e, "Cmd:ArrowRight")) {
this.handleForward(null);
return true;
}
return false;
}
2024-09-10 00:41:26 +02:00
getSettingsMenuItems() {
return [
{
label: this.webviewRef.current?.isDevToolsOpened() ? "Close DevTools" : "Open DevTools",
click: async () => {
if (this.webviewRef.current) {
if (this.webviewRef.current.isDevToolsOpened()) {
this.webviewRef.current.closeDevTools();
} else {
this.webviewRef.current.openDevTools();
}
}
},
},
];
}
2024-07-15 18:40:28 +02:00
}
2024-08-30 01:06:15 +02:00
function makeWebViewModel(blockId: string, nodeModel: NodeModel): WebViewModel {
const webviewModel = new WebViewModel(blockId, nodeModel);
2024-07-15 18:40:28 +02:00
return webviewModel;
}
interface WebViewProps {
blockId: string;
2024-07-15 18:40:28 +02:00
model: WebViewModel;
}
const WebView = memo(({ model }: WebViewProps) => {
const blockData = jotai.useAtomValue(model.blockAtom);
const metaUrl = blockData?.meta?.url;
const metaUrlRef = React.useRef(metaUrl);
// The initial value of the block metadata URL when the component first renders. Used to set the starting src value for the webview.
const [metaUrlInitial] = useState(metaUrl);
// Load a new URL if the block metadata is updated.
useEffect(() => {
if (metaUrlRef.current != metaUrl) {
metaUrlRef.current = metaUrl;
2024-09-08 03:22:13 +02:00
model.loadUrl(metaUrl, "meta");
}
}, [metaUrl]);
2024-07-15 18:40:28 +02:00
useEffect(() => {
const webview = model.webviewRef.current;
if (webview) {
const navigateListener = (e: any) => {
model.handleNavigate(e.url);
2024-07-15 18:40:28 +02:00
};
const newWindowHandler = (e: any) => {
e.preventDefault();
const newUrl = e.detail.url;
console.log("webview new-window event:", newUrl);
fireAndForget(() => openLink(newUrl, true));
};
const startLoadingHandler = () => {
2024-07-15 18:40:28 +02:00
model.setRefreshIcon("xmark-large");
model.setIsLoading(true);
webview.style.backgroundColor = "transparent";
};
const stopLoadingHandler = () => {
2024-07-15 18:40:28 +02:00
model.setRefreshIcon("rotate-right");
model.setIsLoading(false);
debounce(1000, () => {
webview.style.backgroundColor = "white";
})();
};
const failLoadHandler = (e: any) => {
2024-07-15 18:40:28 +02:00
if (e.errorCode === -3) {
console.warn("Suppressed ERR_ABORTED error", e);
2024-07-15 18:40:28 +02:00
} else {
console.error(`Failed to load ${e.validatedURL}: ${e.errorDescription}`);
}
};
2024-08-30 01:06:15 +02:00
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);
webview.addEventListener("did-start-loading", startLoadingHandler);
webview.addEventListener("did-stop-loading", stopLoadingHandler);
webview.addEventListener("new-window", newWindowHandler);
webview.addEventListener("did-fail-load", failLoadHandler);
2024-07-15 18:40:28 +02:00
2024-08-30 01:06:15 +02:00
webview.addEventListener("focus", webviewFocus);
webview.addEventListener("blur", webviewBlur);
2024-07-15 18:40:28 +02:00
// Clean up event listeners on component unmount
return () => {
webview.removeEventListener("did-navigate", navigateListener);
webview.removeEventListener("did-navigate-in-page", navigateListener);
webview.removeEventListener("new-window", newWindowHandler);
webview.removeEventListener("did-fail-load", failLoadHandler);
webview.removeEventListener("did-start-loading", startLoadingHandler);
webview.removeEventListener("did-stop-loading", stopLoadingHandler);
2024-08-30 01:06:15 +02:00
webview.addEventListener("focus", webviewFocus);
webview.addEventListener("blur", webviewBlur);
2024-07-15 18:40:28 +02:00
};
}
2024-07-15 18:40:28 +02:00
}, []);
return (
<webview
id="webview"
className="webview"
ref={model.webviewRef}
src={metaUrlInitial}
// @ts-ignore This is a discrepancy between the React typing and the Chromium impl for webviewTag. Chrome webviewTag expects a string, while React expects a boolean.
allowpopups="true"
></webview>
);
2024-07-03 23:32:55 +02:00
});
2024-06-26 18:39:41 +02:00
2024-07-15 18:40:28 +02:00
export { WebView, makeWebViewModel };