diff --git a/frontend/app/block/blockutil.tsx b/frontend/app/block/blockutil.tsx index 660c3449..cef13f16 100644 --- a/frontend/app/block/blockutil.tsx +++ b/frontend/app/block/blockutil.tsx @@ -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()} /> ); diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 1d67119f..fbb7fb4f 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -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", diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index faac9910..69b36e02 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -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; webviewRef: React.RefObject; urlInputRef: React.RefObject; - 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(); this.webviewRef = React.createRef(); 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) { @@ -131,49 +134,28 @@ export class WebViewModel implements ViewModel { handleBack(e: React.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) { 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) { e.preventDefault(); e.stopPropagation(); - if (this.webviewRef.current) { - if (globalStore.get(this.isLoading)) { - this.webviewRef.current.stop(); - } else { - this.webviewRef.current.reload(); + try { + if (this.webviewRef.current) { + if (globalStore.get(this.isLoading)) { + this.webviewRef.current.stop(); + } else { + this.webviewRef.current.reload(); + } } + } catch (e) { + console.warn("handleRefresh catch", e); } } @@ -184,11 +166,8 @@ export class WebViewModel implements ViewModel { handleKeyDown(event: React.KeyboardEvent) { 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 ; + return ( + + ); }); export { WebView, makeWebViewModel };