// Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Button } from "@/app/element/button"; import { useParentHeight } from "@/app/hook/useParentHeight"; import { getApi } from "@/app/store/global"; import { WOS } from "@/store/global"; import { WebviewTag } from "electron"; import React, { memo, useEffect, useMemo, useRef, useState } from "react"; import "./webview.less"; interface WebViewProps { blockId: string; parentRef: React.MutableRefObject; } const WebView = memo(({ blockId, parentRef }: WebViewProps) => { const blockData = WOS.useWaveObjectValueWithSuspense(WOS.makeORef("block", blockId)); const initialUrl = useMemo(() => blockData?.meta?.url, []); const [url, setUrl] = useState(initialUrl); const [inputUrl, setInputUrl] = useState(initialUrl); // 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 }>({}); const parentHeight = useParentHeight(parentRef); const inputHeight = inputRef.current?.getBoundingClientRect().height || 0; const webViewHeight = parentHeight - (inputHeight + 35); 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) { setUrl(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); } }; if (webview) { const navigateListener = (event: any) => { handleNavigation(event.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)); // Handle new-window event webview.addEventListener("new-window", (event: any) => { event.preventDefault(); const newUrl = event.detail.url; getApi().openExternal(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(() => { // setWebViewHeight(getWebViewHeight()); // }, [parentHeight, getWebViewHeight]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if ((event.ctrlKey || event.metaKey) && event.key === "l") { event.preventDefault(); if (inputRef.current) { inputRef.current.focus(); inputRef.current.select(); } } else if ((event.ctrlKey || event.metaKey) && event.key === "r") { event.preventDefault(); if (webviewRef.current) { webviewRef.current.reload(); } } }; const parentElement = parentRef.current; if (parentElement) { parentElement.addEventListener("keydown", handleKeyDown); } return () => { if (parentElement) { parentElement.removeEventListener("keydown", handleKeyDown); } }; }, [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) { setUrl(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]; setUrl(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]; setUrl(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 (
); }); export { WebView };