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"