From 566f6764c25c7e453244b123d193fa3c67c76f6a Mon Sep 17 00:00:00 2001 From: Red J Adaya Date: Thu, 27 Jun 2024 00:39:41 +0800 Subject: [PATCH] Web view (#78) --- emain/emain.ts | 1 + frontend/app/block/block.tsx | 7 +- frontend/app/store/navigate.ts | 39 +++++ frontend/app/view/preview.tsx | 2 + frontend/app/view/webview.less | 45 ++++++ frontend/app/view/webview.tsx | 229 +++++++++++++++++++++++++++ frontend/app/workspace/workspace.tsx | 9 ++ frontend/types/custom.d.ts | 2 + frontend/types/gotypes.d.ts | 4 +- pkg/wconfig/settingsconfig.go | 15 +- 10 files changed, 348 insertions(+), 5 deletions(-) create mode 100644 frontend/app/store/navigate.ts create mode 100644 frontend/app/view/webview.less create mode 100644 frontend/app/view/webview.tsx diff --git a/emain/emain.ts b/emain/emain.ts index c06844296..b0b315979 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -251,6 +251,7 @@ function createBrowserWindow(clientId: string, waveWindow: WaveWindow): WaveBrow : undefined, webPreferences: { preload: path.join(getElectronAppBasePath(), "preload", "index.cjs"), + webviewTag: true, }, show: false, autoHideMenuBar: true, diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index d54fb54e3..e31dbb2b2 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -5,6 +5,7 @@ import { CodeEdit } from "@/app/view/codeedit"; import { PlotView } from "@/app/view/plotview"; import { PreviewView } from "@/app/view/preview"; import { TerminalView } from "@/app/view/term/term"; +import { WebView } from "@/app/view/webview"; import { ErrorBoundary } from "@/element/errorboundary"; import { CenteredDiv } from "@/element/quickelems"; import { ContextMenuModel } from "@/store/contextmenu"; @@ -221,7 +222,6 @@ const BlockFrame_Tech = React.memo( }); let isFocused = jotai.useAtomValue(isFocusedAtom); const blockIcon = useBlockIcon(blockId); - if (preview) { isFocused = true; } @@ -364,6 +364,9 @@ function blockViewToIcon(view: string): string { if (view == "preview") { return "file"; } + if (view == "web") { + return "globe"; + } return null; } @@ -435,6 +438,8 @@ const Block = React.memo(({ blockId, onClose, dragHandleRef }: BlockProps) => { blockElem = ; } else if (blockData.view === "codeedit") { blockElem = ; + } else if (blockData.view === "web") { + blockElem = ; } return ( void> = new Map(); // id -> handler + urls: string[] = []; + + constructor() { + getApi().onNavigate(this.handleNavigate.bind(this)); + getApi().onIframeNavigate(this.handleIframeNavigate.bind(this)); + } + + handleContextMenuClick(e: any, id: string): void { + let handler = this.handlers.get(id); + if (handler) { + handler(); + } + } + + handleNavigate(url: string): void { + console.log("Navigate to", url); + this.urls.push(url); + } + + handleIframeNavigate(url: string): void { + console.log("Iframe navigate to", url); + this.urls.push(url); + } + + getUrls(): string[] { + return this.urls; + } +} + +const NavigateModel = new NavigateModelType(); + +export { NavigateModel, NavigateModelType }; diff --git a/frontend/app/view/preview.tsx b/frontend/app/view/preview.tsx index 953409dc0..34627378b 100644 --- a/frontend/app/view/preview.tsx +++ b/frontend/app/view/preview.tsx @@ -149,6 +149,8 @@ function iconForFile(mimeType: string, fileName: string): string { return "headphones"; } else if (mimeType.startsWith("text/markdown")) { return "file-lines"; + } else if (mimeType == "text/csv") { + return "file-csv"; } else if ( mimeType.startsWith("text/") || (mimeType.startsWith("application/") && diff --git a/frontend/app/view/webview.less b/frontend/app/view/webview.less new file mode 100644 index 000000000..f6c968ada --- /dev/null +++ b/frontend/app/view/webview.less @@ -0,0 +1,45 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.webview-wrapper { + width: 100%; + + .toolbar { + display: flex; + + .navigation { + display: flex; + border: 1px solid var(--border-color); + border-right: none; + border-top-left-radius: 4px; + } + + .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 { + width: 100%; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + } +} diff --git a/frontend/app/view/webview.tsx b/frontend/app/view/webview.tsx new file mode 100644 index 000000000..afc5755fc --- /dev/null +++ b/frontend/app/view/webview.tsx @@ -0,0 +1,229 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Button } from "@/app/element/button"; +import { WebviewTag } from "electron"; +import React, { useEffect, useRef, useState } from "react"; + +import "./webview.less"; + +interface WebViewProps { + parentRef: React.MutableRefObject; + initialUrl: string; +} + +const WebView = ({ parentRef, initialUrl }: WebViewProps) => { + const [url, setUrl] = useState(initialUrl); + const [inputUrl, setInputUrl] = useState(initialUrl); // Separate state for the input field + const [webViewHeight, setWebViewHeight] = useState(0); + + const webviewRef = useRef(null); + const inputRef = useRef(null); + + const historyStack = useRef([]); + const historyIndex = useRef(-1); + + useEffect(() => { + const inputHeight = inputRef.current?.getBoundingClientRect().height + 25; + const parentHeight = parentRef.current?.getBoundingClientRect().height; + setWebViewHeight(parentHeight - inputHeight); + + 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) { + setUrl(newUrl); + setInputUrl(newUrl); // Update input field as well + historyIndex.current += 1; + historyStack.current = historyStack.current.slice(0, historyIndex.current); + historyStack.current.push(newUrl); + } + }; + + if (webview) { + const navigateListener = (event: any) => { + handleNavigation(event.url); + }; + + webview.addEventListener("did-navigate", navigateListener); + webview.addEventListener("did-navigate-in-page", navigateListener); + + // Handle new-window event + webview.addEventListener("new-window", (event: any) => { + event.preventDefault(); + const newUrl = event.url; + webview.src = newUrl; + }); + + // Suppress errors + webview.addEventListener("did-fail-load", (event: any) => { + if (event.errorCode === -3) { + console.log("Suppressed ERR_ABORTED error"); + } else { + console.error(`Failed to load ${event.validatedURL}: ${event.errorDescription}`); + } + }); + + // Clean up event listeners on component unmount + 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("did-fail-load", (event: any) => { + if (event.errorCode === -3) { + console.log("Suppressed ERR_ABORTED error"); + } else { + console.error(`Failed to load ${event.validatedURL}: ${event.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 handleResize = () => { + const parentHeight = parentRef.current?.getBoundingClientRect().height; + setWebViewHeight(parentHeight); + }; + + const parentElement = parentRef.current; + if (parentElement) { + parentElement.addEventListener("keydown", handleKeyDown); + } + window.addEventListener("resize", handleResize); + + return () => { + if (parentElement) { + parentElement.removeEventListener("keydown", handleKeyDown); + } + window.removeEventListener("resize", handleResize); + }; + }, []); + + const ensureUrlScheme = (url: string) => { + if (!/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(url)) { + 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); + } + parsedUrl.pathname = parsedUrl.pathname.replace(/\/+$/, ""); // Remove trailing slashes + parsedUrl.search = ""; // Remove query parameters + return parsedUrl.href; + } catch (e) { + return url.replace(/\/+$/, ""); // Fallback for invalid URLs + } + }; + + const navigateTo = (newUrl: string) => { + const finalUrl = ensureUrlScheme(newUrl); + const normalizedFinalUrl = normalizeUrl(finalUrl); + const normalizedLastUrl = normalizeUrl(historyStack.current[historyIndex.current]); + + if (normalizedLastUrl !== normalizedFinalUrl) { + setUrl(finalUrl); + setInputUrl(finalUrl); + historyIndex.current += 1; + historyStack.current = historyStack.current.slice(0, historyIndex.current); + historyStack.current.push(finalUrl); + if (webviewRef.current) { + webviewRef.current.src = finalUrl; + } + } + }; + + const handleBack = () => { + if (historyIndex.current > 0) { + historyIndex.current -= 1; + const prevUrl = historyStack.current[historyIndex.current]; + setUrl(prevUrl); + setInputUrl(prevUrl); + if (webviewRef.current) { + webviewRef.current.src = prevUrl; + } + } + }; + + const handleForward = () => { + if (historyIndex.current < historyStack.current.length - 1) { + historyIndex.current += 1; + const nextUrl = historyStack.current[historyIndex.current]; + setUrl(nextUrl); + setInputUrl(nextUrl); + if (webviewRef.current) { + webviewRef.current.src = nextUrl; + } + } + }; + + 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(); + }; + + return ( +
+
+
+ + +
+
+ +
+
+ +
+ ); +}; + +export { WebView }; diff --git a/frontend/app/workspace/workspace.tsx b/frontend/app/workspace/workspace.tsx index b1008f9cc..a0cab87ee 100644 --- a/frontend/app/workspace/workspace.tsx +++ b/frontend/app/workspace/workspace.tsx @@ -34,6 +34,15 @@ const Widgets = React.memo(() => { }; createBlock(editDef); } + async function clickWeb() { + const editDef: BlockDef = { + view: "web", + meta: { + url: "https://waveterm.dev/", + }, + }; + createBlock(editDef); + } async function handleWidgetSelect(blockDef: BlockDef) { createBlock(blockDef); } diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 0130794dd..5df036a07 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -34,6 +34,8 @@ declare global { showContextMenu: (menu: ElectronContextMenuItem[], position: { x: number; y: number }) => void; onContextMenuClick: (callback: (id: string) => void) => void; + onNavigate: (callback: (url: string) => void) => void; + onIframeNavigate: (callback: (url: string) => void) => void; }; type ElectronContextMenuItem = { diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index fc1d1b09e..e31ac039d 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -61,7 +61,7 @@ declare global { // wshutil.BlockInputCommand type BlockInputCommand = { - blockid: string; + blockid?: string; command: "controller:input"; inputdata64?: string; signame?: string; @@ -380,4 +380,4 @@ declare global { } -export {} +export {} \ No newline at end of file diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index bf3fa3c3b..11f74e26b 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -81,21 +81,32 @@ func getSettingsConfigDefaults() SettingsConfigType { "text/rust": {Icon: "rust fa-brands"}, "text/scss": {Icon: "sass fa-brands"}, "video": {Icon: "file-video"}, + "text/csv": {Icon: "file-csv"}, }, Widgets: []WidgetsConfigType{ { - Icon: "files", + Icon: "files", + Label: "files", BlockDef: wstore.BlockDef{ View: "preview", Meta: map[string]any{"file": wavebase.GetHomeDir()}, }, }, { - Icon: "chart-simple", + Icon: "chart-simple", + Label: "chart", BlockDef: wstore.BlockDef{ View: "plot", }, }, + { + Icon: "globe", + Label: "web", + BlockDef: wstore.BlockDef{ + View: "web", + Meta: map[string]any{"url": "https://waveterm.dev/"}, + }, + }, }, } }