From e140076801031241683164994970fc007c291fa9 Mon Sep 17 00:00:00 2001 From: Red J Adaya Date: Tue, 16 Jul 2024 00:40:28 +0800 Subject: [PATCH] webview controls (#110) --- frontend/app/block/block.less | 71 ++++ frontend/app/block/block.tsx | 96 ++++- frontend/app/hook/useLongClick.tsx | 4 +- frontend/app/view/webview.less | 61 +--- frontend/app/view/webview.tsx | 568 ++++++++++++++++------------- frontend/types/custom.d.ts | 22 +- package.json | 1 + yarn.lock | 23 ++ 8 files changed, 522 insertions(+), 324 deletions(-) diff --git a/frontend/app/block/block.less b/frontend/app/block/block.less index 0c6758a54..86d873e65 100644 --- a/frontend/app/block/block.less +++ b/frontend/app/block/block.less @@ -117,6 +117,77 @@ opacity: 0.7; } + .block-frame-textelems-wrapper { + display: flex; + flex-grow: 1; + gap: 8px; + height: 20px; + align-items: center; + + .block-frame-header-iconbutton { + cursor: pointer; + opacity: 0.5; + + &:hover { + opacity: 1; + } + + &.disabled { + opacity: 0.5; + + &:hover { + opacity: 0.5; + } + } + } + + .block-frame-div { + display: flex; + width: 100%; + height: 100%; + justify-content: space-between; + border-radius: 3px; + align-items: center; + padding-left: 7px; + background: rgba(255, 255, 255, 0.1); + + &.hovered { + background: rgba(255, 255, 255, 0.2); + cursor: text; + transition: background 0.2s ease; + } + + &.focused { + outline: 2px solid rgba(88, 193, 66, 0.5); + background: #181818; + } + + .input-wrapper { + flex-grow: 1; + + input { + background-color: transparent; + outline: none; + border: none; + color: var(--app-text-color); + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + box-sizing: border-box; + } + } + + .block-frame-header-iconbutton { + height: 100%; + width: 27px; + display: flex; + align-items: center; + justify-content: center; + } + } + } + .block-frame-end-icons { display: flex; align-items: center; diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index d1c350beb..755c0cf93 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -14,7 +14,7 @@ import { PlotView } from "@/view/plotview"; import { PreviewView, makePreviewModel } from "@/view/preview"; import { TerminalView } from "@/view/term/term"; import { WaveAi } from "@/view/waveai"; -import { WebView } from "@/view/webview"; +import { WebView, makeWebViewModel } from "@/view/webview"; import clsx from "clsx"; import * as jotai from "jotai"; import * as React from "react"; @@ -221,6 +221,23 @@ const IconButton = React.memo(({ decl, className }: { decl: HeaderIconButton; cl ); }); +const Input = React.memo(({ decl, className }: { decl: HeaderInput; className: string }) => { + const { value, ref, onChange, onKeyDown, onFocus, onBlur } = decl; + return ( +
+ onChange(e)} + onKeyDown={(e) => onKeyDown(e)} + onFocus={(e) => onFocus(e)} + onBlur={(e) => onBlur(e)} + /> +
+ ); +}); + const BlockFrame_Default_Component = ({ blockId, layoutModel, @@ -289,7 +306,54 @@ const BlockFrame_Default_Component = ({ endIconsElem.push( ); - let headerTextElems: JSX.Element[] = []; + + function renderHeaderElements(headerTextUnion: HeaderElem[]): JSX.Element[] { + const headerTextElems: JSX.Element[] = []; + + function renderElement(elem: HeaderElem, key: number): JSX.Element { + if (elem.elemtype == "iconbutton") { + return ( + + ); + } else if (elem.elemtype == "input") { + return ; + } else if (elem.elemtype == "text") { + return ( +
+ {elem.text} +
+ ); + } else if (elem.elemtype == "div") { + return ( +
+ {elem.children.map((child, childIdx) => renderElement(child, childIdx))} +
+ ); + } + return null; + } + + for (let idx = 0; idx < headerTextUnion.length; idx++) { + const elem = headerTextUnion[idx]; + const renderedElement = renderElement(elem, idx); + if (renderedElement) { + headerTextElems.push(renderedElement); + } + } + + return headerTextElems; + } + + const headerTextElems: JSX.Element[] = []; if (typeof headerTextUnion === "string") { if (!util.isBlank(headerTextUnion)) { headerTextElems.push( @@ -299,19 +363,9 @@ const BlockFrame_Default_Component = ({ ); } } else if (Array.isArray(headerTextUnion)) { - for (let idx = 0; idx < headerTextUnion.length; idx++) { - const elem = headerTextUnion[idx]; - if (elem.elemtype == "iconbutton") { - headerTextElems.push(); - } else if (elem.elemtype == "text") { - headerTextElems.push( -
- {elem.text} -
- ); - } - } + headerTextElems.push(...renderHeaderElements(headerTextUnion)); } + return (
[{blockId.substring(0, 8)}]
)} - {headerTextElems} -
+
{headerTextElems}
{endIconsElem}
{preview ?
: children} @@ -445,7 +498,9 @@ function getViewElemAndModel( } else if (blockView === "codeedit") { viewElem = ; } else if (blockView === "web") { - viewElem = ; + const webviewModel = makeWebViewModel(blockId); + viewElem = ; + viewModel = webviewModel; } else if (blockView === "waveai") { viewElem = ; } @@ -559,9 +614,12 @@ const BlockFull = React.memo(({ blockId, layoutModel }: BlockProps) => { if (focusableChildren.length == 0) { focusElemRef.current.focus({ preventScroll: true }); } else { - (focusableChildren[0] as HTMLElement).focus({ preventScroll: true }); + const firstFocusableChild = focusableChildren[0] as HTMLElement; + if (!firstFocusableChild.classList.contains("url-input")) { + firstFocusableChild.focus({ preventScroll: true }); + } } - }, [focusElemRef.current, getFocusableChildren]); + }, [getFocusableChildren]); let { viewElem, viewModel } = React.useMemo( () => getViewElemAndModel(blockId, blockData?.view, blockRef), diff --git a/frontend/app/hook/useLongClick.tsx b/frontend/app/hook/useLongClick.tsx index 39841e571..54f1cd4a3 100644 --- a/frontend/app/hook/useLongClick.tsx +++ b/frontend/app/hook/useLongClick.tsx @@ -25,8 +25,8 @@ export const useLongClick = (ref, onClick, onLongClick, ms = 300) => { const handleClick = useCallback( (e: React.MouseEvent) => { if (longClickTriggered) { - event.preventDefault(); - event.stopPropagation(); + e.preventDefault(); + e.stopPropagation(); return; } onClick?.(e); diff --git a/frontend/app/view/webview.less b/frontend/app/view/webview.less index 137fcb35a..74377d9e3 100644 --- a/frontend/app/view/webview.less +++ b/frontend/app/view/webview.less @@ -1,62 +1,9 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -.webview-wrapper { +.webview { + height: 100%; width: 100%; - display: flex; - flex-direction: column; - - .toolbar { - display: flex; - flex-shrink: 0; - - .navigation { - display: flex; - border: 1px solid var(--border-color); - border-right: none; - border-top-left-radius: 4px; - - .button { - padding: 6px 12px; - - i { - font-size: 16px; - margin: 0; - - &.fa-rotate-right { - font-size: 12px; - } - } - } - } - - .url-input-wrapper { - width: 100%; - padding: 3px; - border: 1px solid var(--border-color); - border-top-right-radius: 4px; - - .url-input { - flex: 1; - width: 100%; - height: 100%; - border: none; - background-color: rgba(255, 255, 255, 0.1); - padding: 0 5px; - color: var(--app-color); - border-radius: 2px; - - &:focus { - outline: none; - } - } - } - } - - .webview { - flex-grow: 1; - width: 100%; - border-bottom-left-radius: 4px; - border-bottom-right-radius: 4px; - } + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; } diff --git a/frontend/app/view/webview.tsx b/frontend/app/view/webview.tsx index 54570f615..aa27faf76 100644 --- a/frontend/app/view/webview.tsx +++ b/frontend/app/view/webview.tsx @@ -1,96 +1,336 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { Button } from "@/app/element/button"; import { getApi } from "@/app/store/global"; -import { WOS, useBlockAtom } from "@/store/global"; +import { WOS, globalStore } from "@/store/global"; import * as services from "@/store/services"; import { WebviewTag } from "electron"; import * as jotai from "jotai"; -import React, { memo, useEffect, useMemo, useRef, useState } from "react"; + +import React, { memo, useEffect } from "react"; import "./webview.less"; -interface WebViewProps { +export class WebViewModel implements ViewModel { blockId: string; - parentRef: React.MutableRefObject; -} + blockAtom: jotai.Atom; + viewIcon: jotai.Atom; + viewName: jotai.Atom; + viewText: jotai.Atom; + preIconButton: jotai.Atom; + endIconButtons: jotai.Atom; + url: jotai.PrimitiveAtom; + urlInput: jotai.PrimitiveAtom; + urlInputFocused: jotai.PrimitiveAtom; + isLoading: jotai.PrimitiveAtom; + urlWrapperClassName: jotai.PrimitiveAtom; + refreshIcon: jotai.PrimitiveAtom; + webviewRef: React.RefObject; + urlInputRef: React.RefObject; + historyStack: string[]; + historyIndex: number; + recentUrls: { [key: string]: number }; -function setBlockUrl(blockId: string, url: string) { - services.ObjectService.UpdateObjectMeta(WOS.makeORef("block", blockId), { url: url }); -} + constructor(blockId: string) { + this.blockId = blockId; + this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); -const WebView = memo(({ blockId, parentRef }: WebViewProps) => { - const blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", blockId)); - const blockData = WOS.useWaveObjectValueWithSuspense(WOS.makeORef("block", blockId)); - const urlAtom = useBlockAtom(blockId, "webview:url", () => { - return jotai.atom((get) => { - const blockData = get(blockAtom); - return blockData?.meta?.url; + this.url = jotai.atom(""); + 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.historyStack = []; + this.historyIndex = 0; + this.recentUrls = {}; + + this.viewIcon = jotai.atom((get) => { + return "globe"; // should not be hardcoded }); - }); - const realUrl = jotai.useAtomValue(urlAtom); - const [lastRealUrl, setLastRealUrl] = useState(realUrl); - const initialUrl = useMemo(() => blockData?.meta?.url, []); - const [inputUrl, setInputUrl] = useState(realUrl); // Separate state for the input field - const [isLoading, setIsLoading] = useState(false); - const webviewRef = useRef(null); - const inputRef = useRef(null); - const historyStack = useRef([]); - const historyIndex = useRef(-1); - const recentUrls = useRef<{ [key: string]: number }>({}); + this.viewName = jotai.atom("Web"); + this.urlInputRef = React.createRef(); + this.webviewRef = React.createRef(); - useEffect(() => { - if (realUrl !== lastRealUrl) { - setLastRealUrl(realUrl); - setInputUrl(realUrl); - } - }, [realUrl, lastRealUrl]); - - useEffect(() => { - historyStack.current.push(initialUrl); - historyIndex.current = 0; - - const webview = webviewRef.current; - - const handleNavigation = (newUrl: string) => { - const normalizedNewUrl = normalizeUrl(newUrl); - const normalizedLastUrl = normalizeUrl(historyStack.current[historyIndex.current]); - - if (normalizedLastUrl !== normalizedNewUrl) { - setBlockUrl(blockId, normalizedNewUrl); - setInputUrl(normalizedNewUrl); // Update input field as well - historyIndex.current += 1; - historyStack.current = historyStack.current.slice(0, historyIndex.current); - historyStack.current.push(normalizedNewUrl); - updateRecentUrls(normalizedNewUrl); + this.viewText = jotai.atom((get) => { + let url = get(this.blockAtom)?.meta?.url || ""; + if (url && this.historyStack.length === 0) { + this.addToHistoryStack(url); } - }; + const currUrl = get(this.url); + if (currUrl) { + url = currUrl; + } + return [ + { + elemtype: "iconbutton", + className: this.shouldDisabledBackButton() ? "disabled" : "", + icon: "chevron-left", + click: this.handleBack.bind(this), + }, + { + elemtype: "iconbutton", + className: this.shouldDisabledForwardButton() ? "disabled" : "", + icon: "chevron-right", + click: this.handleForward.bind(this), + }, + { + elemtype: "div", + className: get(this.urlWrapperClassName), + 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[]; + }); + } + + shouldDisabledBackButton() { + return this.historyIndex === 0; + } + + shouldDisabledForwardButton() { + return this.historyIndex === this.historyStack.length - 1; + } + + handleUrlWrapperMouseOver(e: React.MouseEvent) { + const urlInputFocused = globalStore.get(this.urlInputFocused); + if (e.type === "mouseover" && !urlInputFocused) { + globalStore.set(this.urlWrapperClassName, "hovered"); + } + } + + handleUrlWrapperMouseOut(e: React.MouseEvent) { + const urlInputFocused = globalStore.get(this.urlInputFocused); + if (e.type === "mouseout" && !urlInputFocused) { + globalStore.set(this.urlWrapperClassName, ""); + } + } + + 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; + } + } + } + + 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; + } + } + } + + 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(); + } + } + } + + handleUrlChange(event: React.ChangeEvent) { + globalStore.set(this.url, event.target.value); + } + + handleKeyDown(event: React.KeyboardEvent) { + if (event.key === "Enter") { + let url = globalStore.get(this.url); + if (!url) { + url = this.historyStack[this.historyIndex]; + } + this.navigateTo(url); + this.urlInputRef.current?.blur(); + } + } + + handleFocus(event: React.FocusEvent) { + globalStore.set(this.urlWrapperClassName, "focused"); + globalStore.set(this.urlInputFocused, true); + this.urlInputRef.current.focus(); + event.target.select(); + } + + handleBlur(event: React.FocusEvent) { + globalStore.set(this.urlWrapperClassName, ""); + globalStore.set(this.urlInputFocused, false); + } + + ensureUrlScheme(url: string) { + if (/^(localhost|(\d{1,3}\.){3}\d{1,3})(:\d+)?/.test(url)) { + // If the URL starts with localhost or an IP address (with optional port) + return `http://${url}`; + } else if (!/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(url)) { + // If the URL doesn't start with a protocol + return `https://${url}`; + } + return url; + } + + normalizeUrl(url: string) { + if (!url) { + return url; + } + + 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(/\/+$/, "") + "/"; + } + } + + navigateTo(newUrl: string) { + const finalUrl = this.ensureUrlScheme(newUrl); + const normalizedFinalUrl = this.normalizeUrl(finalUrl); + const normalizedLastUrl = this.normalizeUrl(this.historyStack[this.historyIndex]); + + 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); + } + } + + 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; + } + + setRefreshIcon(refreshIcon: string) { + globalStore.set(this.refreshIcon, refreshIcon); + } + + setIsLoading(isLoading: boolean) { + globalStore.set(this.isLoading, isLoading); + } + + getUrl() { + return this.historyStack[this.historyIndex]; + } +} + +function makeWebViewModel(blockId: string): WebViewModel { + const webviewModel = new WebViewModel(blockId); + return webviewModel; +} + +interface WebViewProps { + parentRef: React.RefObject; + model: WebViewModel; +} + +const WebView = memo(({ parentRef, model }: WebViewProps) => { + const url = model.getUrl(); + + useEffect(() => { + const webview = model.webviewRef.current; if (webview) { - const navigateListener = (event: any) => { - handleNavigation(event.url); + const navigateListener = (e: any) => { + model.navigateTo(e.url); }; - webview.addEventListener("did-navigate", navigateListener); - webview.addEventListener("did-navigate-in-page", navigateListener); - webview.addEventListener("did-start-loading", () => setIsLoading(true)); - webview.addEventListener("did-stop-loading", () => setIsLoading(false)); + 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", (event: any) => { - event.preventDefault(); - const newUrl = event.detail.url; + webview.addEventListener("new-window", (e: any) => { + e.preventDefault(); + const newUrl = e.detail.url; getApi().openExternal(newUrl); }); // Suppress errors - webview.addEventListener("did-fail-load", (event: any) => { - if (event.errorCode === -3) { - console.log("Suppressed ERR_ABORTED error"); + webview.addEventListener("did-fail-load", (e: any) => { + if (e.errorCode === -3) { + e.log("Suppressed ERR_ABORTED error"); } else { - console.error(`Failed to load ${event.validatedURL}: ${event.errorDescription}`); + console.error(`Failed to load ${e.validatedURL}: ${e.errorDescription}`); } }); @@ -98,32 +338,32 @@ const WebView = memo(({ blockId, parentRef }: WebViewProps) => { return () => { webview.removeEventListener("did-navigate", navigateListener); webview.removeEventListener("did-navigate-in-page", navigateListener); - webview.removeEventListener("new-window", (event: any) => { - webview.src = event.url; + webview.removeEventListener("new-window", (e: any) => { + model.navigateTo(e.url); }); - webview.removeEventListener("did-fail-load", (event: any) => { - if (event.errorCode === -3) { + webview.removeEventListener("did-fail-load", (e: any) => { + if (e.errorCode === -3) { console.log("Suppressed ERR_ABORTED error"); } else { - console.error(`Failed to load ${event.validatedURL}: ${event.errorDescription}`); + console.error(`Failed to load ${e.validatedURL}: ${e.errorDescription}`); } }); }; } - }, [initialUrl]); + }, []); useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if ((event.ctrlKey || event.metaKey) && event.key === "l") { - event.preventDefault(); - if (inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "l") { + e.preventDefault(); + if (model.urlInputRef) { + model.urlInputRef.current.focus(); + model.urlInputRef.current.select(); } - } else if ((event.ctrlKey || event.metaKey) && event.key === "r") { - event.preventDefault(); - if (webviewRef.current) { - webviewRef.current.reload(); + } else if ((e.ctrlKey || e.metaKey) && e.key === "r") { + e.preventDefault(); + if (model.webviewRef.current) { + model.webviewRef.current.reload(); } } }; @@ -140,169 +380,7 @@ const WebView = memo(({ blockId, parentRef }: WebViewProps) => { }; }, [parentRef]); - const ensureUrlScheme = (url: string) => { - if (/^(localhost|(\d{1,3}\.){3}\d{1,3})(:\d+)?/.test(url)) { - // If the URL starts with localhost or an IP address (with optional port) - return `http://${url}`; - } else if (!/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(url)) { - // If the URL doesn't start with a protocol - return `https://${url}`; - } - return url; - }; - - const normalizeUrl = (url: string) => { - try { - const parsedUrl = new URL(url); - if (parsedUrl.hostname.startsWith("www.")) { - parsedUrl.hostname = parsedUrl.hostname.slice(4); - } - - // Ensure pathname ends with a trailing slash - if (!parsedUrl.pathname.endsWith("/")) { - parsedUrl.pathname += "/"; - } - - // Ensure hash fragments end with a trailing slash - if (parsedUrl.hash && !parsedUrl.hash.endsWith("/")) { - parsedUrl.hash += "/"; - } - - // Ensure search parameters end with a trailing slash - if (parsedUrl.search && !parsedUrl.search.endsWith("/")) { - parsedUrl.search += "/"; - } - - return parsedUrl.href; - } catch (e) { - return url.replace(/\/+$/, "") + "/"; - } - }; - - const navigateTo = (newUrl: string) => { - const finalUrl = ensureUrlScheme(newUrl); - const normalizedFinalUrl = normalizeUrl(finalUrl); - const normalizedLastUrl = normalizeUrl(historyStack.current[historyIndex.current]); - - if (normalizedLastUrl !== normalizedFinalUrl) { - setBlockUrl(blockId, normalizedFinalUrl); - setInputUrl(normalizedFinalUrl); - historyIndex.current += 1; - historyStack.current = historyStack.current.slice(0, historyIndex.current); - historyStack.current.push(normalizedFinalUrl); - if (webviewRef.current) { - webviewRef.current.src = normalizedFinalUrl; - } - updateRecentUrls(normalizedFinalUrl); - } - }; - - const handleBack = () => { - if (historyIndex.current > 0) { - do { - historyIndex.current -= 1; - } while (historyIndex.current > 0 && isRecentUrl(historyStack.current[historyIndex.current])); - - const prevUrl = historyStack.current[historyIndex.current]; - setBlockUrl(blockId, prevUrl); - setInputUrl(prevUrl); - if (webviewRef.current) { - webviewRef.current.src = prevUrl; - } - } - }; - - const handleForward = () => { - if (historyIndex.current < historyStack.current.length - 1) { - do { - historyIndex.current += 1; - } while ( - historyIndex.current < historyStack.current.length - 1 && - isRecentUrl(historyStack.current[historyIndex.current]) - ); - - const nextUrl = historyStack.current[historyIndex.current]; - setBlockUrl(blockId, nextUrl); - setInputUrl(nextUrl); - if (webviewRef.current) { - webviewRef.current.src = nextUrl; - } - } - }; - - const handleRefresh = () => { - if (webviewRef.current) { - if (isLoading) { - webviewRef.current.stop(); - } else { - webviewRef.current.reload(); - } - } - }; - - const handleUrlChange = (event: React.ChangeEvent) => { - setInputUrl(event.target.value); - }; - - const handleKeyDown = (event: React.KeyboardEvent) => { - if (event.key === "Enter") { - navigateTo(inputUrl); - } - }; - - const handleFocus = (event: React.FocusEvent) => { - event.target.select(); - }; - - const updateRecentUrls = (url: string) => { - if (recentUrls.current[url]) { - recentUrls.current[url]++; - } else { - recentUrls.current[url] = 1; - } - // Clean up old entries after a certain threshold - if (Object.keys(recentUrls.current).length > 50) { - recentUrls.current = {}; - } - }; - - const isRecentUrl = (url: string) => { - return recentUrls.current[url] > 1; - }; - - return ( -
-
-
- - - -
-
- -
-
- -
- ); + return ; }); -export { WebView }; +export { WebView, makeWebViewModel }; diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index af9dcb8ed..7b12ea4b4 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -116,11 +116,12 @@ declare global { type SubjectWithRef = rxjs.Subject & { refCount: number; release: () => void }; - type HeaderElem = HeaderIconButton | HeaderText; + type HeaderElem = HeaderIconButton | HeaderText | HeaderInput | HeaderDiv; type HeaderIconButton = { elemtype: "iconbutton"; icon: string; + className?: string; title?: string; click?: (e: React.MouseEvent) => void; longClick?: (e: React.MouseEvent) => void; @@ -131,6 +132,25 @@ declare global { text: string; }; + type HeaderInput = { + elemtype: "input"; + value: string; + className?: string; + ref?: React.MutableRefObject; + onChange?: (e: React.ChangeEvent) => void; + onKeyDown?: (e: React.KeyboardEvent) => void; + onFocus?: (e: React.FocusEvent) => void; + onBlur?: (e: React.FocusEvent) => void; + }; + + type HeaderDiv = { + elemtype: "div"; + className?: string; + children: HeaderElem[]; + onMouseOver?: (e: React.MouseEvent) => void; + onMouseOut?: (e: React.MouseEvent) => void; + }; + interface ViewModel { viewIcon?: jotai.Atom; viewName?: jotai.Atom; diff --git a/package.json b/package.json index 64cbf49f4..86182f282 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@table-nav/core": "^0.0.7", "@table-nav/react": "^0.0.7", "@tanstack/react-table": "^8.17.3", + "@types/electron": "^1.6.10", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-serialize": "^0.13.0", "@xterm/xterm": "^5.5.0", diff --git a/yarn.lock b/yarn.lock index d24cad4f8..ab26a7fb8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4151,6 +4151,15 @@ __metadata: languageName: node linkType: hard +"@types/electron@npm:^1.6.10": + version: 1.6.10 + resolution: "@types/electron@npm:1.6.10" + dependencies: + electron: "npm:*" + checksum: 10c0/d9d7facf29280dbfcecca287c453c5dc51f3d10cbfd63ea7e78670d37acf51aabc6e5e2ef1fe5f48d67e7862fa9f590bb6ef703901eb62599837a14b4278b0e1 + languageName: node + linkType: hard + "@types/emscripten@npm:^1.39.6": version: 1.39.12 resolution: "@types/emscripten@npm:1.39.12" @@ -6506,6 +6515,19 @@ __metadata: languageName: node linkType: hard +"electron@npm:*": + version: 31.2.0 + resolution: "electron@npm:31.2.0" + dependencies: + "@electron/get": "npm:^2.0.0" + "@types/node": "npm:^20.9.0" + extract-zip: "npm:^2.0.1" + bin: + electron: cli.js + checksum: 10c0/559f94b4d51d4f3dfdaf4fa9a2443834b98c13402f193f5df2e8c10335cb8a2e8b1e6c6eed8499a04be0db28b52d1ddb190217e6122fb9d20cad27c6b42a9f2b + languageName: node + linkType: hard + "electron@npm:^31.1.0": version: 31.1.0 resolution: "electron@npm:31.1.0" @@ -12448,6 +12470,7 @@ __metadata: "@table-nav/core": "npm:^0.0.7" "@table-nav/react": "npm:^0.0.7" "@tanstack/react-table": "npm:^8.17.3" + "@types/electron": "npm:^1.6.10" "@types/node": "npm:^20.12.12" "@types/papaparse": "npm:^5" "@types/react": "npm:^18.3.2"