2024-06-26 18:39:41 +02:00
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
2024-08-30 01:06:15 +02: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 20:42:24 +02:00
import * as services from "@/store/services" ;
2024-08-30 01:06:15 +02:00
import { adaptFromReactOrNativeKeyEvent , checkKeyPressed } from "@/util/keyutil" ;
2024-08-29 22:37:05 +02:00
import { fireAndForget } from "@/util/util" ;
2024-07-18 08:41:33 +02:00
import clsx from "clsx" ;
2024-06-26 18:39:41 +02:00
import { WebviewTag } from "electron" ;
2024-07-09 20:42:24 +02:00
import * as jotai from "jotai" ;
2024-08-29 22:37:05 +02:00
import React , { memo , useEffect , useState } from "react" ;
2024-06-26 18:39:41 +02:00
import "./webview.less" ;
2024-07-15 18:40:28 +02:00
export class WebViewModel implements ViewModel {
2024-08-23 01:25:53 +02:00
viewType : string ;
2024-07-09 00:04:48 +02:00
blockId : string ;
2024-07-15 18:40:28 +02:00
blockAtom : jotai.Atom < Block > ;
2024-09-09 21:35:53 +02:00
viewIcon : jotai.Atom < string | IconButtonDecl > ;
2024-07-15 18:40:28 +02:00
viewName : jotai.Atom < string > ;
viewText : jotai.Atom < HeaderElem [ ] > ;
url : jotai.PrimitiveAtom < string > ;
2024-08-14 23:39:18 +02:00
isUrlDirty : jotai.PrimitiveAtom < boolean > ;
2024-07-15 18:40:28 +02:00
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 > ;
2024-08-30 01:06:15 +02:00
nodeModel : NodeModel ;
2024-07-15 18:40:28 +02:00
2024-08-30 01:06:15 +02:00
constructor ( blockId : string , nodeModel : NodeModel ) {
this . nodeModel = nodeModel ;
2024-08-23 01:25:53 +02:00
this . viewType = "web" ;
2024-07-15 18:40:28 +02:00
this . blockId = blockId ;
this . blockAtom = WOS . getWaveObjectAtom < Block > ( ` block: ${ blockId } ` ) ;
this . url = jotai . atom ( "" ) ;
2024-08-14 23:39:18 +02:00
this . isUrlDirty = jotai . atom ( false ) ;
2024-07-15 18:40:28 +02:00
this . urlInput = jotai . atom ( "" ) ;
this . urlWrapperClassName = jotai . atom ( "" ) ;
this . urlInputFocused = jotai . atom ( false ) ;
this . isLoading = jotai . atom ( false ) ;
this . refreshIcon = jotai . atom ( "rotate-right" ) ;
2024-08-29 22:37:05 +02:00
this . viewIcon = jotai . atom ( "globe" ) ;
2024-07-15 18:40:28 +02:00
this . viewName = jotai . atom ( "Web" ) ;
this . urlInputRef = React . createRef < HTMLInputElement > ( ) ;
this . webviewRef = React . createRef < WebviewTag > ( ) ;
2024-06-28 03:09:30 +02:00
2024-07-15 18:40:28 +02:00
this . viewText = jotai . atom ( ( get ) = > {
let url = get ( this . blockAtom ) ? . meta ? . url || "" ;
2024-08-29 22:37:05 +02:00
if ( url && ! get ( this . url ) ) {
globalStore . set ( this . url , url ) ;
2024-06-26 18:39:41 +02:00
}
2024-08-14 23:39:18 +02:00
const urlIsDirty = get ( this . isUrlDirty ) ;
if ( urlIsDirty ) {
const currUrl = get ( this . url ) ;
2024-07-15 18:40:28 +02:00
url = currUrl ;
}
return [
{
elemtype : "iconbutton" ,
icon : "chevron-left" ,
click : this.handleBack.bind ( this ) ,
2024-09-03 20:24:45 +02:00
disabled : this.shouldDisabledBackButton ( ) ,
2024-07-15 18:40:28 +02:00
} ,
{
elemtype : "iconbutton" ,
icon : "chevron-right" ,
click : this.handleForward.bind ( this ) ,
2024-09-03 20:24:45 +02:00
disabled : this.shouldDisabledForwardButton ( ) ,
2024-07-15 18:40:28 +02:00
} ,
{
elemtype : "div" ,
2024-07-18 08:41:33 +02:00
className : clsx ( "block-frame-div-url" , get ( this . urlWrapperClassName ) ) ,
2024-07-15 18:40:28 +02: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-06-26 18:39:41 +02:00
2024-08-29 22:37:05 +02: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-15 18:40:28 +02:00
shouldDisabledBackButton() {
2024-08-29 22:37:05 +02:00
try {
return ! this . webviewRef . current ? . canGoBack ( ) ;
} catch ( _ ) { }
return true ;
2024-07-15 18:40:28 +02:00
}
2024-06-26 18:39:41 +02:00
2024-08-29 22:37:05 +02: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-15 18:40:28 +02:00
shouldDisabledForwardButton() {
2024-08-29 22:37:05 +02:00
try {
return ! this . webviewRef . current ? . canGoForward ( ) ;
} catch ( _ ) { }
return true ;
2024-07-15 18:40:28 +02:00
}
2024-06-26 18:39:41 +02:00
2024-07-15 18:40:28 +02:00
handleUrlWrapperMouseOver ( e : React.MouseEvent < HTMLDivElement , MouseEvent > ) {
const urlInputFocused = globalStore . get ( this . urlInputFocused ) ;
if ( e . type === "mouseover" && ! urlInputFocused ) {
globalStore . set ( this . urlWrapperClassName , "hovered" ) ;
}
}
2024-06-26 18:39:41 +02:00
2024-07-15 18:40:28 +02:00
handleUrlWrapperMouseOut ( e : React.MouseEvent < HTMLDivElement , MouseEvent > ) {
const urlInputFocused = globalStore . get ( this . urlInputFocused ) ;
if ( e . type === "mouseout" && ! urlInputFocused ) {
globalStore . set ( this . urlWrapperClassName , "" ) ;
2024-06-26 18:39:41 +02:00
}
2024-07-15 18:40:28 +02:00
}
2024-06-26 18:39:41 +02:00
2024-08-30 01:06:15 +02:00
handleBack ( e? : React.MouseEvent < HTMLDivElement , MouseEvent > ) {
if ( e ) {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
}
2024-08-29 22:37:05 +02:00
this . webviewRef . current ? . goBack ( ) ;
2024-07-15 18:40:28 +02:00
}
2024-06-26 18:39:41 +02:00
2024-08-30 01:06:15 +02:00
handleForward ( e? : React.MouseEvent < HTMLDivElement , MouseEvent > ) {
if ( e ) {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
}
2024-08-29 22:37:05 +02:00
this . webviewRef . current ? . goForward ( ) ;
2024-07-15 18:40:28 +02:00
}
handleRefresh ( e : React.MouseEvent < HTMLDivElement , MouseEvent > ) {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
2024-08-29 22:37:05 +02:00
try {
if ( this . webviewRef . current ) {
if ( globalStore . get ( this . isLoading ) ) {
this . webviewRef . current . stop ( ) ;
} else {
this . webviewRef . current . reload ( ) ;
}
2024-07-15 18:40:28 +02:00
}
2024-08-29 22:37:05 +02:00
} catch ( e ) {
console . warn ( "handleRefresh catch" , e ) ;
2024-07-15 18:40:28 +02:00
}
}
2024-06-28 03:09:30 +02:00
2024-07-15 18:40:28 +02:00
handleUrlChange ( event : React.ChangeEvent < HTMLInputElement > ) {
globalStore . set ( this . url , event . target . value ) ;
2024-08-14 23:39:18 +02:00
globalStore . set ( this . isUrlDirty , true ) ;
2024-07-15 18:40:28 +02:00
}
handleKeyDown ( event : React.KeyboardEvent < HTMLInputElement > ) {
2024-08-30 01:06:15 +02:00
const waveEvent = adaptFromReactOrNativeKeyEvent ( event ) ;
if ( checkKeyPressed ( waveEvent , "Enter" ) ) {
2024-08-29 22:37:05 +02:00
const url = globalStore . get ( this . url ) ;
2024-09-08 03:22:13 +02:00
this . loadUrl ( url , "enter" ) ;
2024-07-15 18:40:28 +02:00
this . urlInputRef . current ? . blur ( ) ;
2024-08-30 01:06:15 +02:00
return ;
}
if ( checkKeyPressed ( waveEvent , "Escape" ) ) {
this . webviewRef . current ? . focus ( ) ;
2024-07-15 18:40:28 +02:00
}
}
2024-06-26 18:39:41 +02:00
2024-07-15 18:40:28 +02: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 22:37:05 +02: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-15 18:40:28 +02:00
ensureUrlScheme ( url : string ) {
2024-08-08 20:58:22 +02: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-06-28 23:53:50 +02:00
return ` http:// ${ url } ` ;
2024-08-08 20:58:22 +02: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-06-26 18:39:41 +02:00
return ` https:// ${ url } ` ;
}
2024-08-08 20:58:22 +02:00
// Otherwise, treat it as a search query
return ` https://www.google.com/search?q= ${ encodeURIComponent ( url ) } ` ;
2024-07-15 18:40:28 +02:00
}
normalizeUrl ( url : string ) {
if ( ! url ) {
return url ;
}
2024-06-26 18:39:41 +02:00
try {
const parsedUrl = new URL ( url ) ;
if ( parsedUrl . hostname . startsWith ( "www." ) ) {
parsedUrl . hostname = parsedUrl . hostname . slice ( 4 ) ;
}
return parsedUrl . href ;
} catch ( e ) {
2024-06-28 23:53:50 +02:00
return url . replace ( /\/+$/ , "" ) + "/" ;
2024-06-26 18:39:41 +02:00
}
2024-07-15 18:40:28 +02:00
}
2024-06-26 18:39:41 +02:00
2024-08-29 22:37:05 +02:00
/ * *
* Load a new URL in the webview .
* @param newUrl The new URL to load in the webview .
* /
2024-09-08 03:22:13 +02:00
loadUrl ( newUrl : string , reason : string ) {
2024-08-29 22:37:05 +02:00
const nextUrl = this . ensureUrlScheme ( newUrl ) ;
2024-09-08 03:22:13 +02: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-06-26 18:39:41 +02:00
}
2024-07-15 18:40:28 +02:00
}
2024-06-26 18:39:41 +02:00
2024-08-29 22:37:05 +02:00
/ * *
* Get the current URL from the state .
* @returns The URL from the state .
* /
getUrl() {
return globalStore . get ( this . url ) ;
2024-07-15 18:40:28 +02:00
}
2024-06-26 18:39:41 +02:00
2024-07-15 18:40:28 +02:00
setRefreshIcon ( refreshIcon : string ) {
globalStore . set ( this . refreshIcon , refreshIcon ) ;
}
2024-06-26 18:39:41 +02:00
2024-07-15 18:40:28 +02:00
setIsLoading ( isLoading : boolean ) {
globalStore . set ( this . isLoading , isLoading ) ;
}
2024-06-26 18:39:41 +02:00
2024-07-23 01:41:18 +02:00
giveFocus ( ) : boolean {
2024-08-30 01:06:15 +02: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-23 01:41:18 +02:00
}
2024-08-30 01:06:15 +02:00
this . webviewRef . current ? . focus ( ) ;
return true ;
2024-07-23 01:41:18 +02:00
}
2024-08-22 00:49:23 +02: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-30 01:06:15 +02:00
if ( checkKeyPressed ( e , "Cmd:ArrowLeft" ) ) {
this . handleBack ( null ) ;
return true ;
}
if ( checkKeyPressed ( e , "Cmd:ArrowRight" ) ) {
this . handleForward ( null ) ;
return true ;
}
2024-08-22 00:49:23 +02:00
return false ;
}
2024-09-10 00:41:26 +02: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-15 18:40:28 +02:00
}
2024-08-30 01:06:15 +02:00
function makeWebViewModel ( blockId : string , nodeModel : NodeModel ) : WebViewModel {
const webviewModel = new WebViewModel ( blockId , nodeModel ) ;
2024-07-15 18:40:28 +02:00
return webviewModel ;
}
interface WebViewProps {
2024-08-22 00:49:23 +02:00
blockId : string ;
2024-07-15 18:40:28 +02:00
model : WebViewModel ;
}
2024-08-22 00:49:23 +02:00
const WebView = memo ( ( { model } : WebViewProps ) = > {
2024-08-01 09:35:44 +02:00
const blockData = jotai . useAtomValue ( model . blockAtom ) ;
const metaUrl = blockData ? . meta ? . url ;
const metaUrlRef = React . useRef ( metaUrl ) ;
2024-08-29 22:37:05 +02: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 ) ;
// Load a new URL if the block metadata is updated.
2024-08-01 09:35:44 +02:00
useEffect ( ( ) = > {
if ( metaUrlRef . current != metaUrl ) {
metaUrlRef . current = metaUrl ;
2024-09-08 03:22:13 +02:00
model . loadUrl ( metaUrl , "meta" ) ;
2024-08-01 09:35:44 +02:00
}
} , [ metaUrl ] ) ;
2024-07-15 18:40:28 +02:00
useEffect ( ( ) = > {
const webview = model . webviewRef . current ;
if ( webview ) {
const navigateListener = ( e : any ) = > {
2024-08-29 22:37:05 +02:00
model . handleNavigate ( e . url ) ;
2024-07-15 18:40:28 +02:00
} ;
2024-08-29 22:37:05 +02:00
const newWindowHandler = ( e : any ) = > {
e . preventDefault ( ) ;
const newUrl = e . detail . url ;
console . log ( "webview new-window event:" , newUrl ) ;
fireAndForget ( ( ) = > openLink ( newUrl , true ) ) ;
} ;
const startLoadingHandler = ( ) = > {
2024-07-15 18:40:28 +02:00
model . setRefreshIcon ( "xmark-large" ) ;
model . setIsLoading ( true ) ;
2024-08-29 22:37:05 +02:00
} ;
const stopLoadingHandler = ( ) = > {
2024-07-15 18:40:28 +02:00
model . setRefreshIcon ( "rotate-right" ) ;
model . setIsLoading ( false ) ;
2024-08-29 22:37:05 +02:00
} ;
const failLoadHandler = ( e : any ) = > {
2024-07-15 18:40:28 +02:00
if ( e . errorCode === - 3 ) {
2024-08-29 22:37:05 +02:00
console . warn ( "Suppressed ERR_ABORTED error" , e ) ;
2024-07-15 18:40:28 +02:00
} else {
console . error ( ` Failed to load ${ e . validatedURL } : ${ e . errorDescription } ` ) ;
}
2024-08-29 22:37:05 +02:00
} ;
2024-08-30 01:06:15 +02:00
const webviewFocus = ( ) = > {
getApi ( ) . setWebviewFocus ( webview . getWebContentsId ( ) ) ;
model . nodeModel . focusNode ( ) ;
} ;
const webviewBlur = ( ) = > {
getApi ( ) . setWebviewFocus ( null ) ;
} ;
2024-08-29 22:37:05 +02: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-07-15 18:40:28 +02:00
2024-08-30 01:06:15 +02:00
webview . addEventListener ( "focus" , webviewFocus ) ;
webview . addEventListener ( "blur" , webviewBlur ) ;
2024-07-15 18:40:28 +02:00
// Clean up event listeners on component unmount
return ( ) = > {
webview . removeEventListener ( "did-navigate" , navigateListener ) ;
webview . removeEventListener ( "did-navigate-in-page" , navigateListener ) ;
2024-08-29 22:37:05 +02: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-08-30 01:06:15 +02:00
webview . addEventListener ( "focus" , webviewFocus ) ;
webview . addEventListener ( "blur" , webviewBlur ) ;
2024-07-15 18:40:28 +02:00
} ;
2024-06-28 23:53:50 +02:00
}
2024-07-15 18:40:28 +02:00
} , [ ] ) ;
2024-08-29 22:37:05 +02:00
return (
< webview
id = "webview"
className = "webview"
ref = { model . webviewRef }
src = { metaUrlInitial }
// @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-03 23:32:55 +02:00
} ) ;
2024-06-26 18:39:41 +02:00
2024-07-15 18:40:28 +02:00
export { WebView , makeWebViewModel } ;