2024-06-27 00:39:41 +08:00
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
2024-08-29 16:06:15 -07:00
import { getApi , openLink } from "@/app/store/global" ;
import { getSimpleControlShiftAtom } from "@/app/store/keymodel" ;
import { NodeModel } from "@/layout/index" ;
import { WOS , globalStore } from "@/store/global" ;
2024-07-09 11:42:24 -07:00
import * as services from "@/store/services" ;
2024-08-29 16:06:15 -07:00
import { adaptFromReactOrNativeKeyEvent , checkKeyPressed } from "@/util/keyutil" ;
2024-08-29 13:37:05 -07:00
import { fireAndForget } from "@/util/util" ;
2024-07-18 14:41:33 +08:00
import clsx from "clsx" ;
2024-06-27 00:39:41 +08:00
import { WebviewTag } from "electron" ;
2024-07-09 11:42:24 -07:00
import * as jotai from "jotai" ;
2024-08-29 13:37:05 -07:00
import React , { memo , useEffect , useState } from "react" ;
2024-09-11 03:16:34 +08:00
import { debounce } from "throttle-debounce" ;
2024-06-27 00:39:41 +08:00
import "./webview.less" ;
2024-07-16 00:40:28 +08:00
export class WebViewModel implements ViewModel {
2024-08-22 16:25:53 -07:00
viewType : string ;
2024-07-08 15:04:48 -07:00
blockId : string ;
2024-07-16 00:40:28 +08:00
blockAtom : jotai.Atom < Block >;
2024-09-09 12:35:53 -07:00
viewIcon : jotai.Atom < string | IconButtonDecl >;
2024-07-16 00:40:28 +08:00
viewName : jotai.Atom < string >;
viewText : jotai.Atom < HeaderElem [] >;
url : 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 >;
2024-08-29 16:06:15 -07:00
nodeModel : NodeModel ;
2024-06-27 00:39:41 +08:00
2024-08-29 16:06:15 -07:00
constructor ( blockId : string , nodeModel : NodeModel ) {
this . nodeModel = nodeModel ;
2024-08-22 16:25:53 -07:00
this . viewType = "web" ;
2024-07-16 00:40:28 +08:00
this . blockId = blockId ;
this . blockAtom = WOS . getWaveObjectAtom < Block >( `block: ${ blockId } ` );
2024-07-09 11:42:24 -07:00
2024-09-10 14:46:59 -07:00
this . url = jotai . atom ();
2024-07-16 00:40:28 +08:00
this . urlWrapperClassName = jotai . atom ( "" );
this . urlInputFocused = jotai . atom ( false );
this . isLoading = jotai . atom ( false );
this . refreshIcon = jotai . atom ( "rotate-right" );
2024-08-29 13:37:05 -07:00
this . viewIcon = jotai . atom ( "globe" );
2024-07-16 00:40:28 +08:00
this . viewName = jotai . atom ( "Web" );
this . urlInputRef = React . createRef < HTMLInputElement >();
this . webviewRef = React . createRef < WebviewTag >();
2024-06-27 00:39:41 +08:00
2024-07-16 00:40:28 +08:00
this . viewText = jotai . atom (( get ) => {
let url = get ( this . blockAtom ) ? . meta ? . url || "" ;
2024-09-10 14:59:34 -07:00
const currUrl = get ( this . url );
if ( currUrl !== undefined ) {
2024-07-16 00:40:28 +08:00
url = currUrl ;
}
return [
{
elemtype : "iconbutton" ,
icon : "chevron-left" ,
click : this.handleBack.bind ( this ),
2024-09-03 11:24:45 -07:00
disabled : this.shouldDisabledBackButton (),
2024-07-16 00:40:28 +08:00
},
{
elemtype : "iconbutton" ,
icon : "chevron-right" ,
click : this.handleForward.bind ( this ),
2024-09-03 11:24:45 -07:00
disabled : this.shouldDisabledForwardButton (),
2024-07-16 00:40:28 +08:00
},
{
elemtype : "div" ,
2024-07-18 14:41:33 +08:00
className : clsx ( "block-frame-div-url" , get ( this . urlWrapperClassName )),
2024-07-16 00:40:28 +08:00
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 [];
});
}
2024-08-29 13:37:05 -07:00
/**
* Whether the back button in the header should be disabled.
* @returns True if the WebView cannot go back or if the WebView call fails. False otherwise.
*/
2024-07-16 00:40:28 +08:00
shouldDisabledBackButton() {
2024-08-29 13:37:05 -07:00
try {
return ! this . webviewRef . current ? . canGoBack ();
} catch ( _ ) {}
return true ;
2024-07-16 00:40:28 +08:00
}
2024-08-29 13:37:05 -07:00
/**
* Whether the forward button in the header should be disabled.
* @returns True if the WebView cannot go forward or if the WebView call fails. False otherwise.
*/
2024-07-16 00:40:28 +08:00
shouldDisabledForwardButton() {
2024-08-29 13:37:05 -07:00
try {
return ! this . webviewRef . current ? . canGoForward ();
} catch ( _ ) {}
return true ;
2024-07-16 00:40:28 +08:00
}
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 , "" );
}
}
2024-08-29 16:06:15 -07:00
handleBack ( e? : React.MouseEvent < HTMLDivElement , MouseEvent >) {
if ( e ) {
e . preventDefault ();
e . stopPropagation ();
}
2024-08-29 13:37:05 -07:00
this . webviewRef . current ? . goBack ();
2024-07-16 00:40:28 +08:00
}
2024-08-29 16:06:15 -07:00
handleForward ( e? : React.MouseEvent < HTMLDivElement , MouseEvent >) {
if ( e ) {
e . preventDefault ();
e . stopPropagation ();
}
2024-08-29 13:37:05 -07:00
this . webviewRef . current ? . goForward ();
2024-07-16 00:40:28 +08:00
}
handleRefresh ( e : React.MouseEvent < HTMLDivElement , MouseEvent >) {
e . preventDefault ();
e . stopPropagation ();
2024-08-29 13:37:05 -07:00
try {
if ( this . webviewRef . current ) {
if ( globalStore . get ( this . isLoading )) {
this . webviewRef . current . stop ();
} else {
this . webviewRef . current . reload ();
}
2024-07-16 00:40:28 +08:00
}
2024-08-29 13:37:05 -07:00
} catch ( e ) {
console . warn ( "handleRefresh catch" , e );
2024-07-16 00:40:28 +08:00
}
}
handleUrlChange ( event : React.ChangeEvent < HTMLInputElement >) {
globalStore . set ( this . url , event . target . value );
}
handleKeyDown ( event : React.KeyboardEvent < HTMLInputElement >) {
2024-08-29 16:06:15 -07:00
const waveEvent = adaptFromReactOrNativeKeyEvent ( event );
if ( checkKeyPressed ( waveEvent , "Enter" )) {
2024-08-29 13:37:05 -07:00
const url = globalStore . get ( this . url );
2024-09-07 18:22:13 -07:00
this . loadUrl ( url , "enter" );
2024-07-16 00:40:28 +08:00
this . urlInputRef . current ? . blur ();
2024-08-29 16:06:15 -07:00
return ;
}
if ( checkKeyPressed ( waveEvent , "Escape" )) {
this . webviewRef . current ? . focus ();
2024-07-16 00:40:28 +08:00
}
}
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 );
}
2024-08-29 13:37:05 -07:00
/**
* Update the URL in the state when a navigation event has occurred.
* @param url The URL that has been navigated to.
*/
handleNavigate ( url : string ) {
services . ObjectService . UpdateObjectMeta ( WOS . makeORef ( "block" , this . blockId ), { url });
globalStore . set ( this . url , url );
}
2024-07-16 00:40:28 +08:00
ensureUrlScheme ( url : string ) {
2024-08-09 02:58:22 +08:00
if ( /^(http|https):/ . test ( url )) {
// If the URL starts with http: or https:, return it as is
return url ;
}
// Check if the URL looks like a local URL
const isLocal = /^(localhost|(\d{1,3}\.){3}\d{1,3})(:\d+)?$/ . test ( url . split ( "/" )[ 0 ]);
if ( isLocal ) {
// If it is a local URL, ensure it has http:// scheme
2024-07-16 00:40:28 +08:00
return `http:// ${ url } ` ;
2024-08-09 02:58:22 +08:00
}
// Check if the URL looks like a domain
const domainRegex = /^[a-z0-9.-]+\.[a-z]{2,}$/i ;
const isDomain = domainRegex . test ( url . split ( "/" )[ 0 ]);
if ( isDomain ) {
// If it looks like a domain, ensure it has https:// scheme
2024-07-16 00:40:28 +08:00
return `https:// ${ url } ` ;
}
2024-08-09 02:58:22 +08:00
// Otherwise, treat it as a search query
return `https://www.google.com/search?q= ${ encodeURIComponent ( url ) } ` ;
2024-07-16 00:40:28 +08:00
}
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 ( /\/+$/ , "" ) + "/" ;
}
}
2024-08-29 13:37:05 -07:00
/**
* Load a new URL in the webview.
* @param newUrl The new URL to load in the webview.
*/
2024-09-07 18:22:13 -07:00
loadUrl ( newUrl : string , reason : string ) {
2024-08-29 13:37:05 -07:00
const nextUrl = this . ensureUrlScheme ( newUrl );
2024-09-07 18:22:13 -07:00
console . log ( "webview loadUrl" , reason , nextUrl , "cur=" , this . webviewRef ? . current . getURL ());
if ( newUrl != nextUrl ) {
globalStore . set ( this . url , nextUrl );
}
if ( ! this . webviewRef . current ) {
return ;
}
if ( this . webviewRef . current . getURL () != nextUrl ) {
this . webviewRef . current . loadURL ( nextUrl );
2024-07-16 00:40:28 +08:00
}
}
2024-08-29 13:37:05 -07:00
/**
* Get the current URL from the state.
* @returns The URL from the state.
*/
getUrl() {
return globalStore . get ( this . url );
2024-07-16 00:40:28 +08:00
}
setRefreshIcon ( refreshIcon : string ) {
globalStore . set ( this . refreshIcon , refreshIcon );
}
setIsLoading ( isLoading : boolean ) {
globalStore . set ( this . isLoading , isLoading );
}
2024-07-22 16:41:18 -07:00
giveFocus () : boolean {
2024-08-29 16:06:15 -07:00
const ctrlShiftState = globalStore . get ( getSimpleControlShiftAtom ());
if ( ctrlShiftState ) {
// this is really weird, we don't get keyup events from webview
const unsubFn = globalStore . sub ( getSimpleControlShiftAtom (), () => {
const state = globalStore . get ( getSimpleControlShiftAtom ());
if ( ! state ) {
unsubFn ();
const isStillFocused = globalStore . get ( this . nodeModel . isFocused );
if ( isStillFocused ) {
this . webviewRef . current ? . focus ();
}
}
});
return false ;
2024-07-22 16:41:18 -07:00
}
2024-08-29 16:06:15 -07:00
this . webviewRef . current ? . focus ();
return true ;
2024-07-22 16:41:18 -07:00
}
2024-08-21 15:49:23 -07:00
keyDownHandler ( e : WaveKeyboardEvent ) : boolean {
if ( checkKeyPressed ( e , "Cmd:l" )) {
this . urlInputRef ? . current ? . focus ();
this . urlInputRef ? . current ? . select ();
return true ;
}
if ( checkKeyPressed ( e , "Cmd:r" )) {
this . webviewRef ? . current ? . reload ();
return true ;
}
2024-08-29 16:06:15 -07:00
if ( checkKeyPressed ( e , "Cmd:ArrowLeft" )) {
this . handleBack ( null );
return true ;
}
if ( checkKeyPressed ( e , "Cmd:ArrowRight" )) {
this . handleForward ( null );
return true ;
}
2024-08-21 15:49:23 -07:00
return false ;
}
2024-09-09 15:41:26 -07:00
getSettingsMenuItems() {
return [
{
label : this.webviewRef.current?.isDevToolsOpened () ? "Close DevTools" : "Open DevTools" ,
click : async () => {
if ( this . webviewRef . current ) {
if ( this . webviewRef . current . isDevToolsOpened ()) {
this . webviewRef . current . closeDevTools ();
} else {
this . webviewRef . current . openDevTools ();
}
}
},
},
];
}
2024-07-16 00:40:28 +08:00
}
2024-08-29 16:06:15 -07:00
function makeWebViewModel ( blockId : string , nodeModel : NodeModel ) : WebViewModel {
const webviewModel = new WebViewModel ( blockId , nodeModel );
2024-07-16 00:40:28 +08:00
return webviewModel ;
}
interface WebViewProps {
2024-08-21 15:49:23 -07:00
blockId : string ;
2024-07-16 00:40:28 +08:00
model : WebViewModel ;
}
2024-08-21 15:49:23 -07:00
const WebView = memo (({ model } : WebViewProps ) => {
2024-08-01 00:35:44 -07:00
const blockData = jotai . useAtomValue ( model . blockAtom );
const metaUrl = blockData ? . meta ? . url ;
const metaUrlRef = React . useRef ( metaUrl );
2024-08-29 13:37:05 -07:00
// The initial value of the block metadata URL when the component first renders. Used to set the starting src value for the webview.
const [ metaUrlInitial ] = useState ( metaUrl );
2024-09-10 12:50:55 -07:00
const [ webContentsId , setWebContentsId ] = useState ( null );
const [ domReady , setDomReady ] = useState ( false );
useEffect (() => {
if ( model . webviewRef . current && domReady ) {
const wcId = model . webviewRef . current . getWebContentsId ? .();
if ( wcId ) {
setWebContentsId ( wcId );
}
}
}, [ model . webviewRef . current , domReady ]);
2024-08-29 13:37:05 -07:00
// Load a new URL if the block metadata is updated.
2024-08-01 00:35:44 -07:00
useEffect (() => {
if ( metaUrlRef . current != metaUrl ) {
metaUrlRef . current = metaUrl ;
2024-09-07 18:22:13 -07:00
model . loadUrl ( metaUrl , "meta" );
2024-08-01 00:35:44 -07:00
}
}, [ metaUrl ]);
2024-07-16 00:40:28 +08:00
useEffect (() => {
const webview = model . webviewRef . current ;
2024-06-27 00:39:41 +08:00
if ( webview ) {
2024-07-16 00:40:28 +08:00
const navigateListener = ( e : any ) => {
2024-08-29 13:37:05 -07:00
model . handleNavigate ( e . url );
2024-06-27 00:39:41 +08:00
};
2024-08-29 13:37:05 -07:00
const newWindowHandler = ( e : any ) => {
2024-07-16 00:40:28 +08:00
e . preventDefault ();
const newUrl = e . detail . url ;
2024-08-29 13:37:05 -07:00
console . log ( "webview new-window event:" , newUrl );
fireAndForget (() => openLink ( newUrl , true ));
};
const startLoadingHandler = () => {
model . setRefreshIcon ( "xmark-large" );
model . setIsLoading ( true );
2024-09-11 03:16:34 +08:00
webview . style . backgroundColor = "transparent" ;
2024-08-29 13:37:05 -07:00
};
const stopLoadingHandler = () => {
model . setRefreshIcon ( "rotate-right" );
model . setIsLoading ( false );
2024-09-11 03:16:34 +08:00
debounce ( 1000 , () => {
webview . style . backgroundColor = "white" ;
})();
2024-08-29 13:37:05 -07:00
};
const failLoadHandler = ( e : any ) => {
2024-07-16 00:40:28 +08:00
if ( e . errorCode === - 3 ) {
2024-08-29 13:37:05 -07:00
console . warn ( "Suppressed ERR_ABORTED error" , e );
2024-06-27 00:39:41 +08:00
} else {
2024-07-16 00:40:28 +08:00
console . error ( `Failed to load ${ e . validatedURL } : ${ e . errorDescription } ` );
2024-06-27 00:39:41 +08:00
}
2024-08-29 13:37:05 -07:00
};
2024-08-29 16:06:15 -07:00
const webviewFocus = () => {
getApi (). setWebviewFocus ( webview . getWebContentsId ());
model . nodeModel . focusNode ();
};
const webviewBlur = () => {
getApi (). setWebviewFocus ( null );
};
2024-09-10 12:50:55 -07:00
const handleDomReady = () => {
setDomReady ( true );
};
2024-08-29 13:37:05 -07:00
webview . addEventListener ( "did-navigate-in-page" , navigateListener );
webview . addEventListener ( "did-navigate" , navigateListener );
webview . addEventListener ( "did-start-loading" , startLoadingHandler );
webview . addEventListener ( "did-stop-loading" , stopLoadingHandler );
webview . addEventListener ( "new-window" , newWindowHandler );
webview . addEventListener ( "did-fail-load" , failLoadHandler );
2024-08-29 16:06:15 -07:00
webview . addEventListener ( "focus" , webviewFocus );
webview . addEventListener ( "blur" , webviewBlur );
2024-09-10 12:50:55 -07:00
webview . addEventListener ( "dom-ready" , handleDomReady );
2024-08-29 16:06:15 -07:00
2024-06-27 00:39:41 +08:00
// Clean up event listeners on component unmount
return () => {
webview . removeEventListener ( "did-navigate" , navigateListener );
webview . removeEventListener ( "did-navigate-in-page" , navigateListener );
2024-08-29 13:37:05 -07:00
webview . removeEventListener ( "new-window" , newWindowHandler );
webview . removeEventListener ( "did-fail-load" , failLoadHandler );
webview . removeEventListener ( "did-start-loading" , startLoadingHandler );
webview . removeEventListener ( "did-stop-loading" , stopLoadingHandler );
2024-09-10 12:50:55 -07:00
webview . removeEventListener ( "focus" , webviewFocus );
webview . removeEventListener ( "blur" , webviewBlur );
webview . removeEventListener ( "dom-ready" , handleDomReady );
2024-06-27 00:39:41 +08:00
};
}
2024-07-16 00:40:28 +08:00
}, []);
2024-06-27 00:39:41 +08:00
2024-08-29 13:37:05 -07:00
return (
< webview
id = "webview"
className = "webview"
ref = { model . webviewRef }
src = { metaUrlInitial }
2024-09-10 12:50:55 -07:00
data-blockid = { model . blockId }
data-webcontentsid = { webContentsId } // needed for emain
2024-08-29 13:37:05 -07:00
// @ts-ignore This is a discrepancy between the React typing and the Chromium impl for webviewTag. Chrome webviewTag expects a string, while React expects a boolean.
allowpopups = "true"
></ webview >
);
2024-07-04 05:32:55 +08:00
});
2024-06-27 00:39:41 +08:00
2024-07-16 00:40:28 +08:00
export { WebView , makeWebViewModel };