webview controls (#110)

This commit is contained in:
Red J Adaya 2024-07-16 00:40:28 +08:00 committed by GitHub
parent e012cc16e6
commit e140076801
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 522 additions and 324 deletions

View File

@ -117,6 +117,77 @@
opacity: 0.7; 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 { .block-frame-end-icons {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -14,7 +14,7 @@ import { PlotView } from "@/view/plotview";
import { PreviewView, makePreviewModel } from "@/view/preview"; import { PreviewView, makePreviewModel } from "@/view/preview";
import { TerminalView } from "@/view/term/term"; import { TerminalView } from "@/view/term/term";
import { WaveAi } from "@/view/waveai"; import { WaveAi } from "@/view/waveai";
import { WebView } from "@/view/webview"; import { WebView, makeWebViewModel } from "@/view/webview";
import clsx from "clsx"; import clsx from "clsx";
import * as jotai from "jotai"; import * as jotai from "jotai";
import * as React from "react"; 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 (
<div className="input-wrapper">
<input
ref={ref}
className={className}
value={value}
onChange={(e) => onChange(e)}
onKeyDown={(e) => onKeyDown(e)}
onFocus={(e) => onFocus(e)}
onBlur={(e) => onBlur(e)}
/>
</div>
);
});
const BlockFrame_Default_Component = ({ const BlockFrame_Default_Component = ({
blockId, blockId,
layoutModel, layoutModel,
@ -289,7 +306,54 @@ const BlockFrame_Default_Component = ({
endIconsElem.push( endIconsElem.push(
<IconButton key="close" decl={closeDecl} className="block-frame-endicon-button block-frame-default-close" /> <IconButton key="close" decl={closeDecl} className="block-frame-endicon-button block-frame-default-close" />
); );
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 (
<IconButton
key={key}
decl={elem}
className={clsx("block-frame-header-iconbutton", elem.className)}
/>
);
} else if (elem.elemtype == "input") {
return <Input key={key} decl={elem} className={clsx("block-frame-input", elem.className)} />;
} else if (elem.elemtype == "text") {
return (
<div key={key} className="block-frame-text">
{elem.text}
</div>
);
} else if (elem.elemtype == "div") {
return (
<div
key={key}
className={clsx("block-frame-div", elem.className)}
onMouseOver={elem.onMouseOver}
onMouseOut={elem.onMouseOut}
>
{elem.children.map((child, childIdx) => renderElement(child, childIdx))}
</div>
);
}
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 (typeof headerTextUnion === "string") {
if (!util.isBlank(headerTextUnion)) { if (!util.isBlank(headerTextUnion)) {
headerTextElems.push( headerTextElems.push(
@ -299,19 +363,9 @@ const BlockFrame_Default_Component = ({
); );
} }
} else if (Array.isArray(headerTextUnion)) { } else if (Array.isArray(headerTextUnion)) {
for (let idx = 0; idx < headerTextUnion.length; idx++) { headerTextElems.push(...renderHeaderElements(headerTextUnion));
const elem = headerTextUnion[idx];
if (elem.elemtype == "iconbutton") {
headerTextElems.push(<IconButton key={idx} decl={elem} className="block-frame-header-iconbutton" />);
} else if (elem.elemtype == "text") {
headerTextElems.push(
<div key={idx} className="block-frame-text">
{elem.text}
</div>
);
}
}
} }
return ( return (
<div <div
className={clsx( className={clsx(
@ -341,8 +395,7 @@ const BlockFrame_Default_Component = ({
<div className="block-frame-blockid">[{blockId.substring(0, 8)}]</div> <div className="block-frame-blockid">[{blockId.substring(0, 8)}]</div>
)} )}
</div> </div>
{headerTextElems} <div className="block-frame-textelems-wrapper">{headerTextElems}</div>
<div className="flex-spacer"></div>
<div className="block-frame-end-icons">{endIconsElem}</div> <div className="block-frame-end-icons">{endIconsElem}</div>
</div> </div>
{preview ? <div className="block-frame-preview" /> : children} {preview ? <div className="block-frame-preview" /> : children}
@ -445,7 +498,9 @@ function getViewElemAndModel(
} else if (blockView === "codeedit") { } else if (blockView === "codeedit") {
viewElem = <CodeEdit key={blockId} text={null} filename={null} />; viewElem = <CodeEdit key={blockId} text={null} filename={null} />;
} else if (blockView === "web") { } else if (blockView === "web") {
viewElem = <WebView key={blockId} blockId={blockId} parentRef={blockRef} />; const webviewModel = makeWebViewModel(blockId);
viewElem = <WebView key={blockId} parentRef={blockRef} model={webviewModel} />;
viewModel = webviewModel;
} else if (blockView === "waveai") { } else if (blockView === "waveai") {
viewElem = <WaveAi key={blockId} />; viewElem = <WaveAi key={blockId} />;
} }
@ -559,9 +614,12 @@ const BlockFull = React.memo(({ blockId, layoutModel }: BlockProps) => {
if (focusableChildren.length == 0) { if (focusableChildren.length == 0) {
focusElemRef.current.focus({ preventScroll: true }); focusElemRef.current.focus({ preventScroll: true });
} else { } 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( let { viewElem, viewModel } = React.useMemo(
() => getViewElemAndModel(blockId, blockData?.view, blockRef), () => getViewElemAndModel(blockId, blockData?.view, blockRef),

View File

@ -25,8 +25,8 @@ export const useLongClick = (ref, onClick, onLongClick, ms = 300) => {
const handleClick = useCallback( const handleClick = useCallback(
(e: React.MouseEvent<any>) => { (e: React.MouseEvent<any>) => {
if (longClickTriggered) { if (longClickTriggered) {
event.preventDefault(); e.preventDefault();
event.stopPropagation(); e.stopPropagation();
return; return;
} }
onClick?.(e); onClick?.(e);

View File

@ -1,62 +1,9 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
.webview-wrapper { .webview {
height: 100%;
width: 100%; width: 100%;
display: flex; border-bottom-left-radius: 4px;
flex-direction: column; border-bottom-right-radius: 4px;
.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;
}
} }

View File

@ -1,96 +1,336 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { Button } from "@/app/element/button";
import { getApi } from "@/app/store/global"; 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 * as services from "@/store/services";
import { WebviewTag } from "electron"; import { WebviewTag } from "electron";
import * as jotai from "jotai"; import * as jotai from "jotai";
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
import React, { memo, useEffect } from "react";
import "./webview.less"; import "./webview.less";
interface WebViewProps { export class WebViewModel implements ViewModel {
blockId: string; blockId: string;
parentRef: React.MutableRefObject<HTMLDivElement>; blockAtom: jotai.Atom<Block>;
} viewIcon: jotai.Atom<string | HeaderIconButton>;
viewName: jotai.Atom<string>;
viewText: jotai.Atom<HeaderElem[]>;
preIconButton: jotai.Atom<HeaderIconButton>;
endIconButtons: jotai.Atom<HeaderIconButton[]>;
url: jotai.PrimitiveAtom<string>;
urlInput: jotai.PrimitiveAtom<string>;
urlInputFocused: jotai.PrimitiveAtom<boolean>;
isLoading: jotai.PrimitiveAtom<boolean>;
urlWrapperClassName: jotai.PrimitiveAtom<string>;
refreshIcon: jotai.PrimitiveAtom<string>;
webviewRef: React.RefObject<WebviewTag>;
urlInputRef: React.RefObject<HTMLInputElement>;
historyStack: string[];
historyIndex: number;
recentUrls: { [key: string]: number };
function setBlockUrl(blockId: string, url: string) { constructor(blockId: string) {
services.ObjectService.UpdateObjectMeta(WOS.makeORef("block", blockId), { url: url }); this.blockId = blockId;
} this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`);
const WebView = memo(({ blockId, parentRef }: WebViewProps) => { this.url = jotai.atom("");
const blockAtom = WOS.getWaveObjectAtom<Block>(WOS.makeORef("block", blockId)); this.urlInput = jotai.atom("");
const blockData = WOS.useWaveObjectValueWithSuspense<Block>(WOS.makeORef("block", blockId)); this.urlWrapperClassName = jotai.atom("");
const urlAtom = useBlockAtom<string>(blockId, "webview:url", () => { this.urlInputFocused = jotai.atom(false);
return jotai.atom((get) => { this.isLoading = jotai.atom(false);
const blockData = get(blockAtom); this.refreshIcon = jotai.atom("rotate-right");
return blockData?.meta?.url; 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<WebviewTag>(null); this.viewName = jotai.atom("Web");
const inputRef = useRef<HTMLInputElement>(null); this.urlInputRef = React.createRef<HTMLInputElement>();
const historyStack = useRef<string[]>([]); this.webviewRef = React.createRef<WebviewTag>();
const historyIndex = useRef<number>(-1);
const recentUrls = useRef<{ [key: string]: number }>({});
useEffect(() => { this.viewText = jotai.atom((get) => {
if (realUrl !== lastRealUrl) { let url = get(this.blockAtom)?.meta?.url || "";
setLastRealUrl(realUrl); if (url && this.historyStack.length === 0) {
setInputUrl(realUrl); this.addToHistoryStack(url);
}
}, [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);
} }
}; 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<HTMLDivElement, MouseEvent>) {
const urlInputFocused = globalStore.get(this.urlInputFocused);
if (e.type === "mouseover" && !urlInputFocused) {
globalStore.set(this.urlWrapperClassName, "hovered");
}
}
handleUrlWrapperMouseOut(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
const urlInputFocused = globalStore.get(this.urlInputFocused);
if (e.type === "mouseout" && !urlInputFocused) {
globalStore.set(this.urlWrapperClassName, "");
}
}
handleBack(e: React.MouseEvent<HTMLDivElement, 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<HTMLDivElement, 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<HTMLDivElement, 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<HTMLInputElement>) {
globalStore.set(this.url, event.target.value);
}
handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
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<HTMLInputElement>) {
globalStore.set(this.urlWrapperClassName, "focused");
globalStore.set(this.urlInputFocused, true);
this.urlInputRef.current.focus();
event.target.select();
}
handleBlur(event: React.FocusEvent<HTMLInputElement>) {
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<HTMLDivElement>;
model: WebViewModel;
}
const WebView = memo(({ parentRef, model }: WebViewProps) => {
const url = model.getUrl();
useEffect(() => {
const webview = model.webviewRef.current;
if (webview) { if (webview) {
const navigateListener = (event: any) => { const navigateListener = (e: any) => {
handleNavigation(event.url); model.navigateTo(e.url);
}; };
webview.addEventListener("did-navigate", navigateListener); webview.addEventListener("did-navigate", (e) => {
webview.addEventListener("did-navigate-in-page", navigateListener); console.log("did-navigate");
webview.addEventListener("did-start-loading", () => setIsLoading(true)); navigateListener(e);
webview.addEventListener("did-stop-loading", () => setIsLoading(false)); });
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 // Handle new-window event
webview.addEventListener("new-window", (event: any) => { webview.addEventListener("new-window", (e: any) => {
event.preventDefault(); e.preventDefault();
const newUrl = event.detail.url; const newUrl = e.detail.url;
getApi().openExternal(newUrl); getApi().openExternal(newUrl);
}); });
// Suppress errors // Suppress errors
webview.addEventListener("did-fail-load", (event: any) => { webview.addEventListener("did-fail-load", (e: any) => {
if (event.errorCode === -3) { if (e.errorCode === -3) {
console.log("Suppressed ERR_ABORTED error"); e.log("Suppressed ERR_ABORTED error");
} else { } 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 () => { return () => {
webview.removeEventListener("did-navigate", navigateListener); webview.removeEventListener("did-navigate", navigateListener);
webview.removeEventListener("did-navigate-in-page", navigateListener); webview.removeEventListener("did-navigate-in-page", navigateListener);
webview.removeEventListener("new-window", (event: any) => { webview.removeEventListener("new-window", (e: any) => {
webview.src = event.url; model.navigateTo(e.url);
}); });
webview.removeEventListener("did-fail-load", (event: any) => { webview.removeEventListener("did-fail-load", (e: any) => {
if (event.errorCode === -3) { if (e.errorCode === -3) {
console.log("Suppressed ERR_ABORTED error"); console.log("Suppressed ERR_ABORTED error");
} else { } else {
console.error(`Failed to load ${event.validatedURL}: ${event.errorDescription}`); console.error(`Failed to load ${e.validatedURL}: ${e.errorDescription}`);
} }
}); });
}; };
} }
}, [initialUrl]); }, []);
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === "l") { if ((e.ctrlKey || e.metaKey) && e.key === "l") {
event.preventDefault(); e.preventDefault();
if (inputRef.current) { if (model.urlInputRef) {
inputRef.current.focus(); model.urlInputRef.current.focus();
inputRef.current.select(); model.urlInputRef.current.select();
} }
} else if ((event.ctrlKey || event.metaKey) && event.key === "r") { } else if ((e.ctrlKey || e.metaKey) && e.key === "r") {
event.preventDefault(); e.preventDefault();
if (webviewRef.current) { if (model.webviewRef.current) {
webviewRef.current.reload(); model.webviewRef.current.reload();
} }
} }
}; };
@ -140,169 +380,7 @@ const WebView = memo(({ blockId, parentRef }: WebViewProps) => {
}; };
}, [parentRef]); }, [parentRef]);
const ensureUrlScheme = (url: string) => { return <webview id="webview" className="webview" ref={model.webviewRef} src={url}></webview>;
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<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();
};
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 (
<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>
<Button onClick={handleRefresh} className="secondary ghost refresh">
<i className={`fa-sharp fa-regular ${isLoading ? "fa-xmark" : "fa-rotate-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 id="webview" className="webview" ref={webviewRef} src={realUrl}></webview>
</div>
);
}); });
export { WebView }; export { WebView, makeWebViewModel };

View File

@ -116,11 +116,12 @@ declare global {
type SubjectWithRef<T> = rxjs.Subject<T> & { refCount: number; release: () => void }; type SubjectWithRef<T> = rxjs.Subject<T> & { refCount: number; release: () => void };
type HeaderElem = HeaderIconButton | HeaderText; type HeaderElem = HeaderIconButton | HeaderText | HeaderInput | HeaderDiv;
type HeaderIconButton = { type HeaderIconButton = {
elemtype: "iconbutton"; elemtype: "iconbutton";
icon: string; icon: string;
className?: string;
title?: string; title?: string;
click?: (e: React.MouseEvent<any>) => void; click?: (e: React.MouseEvent<any>) => void;
longClick?: (e: React.MouseEvent<any>) => void; longClick?: (e: React.MouseEvent<any>) => void;
@ -131,6 +132,25 @@ declare global {
text: string; text: string;
}; };
type HeaderInput = {
elemtype: "input";
value: string;
className?: string;
ref?: React.MutableRefObject<HTMLInputElement>;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
};
type HeaderDiv = {
elemtype: "div";
className?: string;
children: HeaderElem[];
onMouseOver?: (e: React.MouseEvent<any>) => void;
onMouseOut?: (e: React.MouseEvent<any>) => void;
};
interface ViewModel { interface ViewModel {
viewIcon?: jotai.Atom<string | HeaderIconButton>; viewIcon?: jotai.Atom<string | HeaderIconButton>;
viewName?: jotai.Atom<string>; viewName?: jotai.Atom<string>;

View File

@ -58,6 +58,7 @@
"@table-nav/core": "^0.0.7", "@table-nav/core": "^0.0.7",
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-table": "^8.17.3", "@tanstack/react-table": "^8.17.3",
"@types/electron": "^1.6.10",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-serialize": "^0.13.0", "@xterm/addon-serialize": "^0.13.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",

View File

@ -4151,6 +4151,15 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@types/emscripten@npm:^1.39.6":
version: 1.39.12 version: 1.39.12
resolution: "@types/emscripten@npm:1.39.12" resolution: "@types/emscripten@npm:1.39.12"
@ -6506,6 +6515,19 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "electron@npm:^31.1.0":
version: 31.1.0 version: 31.1.0
resolution: "electron@npm:31.1.0" resolution: "electron@npm:31.1.0"
@ -12448,6 +12470,7 @@ __metadata:
"@table-nav/core": "npm:^0.0.7" "@table-nav/core": "npm:^0.0.7"
"@table-nav/react": "npm:^0.0.7" "@table-nav/react": "npm:^0.0.7"
"@tanstack/react-table": "npm:^8.17.3" "@tanstack/react-table": "npm:^8.17.3"
"@types/electron": "npm:^1.6.10"
"@types/node": "npm:^20.12.12" "@types/node": "npm:^20.12.12"
"@types/papaparse": "npm:^5" "@types/papaparse": "npm:^5"
"@types/react": "npm:^18.3.2" "@types/react": "npm:^18.3.2"