mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-31 23:11:28 +01:00
Web view (#78)
This commit is contained in:
parent
4f627a0342
commit
566f6764c2
@ -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,
|
||||
|
@ -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 = <PlotView key={blockId} />;
|
||||
} else if (blockData.view === "codeedit") {
|
||||
blockElem = <CodeEdit key={blockId} text={null} filename={null} />;
|
||||
} else if (blockData.view === "web") {
|
||||
blockElem = <WebView key={blockId} parentRef={blockRef} initialUrl={blockData.meta.url} />;
|
||||
}
|
||||
return (
|
||||
<BlockFrame
|
||||
|
39
frontend/app/store/navigate.ts
Normal file
39
frontend/app/store/navigate.ts
Normal file
@ -0,0 +1,39 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { getApi } from "./global";
|
||||
|
||||
class NavigateModelType {
|
||||
handlers: Map<string, () => 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 };
|
@ -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/") &&
|
||||
|
45
frontend/app/view/webview.less
Normal file
45
frontend/app/view/webview.less
Normal file
@ -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;
|
||||
}
|
||||
}
|
229
frontend/app/view/webview.tsx
Normal file
229
frontend/app/view/webview.tsx
Normal file
@ -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<HTMLDivElement>;
|
||||
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<WebviewTag>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const historyStack = useRef<string[]>([]);
|
||||
const historyIndex = useRef<number>(-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<HTMLInputElement>) => {
|
||||
setInputUrl(event.target.value);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
navigateTo(inputUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
|
||||
event.target.select();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="webview-wrapper">
|
||||
<div className="toolbar">
|
||||
<div className="navigation">
|
||||
<Button className="secondary ghost back" onClick={handleBack} disabled={historyIndex.current <= 0}>
|
||||
<i className="fa-sharp fa-regular fa-arrow-left"></i>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleForward}
|
||||
className="secondary ghost forward"
|
||||
disabled={historyIndex.current >= historyStack.current.length - 1}
|
||||
>
|
||||
<i className="fa-sharp fa-regular fa-arrow-right"></i>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="url-input-wrapper">
|
||||
<input
|
||||
className="url-input"
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputUrl}
|
||||
onChange={handleUrlChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<webview className="webview" ref={webviewRef} src={url} style={{ height: webViewHeight }}></webview>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { WebView };
|
@ -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);
|
||||
}
|
||||
|
2
frontend/types/custom.d.ts
vendored
2
frontend/types/custom.d.ts
vendored
@ -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 = {
|
||||
|
4
frontend/types/gotypes.d.ts
vendored
4
frontend/types/gotypes.d.ts
vendored
@ -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 {}
|
@ -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/"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user