mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-02 18:39:05 +01:00
Use webview's built-in history for navigation (#232)
This commit is contained in:
parent
87d69eb5bc
commit
637eaa4206
@ -226,6 +226,7 @@ export const Input = React.memo(
|
||||
onKeyDown={(e) => onKeyDown(e)}
|
||||
onFocus={(e) => onFocus(e)}
|
||||
onBlur={(e) => onBlur(e)}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -504,8 +504,13 @@ function getUserName(): string {
|
||||
return cachedUserName;
|
||||
}
|
||||
|
||||
async function openLink(uri: string) {
|
||||
if (globalStore.get(atoms.settingsAtom)?.["web:openlinksinternally"]) {
|
||||
/**
|
||||
* Open a link in a new window, or in a new web widget. The user can set all links to open in a new web widget using the `web:openlinksinternally` setting.
|
||||
* @param uri The link to open.
|
||||
* @param forceOpenInternally Force the link to open in a new web widget.
|
||||
*/
|
||||
async function openLink(uri: string, forceOpenInternally = false) {
|
||||
if (forceOpenInternally || globalStore.get(atoms.settingsAtom)?.["web:openlinksinternally"]) {
|
||||
const blockDef: BlockDef = {
|
||||
meta: {
|
||||
view: "web",
|
||||
|
@ -1,15 +1,14 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { openLink } from "@/app/store/global";
|
||||
import { WOS, globalStore } from "@/store/global";
|
||||
import { WOS, globalStore, openLink } from "@/store/global";
|
||||
import * as services from "@/store/services";
|
||||
import { checkKeyPressed } from "@/util/keyutil";
|
||||
import { fireAndForget } from "@/util/util";
|
||||
import clsx from "clsx";
|
||||
import { WebviewTag } from "electron";
|
||||
import * as jotai from "jotai";
|
||||
import React, { memo, useEffect } from "react";
|
||||
|
||||
import { checkKeyPressed } from "@/util/keyutil";
|
||||
import React, { memo, useEffect, useState } from "react";
|
||||
import "./webview.less";
|
||||
|
||||
export class WebViewModel implements ViewModel {
|
||||
@ -28,9 +27,6 @@ export class WebViewModel implements ViewModel {
|
||||
refreshIcon: jotai.PrimitiveAtom<string>;
|
||||
webviewRef: React.RefObject<WebviewTag>;
|
||||
urlInputRef: React.RefObject<HTMLInputElement>;
|
||||
historyStack: string[];
|
||||
historyIndex: number;
|
||||
recentUrls: { [key: string]: number };
|
||||
|
||||
constructor(blockId: string) {
|
||||
this.viewType = "web";
|
||||
@ -44,22 +40,15 @@ export class WebViewModel implements ViewModel {
|
||||
this.urlInputFocused = jotai.atom(false);
|
||||
this.isLoading = jotai.atom(false);
|
||||
this.refreshIcon = jotai.atom("rotate-right");
|
||||
this.historyStack = [];
|
||||
this.historyIndex = 0;
|
||||
this.recentUrls = {};
|
||||
|
||||
this.viewIcon = jotai.atom((get) => {
|
||||
return "globe"; // should not be hardcoded
|
||||
});
|
||||
|
||||
this.viewIcon = jotai.atom("globe");
|
||||
this.viewName = jotai.atom("Web");
|
||||
this.urlInputRef = React.createRef<HTMLInputElement>();
|
||||
this.webviewRef = React.createRef<WebviewTag>();
|
||||
|
||||
this.viewText = jotai.atom((get) => {
|
||||
let url = get(this.blockAtom)?.meta?.url || "";
|
||||
if (url && this.historyStack.length === 0) {
|
||||
this.addToHistoryStack(url);
|
||||
if (url && !get(this.url)) {
|
||||
globalStore.set(this.url, url);
|
||||
}
|
||||
const urlIsDirty = get(this.isUrlDirty);
|
||||
if (urlIsDirty) {
|
||||
@ -106,12 +95,26 @@ export class WebViewModel implements ViewModel {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
shouldDisabledBackButton() {
|
||||
return this.historyIndex === 0;
|
||||
try {
|
||||
return !this.webviewRef.current?.canGoBack();
|
||||
} catch (_) {}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
shouldDisabledForwardButton() {
|
||||
return this.historyIndex === this.historyStack.length - 1;
|
||||
try {
|
||||
return !this.webviewRef.current?.canGoForward();
|
||||
} catch (_) {}
|
||||
return true;
|
||||
}
|
||||
|
||||
handleUrlWrapperMouseOver(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
|
||||
@ -131,43 +134,19 @@ export class WebViewModel implements ViewModel {
|
||||
handleBack(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (this.historyIndex > 0) {
|
||||
do {
|
||||
this.historyIndex -= 1;
|
||||
} while (this.historyIndex > 0 && this.isRecentUrl(this.historyStack[this.historyIndex]));
|
||||
|
||||
const prevUrl = this.historyStack[this.historyIndex];
|
||||
this.setBlockUrl(this.blockId, prevUrl);
|
||||
globalStore.set(this.url, prevUrl);
|
||||
if (this.webviewRef.current) {
|
||||
this.webviewRef.current.src = prevUrl;
|
||||
}
|
||||
}
|
||||
this.webviewRef.current?.goBack();
|
||||
}
|
||||
|
||||
handleForward(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (this.historyIndex < this.historyStack.length - 1) {
|
||||
do {
|
||||
this.historyIndex += 1;
|
||||
} while (
|
||||
this.historyIndex < this.historyStack.length - 1 &&
|
||||
this.isRecentUrl(this.historyStack[this.historyIndex])
|
||||
);
|
||||
|
||||
const nextUrl = this.historyStack[this.historyIndex];
|
||||
this.setBlockUrl(this.blockId, nextUrl);
|
||||
globalStore.set(this.url, nextUrl);
|
||||
if (this.webviewRef.current) {
|
||||
this.webviewRef.current.src = nextUrl;
|
||||
}
|
||||
}
|
||||
this.webviewRef.current?.goForward();
|
||||
}
|
||||
|
||||
handleRefresh(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (this.webviewRef.current) {
|
||||
if (globalStore.get(this.isLoading)) {
|
||||
this.webviewRef.current.stop();
|
||||
@ -175,6 +154,9 @@ export class WebViewModel implements ViewModel {
|
||||
this.webviewRef.current.reload();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("handleRefresh catch", e);
|
||||
}
|
||||
}
|
||||
|
||||
handleUrlChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
@ -184,11 +166,8 @@ export class WebViewModel implements ViewModel {
|
||||
|
||||
handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === "Enter") {
|
||||
let url = globalStore.get(this.url);
|
||||
if (!url) {
|
||||
url = this.historyStack[this.historyIndex];
|
||||
}
|
||||
this.navigateTo(url);
|
||||
const url = globalStore.get(this.url);
|
||||
this.loadUrl(url);
|
||||
this.urlInputRef.current?.blur();
|
||||
}
|
||||
}
|
||||
@ -205,6 +184,15 @@ export class WebViewModel implements ViewModel {
|
||||
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);
|
||||
}
|
||||
|
||||
ensureUrlScheme(url: string) {
|
||||
if (/^(http|https):/.test(url)) {
|
||||
// If the URL starts with http: or https:, return it as is
|
||||
@ -248,48 +236,27 @@ export class WebViewModel implements ViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
navigateTo(newUrl: string) {
|
||||
const finalUrl = this.ensureUrlScheme(newUrl);
|
||||
const normalizedFinalUrl = this.normalizeUrl(finalUrl);
|
||||
const normalizedLastUrl = this.normalizeUrl(this.historyStack[this.historyIndex]);
|
||||
/**
|
||||
* Load a new URL in the webview.
|
||||
* @param newUrl The new URL to load in the webview.
|
||||
*/
|
||||
loadUrl(newUrl: string) {
|
||||
console.log("loadUrl", newUrl);
|
||||
const nextUrl = this.ensureUrlScheme(newUrl);
|
||||
const normalizedNextUrl = this.normalizeUrl(nextUrl);
|
||||
const normalizedCurUrl = this.normalizeUrl(globalStore.get(this.url));
|
||||
|
||||
if (normalizedLastUrl !== normalizedFinalUrl) {
|
||||
this.setBlockUrl(this.blockId, normalizedFinalUrl);
|
||||
globalStore.set(this.url, normalizedFinalUrl);
|
||||
this.historyIndex += 1;
|
||||
this.historyStack = this.historyStack.slice(0, this.historyIndex);
|
||||
this.addToHistoryStack(normalizedFinalUrl);
|
||||
if (this.webviewRef.current) {
|
||||
this.webviewRef.current.src = normalizedFinalUrl;
|
||||
}
|
||||
this.updateRecentUrls(normalizedFinalUrl);
|
||||
if (normalizedCurUrl !== normalizedNextUrl) {
|
||||
this.webviewRef?.current.loadURL(normalizedNextUrl);
|
||||
}
|
||||
}
|
||||
|
||||
addToHistoryStack(url: string) {
|
||||
if (this.historyStack.length === 0 || this.historyStack[this.historyStack.length - 1] !== url) {
|
||||
this.historyStack.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
setBlockUrl(blockId: string, url: string) {
|
||||
services.ObjectService.UpdateObjectMeta(WOS.makeORef("block", blockId), { url: url });
|
||||
}
|
||||
|
||||
updateRecentUrls(url: string) {
|
||||
if (this.recentUrls[url]) {
|
||||
this.recentUrls[url]++;
|
||||
} else {
|
||||
this.recentUrls[url] = 1;
|
||||
}
|
||||
// Clean up old entries after a certain threshold
|
||||
if (Object.keys(this.recentUrls).length > 50) {
|
||||
this.recentUrls = {};
|
||||
}
|
||||
}
|
||||
|
||||
isRecentUrl(url: string) {
|
||||
return this.recentUrls[url] > 1;
|
||||
/**
|
||||
* Get the current URL from the state.
|
||||
* @returns The URL from the state.
|
||||
*/
|
||||
getUrl() {
|
||||
return globalStore.get(this.url);
|
||||
}
|
||||
|
||||
setRefreshIcon(refreshIcon: string) {
|
||||
@ -300,10 +267,6 @@ export class WebViewModel implements ViewModel {
|
||||
globalStore.set(this.isLoading, isLoading);
|
||||
}
|
||||
|
||||
getUrl() {
|
||||
return this.historyStack[this.historyIndex];
|
||||
}
|
||||
|
||||
giveFocus(): boolean {
|
||||
if (this.urlInputRef.current) {
|
||||
this.urlInputRef.current.focus({ preventScroll: true });
|
||||
@ -336,14 +299,18 @@ interface WebViewProps {
|
||||
}
|
||||
|
||||
const WebView = memo(({ model }: WebViewProps) => {
|
||||
const url = model.getUrl();
|
||||
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;
|
||||
model.navigateTo(metaUrl);
|
||||
model.loadUrl(metaUrl);
|
||||
}
|
||||
}, [metaUrl]);
|
||||
|
||||
@ -352,57 +319,59 @@ const WebView = memo(({ model }: WebViewProps) => {
|
||||
|
||||
if (webview) {
|
||||
const navigateListener = (e: any) => {
|
||||
model.navigateTo(e.url);
|
||||
model.handleNavigate(e.url);
|
||||
};
|
||||
|
||||
webview.addEventListener("did-navigate", (e) => {
|
||||
console.log("did-navigate");
|
||||
navigateListener(e);
|
||||
});
|
||||
webview.addEventListener("did-start-loading", () => {
|
||||
model.setRefreshIcon("xmark-large");
|
||||
model.setIsLoading(true);
|
||||
});
|
||||
webview.addEventListener("did-stop-loading", () => {
|
||||
model.setRefreshIcon("rotate-right");
|
||||
model.setIsLoading(false);
|
||||
});
|
||||
|
||||
// Handle new-window event
|
||||
webview.addEventListener("new-window", (e: any) => {
|
||||
const newWindowHandler = (e: any) => {
|
||||
e.preventDefault();
|
||||
const newUrl = e.detail.url;
|
||||
openLink(newUrl);
|
||||
});
|
||||
|
||||
// Suppress errors
|
||||
webview.addEventListener("did-fail-load", (e: any) => {
|
||||
console.log("webview new-window event:", newUrl);
|
||||
fireAndForget(() => openLink(newUrl, true));
|
||||
};
|
||||
const startLoadingHandler = () => {
|
||||
model.setRefreshIcon("xmark-large");
|
||||
model.setIsLoading(true);
|
||||
};
|
||||
const stopLoadingHandler = () => {
|
||||
model.setRefreshIcon("rotate-right");
|
||||
model.setIsLoading(false);
|
||||
};
|
||||
const failLoadHandler = (e: any) => {
|
||||
if (e.errorCode === -3) {
|
||||
e.log("Suppressed ERR_ABORTED error");
|
||||
console.warn("Suppressed ERR_ABORTED error", e);
|
||||
} else {
|
||||
console.error(`Failed to load ${e.validatedURL}: ${e.errorDescription}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// Clean up event listeners on component unmount
|
||||
return () => {
|
||||
webview.removeEventListener("did-navigate", navigateListener);
|
||||
webview.removeEventListener("did-navigate-in-page", navigateListener);
|
||||
webview.removeEventListener("new-window", (e: any) => {
|
||||
model.navigateTo(e.url);
|
||||
});
|
||||
webview.removeEventListener("did-fail-load", (e: any) => {
|
||||
if (e.errorCode === -3) {
|
||||
console.log("Suppressed ERR_ABORTED error");
|
||||
} else {
|
||||
console.error(`Failed to load ${e.validatedURL}: ${e.errorDescription}`);
|
||||
}
|
||||
});
|
||||
webview.removeEventListener("new-window", newWindowHandler);
|
||||
webview.removeEventListener("did-fail-load", failLoadHandler);
|
||||
webview.removeEventListener("did-start-loading", startLoadingHandler);
|
||||
webview.removeEventListener("did-stop-loading", stopLoadingHandler);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <webview id="webview" className="webview" ref={model.webviewRef} src={url}></webview>;
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
export { WebView, makeWebViewModel };
|
||||
|
Loading…
Reference in New Issue
Block a user