2020-10-14 17:25:40 +01:00
import Setting from 'lib/models/Setting' ;
import shim from 'lib/shim' ;
2020-06-02 20:13:15 +00:00
2020-10-14 17:25:40 +01:00
const Async = require ( 'react-async' ) . default ;
2019-07-29 15:43:53 +02:00
const React = require ( 'react' ) ;
const Component = React . Component ;
2020-10-12 10:25:59 +01:00
const { Platform , View , Text , ToastAndroid } = require ( 'react-native' ) ;
2019-07-29 15:43:53 +02:00
const { WebView } = require ( 'react-native-webview' ) ;
2019-03-08 17:14:17 +00:00
const { themeStyle } = require ( 'lib/components/global-style.js' ) ;
2020-10-14 17:25:40 +01:00
const BackButtonDialogBox = require ( 'lib/components/BackButtonDialogBox' ) . default ;
2020-10-12 10:25:59 +01:00
const { _ } = require ( 'lib/locale.js' ) ;
2018-03-09 20:59:12 +00:00
const { reg } = require ( 'lib/registry.js' ) ;
2020-01-30 21:05:23 +00:00
const { assetsToHeaders } = require ( 'lib/joplin-renderer' ) ;
2019-03-08 17:14:17 +00:00
const shared = require ( 'lib/components/shared/note-screen-shared.js' ) ;
2019-12-29 18:58:40 +01:00
const markupLanguageUtils = require ( 'lib/markupLanguageUtils' ) ;
2020-10-12 10:25:59 +01:00
const { dialogs } = require ( 'lib/dialogs.js' ) ;
const Resource = require ( 'lib/models/Resource.js' ) ;
const Share = require ( 'react-native-share' ) . default ;
2019-12-29 18:58:40 +01:00
2020-10-14 17:25:40 +01:00
export default class NoteBodyViewer extends Component {
private forceUpdate_ :boolean = false ;
private isMounted_ :boolean = false ;
private markupToHtml_ :any ;
2017-07-30 21:51:18 +02:00
constructor ( ) {
super ( ) ;
2020-10-14 17:25:40 +01:00
2017-07-30 21:51:18 +02:00
this . state = {
resources : { } ,
2017-08-21 22:46:31 +02:00
webViewLoaded : false ,
2019-12-29 18:58:40 +01:00
bodyHtml : '' ,
2019-07-29 15:43:53 +02:00
} ;
2017-08-21 22:46:31 +02:00
2019-12-29 18:58:40 +01:00
this . markupToHtml_ = markupLanguageUtils . newMarkupToHtml ( ) ;
this . reloadNote = this . reloadNote . bind ( this ) ;
2020-04-08 17:40:32 +00:00
this . watchFn = this . watchFn . bind ( this ) ;
2017-07-30 21:51:18 +02:00
}
2019-12-29 18:58:40 +01:00
componentDidMount() {
2017-08-21 22:46:31 +02:00
this . isMounted_ = true ;
}
componentWillUnmount() {
2019-12-29 18:58:40 +01:00
this . markupToHtml_ = null ;
2017-08-21 22:46:31 +02:00
this . isMounted_ = false ;
}
2019-12-29 18:58:40 +01:00
async reloadNote() {
2020-04-08 17:40:32 +00:00
this . forceUpdate_ = false ;
2017-07-30 21:51:18 +02:00
const note = this . props . note ;
2020-09-15 14:01:07 +01:00
const theme = themeStyle ( this . props . themeId ) ;
2019-03-08 17:14:17 +00:00
const bodyToRender = note ? note . body : '' ;
2017-11-05 15:35:38 +00:00
const mdOptions = {
2017-11-06 18:05:12 +00:00
onResourceLoaded : ( ) = > {
2018-12-15 01:42:19 +01:00
if ( this . resourceLoadedTimeoutId_ ) {
2020-10-09 18:35:46 +01:00
shim . clearTimeout ( this . resourceLoadedTimeoutId_ ) ;
2018-12-15 01:42:19 +01:00
this . resourceLoadedTimeoutId_ = null ;
}
2020-10-09 18:35:46 +01:00
this . resourceLoadedTimeoutId_ = shim . setTimeout ( ( ) = > {
2018-12-15 01:42:19 +01:00
this . resourceLoadedTimeoutId_ = null ;
this . forceUpdate ( ) ;
} , 100 ) ;
2017-11-05 15:35:38 +00:00
} ,
2018-12-16 18:32:42 +01:00
highlightedKeywords : this.props.highlightedKeywords ,
2020-06-08 07:41:04 +01:00
resources : this.props.noteResources ,
2019-03-08 17:14:17 +00:00
codeTheme : theme.codeThemeCss ,
2020-06-08 07:41:04 +01:00
postMessageSyntax : 'window.joplinPostMessage_' ,
2020-10-12 10:25:59 +01:00
enableLongPress : shim.isReactNative ( ) ,
longPressDelay : 500 , // TODO use system value
2017-11-05 15:35:38 +00:00
} ;
2020-03-23 00:47:25 +00:00
const result = await this . markupToHtml_ . render (
note . markup_language ,
bodyToRender ,
{
2020-04-08 01:22:18 +01:00
bodyPaddingTop : '.8em' , // Extra top padding on the rendered MD so it doesn't touch the border
bodyPaddingBottom : this.props.paddingBottom , // Extra bottom padding to make it possible to scroll past the action button (so that it doesn't overlap the text)
2020-03-23 00:47:25 +00:00
. . . this . props . webViewStyle ,
} ,
mdOptions
) ;
2019-03-08 17:14:17 +00:00
let html = result . html ;
2019-05-22 15:56:07 +01:00
const resourceDownloadMode = Setting . value ( 'sync.resourceDownloadMode' ) ;
2019-12-29 18:58:40 +01:00
const injectedJs = [ ] ;
2020-10-14 17:25:40 +01:00
injectedJs . push ( 'try {' ) ;
2019-03-08 17:14:17 +00:00
injectedJs . push ( shim . injectedJs ( 'webviewLib' ) ) ;
2020-06-08 07:41:04 +01:00
// Note that this postMessage function accepts two arguments, for compatibility with the desktop version, but
// the ReactNativeWebView actually supports only one, so the second arg is ignored (and currently not needed for the mobile app).
2020-06-08 07:43:09 +01:00
injectedJs . push ( 'window.joplinPostMessage_ = (msg, args) => { return window.ReactNativeWebView.postMessage(msg); };' ) ;
2019-06-14 08:11:15 +01:00
injectedJs . push ( 'webviewLib.initialize({ postMessage: msg => { return window.ReactNativeWebView.postMessage(msg); } });' ) ;
2019-05-22 15:56:07 +01:00
injectedJs . push ( `
2020-10-14 17:25:40 +01:00
const readyStateCheckInterval = setInterval ( function ( ) {
2019-05-22 15:56:07 +01:00
if ( document . readyState === "complete" ) {
2020-10-14 17:25:40 +01:00
clearInterval ( readyStateCheckInterval ) ;
2019-05-22 15:56:07 +01:00
if ( "${resourceDownloadMode}" === "manual" ) webviewLib . setupResourceManualDownload ( ) ;
2019-09-09 18:16:00 +01:00
const hash = "${this.props.noteHash}" ;
// Gives it a bit of time before scrolling to the anchor
// so that images are loaded.
if ( hash ) {
2020-10-14 17:25:40 +01:00
setTimeout ( ( ) = > {
2019-09-09 18:16:00 +01:00
const e = document . getElementById ( hash ) ;
if ( ! e ) {
console . warn ( 'Cannot find hash' , hash ) ;
return ;
}
e . scrollIntoView ( ) ;
} , 500 ) ;
}
2019-05-22 15:56:07 +01:00
}
} , 10 ) ;
` );
2020-10-14 17:25:40 +01:00
injectedJs . push ( '} catch (e) {' ) ;
injectedJs . push ( ' window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))' ) ;
injectedJs . push ( ' true;' ) ;
injectedJs . push ( '}' ) ;
injectedJs . push ( 'true;' ) ;
2019-02-27 23:38:50 +00:00
2019-07-29 15:43:53 +02:00
html =
`
2018-02-04 17:12:24 +00:00
< ! DOCTYPE html >
< html >
< head >
2019-06-14 09:14:01 +01:00
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
2020-02-08 11:11:04 +00:00
$ { assetsToHeaders ( result . pluginAssets , { asHtml : true } ) }
2018-02-04 17:12:24 +00:00
< / head >
< body >
2019-09-19 23:02:29 +01:00
$ { html }
2018-02-04 17:12:24 +00:00
< / body >
< / html >
` ;
2017-08-21 22:46:31 +02:00
2020-10-15 11:40:03 +01:00
const tempFile = ` ${ Setting . value ( 'resourceDir' ) } /NoteBodyViewer.html `
await shim . fsDriver ( ) . writeFile ( tempFile , html , 'utf8' ) ;
2017-11-20 00:21:40 +00:00
// On iOS scalesPageToFit work like this:
//
// Find the widest image, resize it *and everything else* by x% so that
// the image fits within the viewport. The problem is that it means if there's
// a large image, everything is going to be scaled to a very small size, making
// the text unreadable.
//
// On Android:
//
// Find the widest elements and scale them (and them only) to fit within the viewport
// It means it's going to scale large images, but the text will remain at the normal
// size.
//
// That means we can use scalesPageToFix on Android but not on iOS.
// The weird thing is that on iOS, scalesPageToFix=false along with a CSS
// rule "img { max-width: 100% }", works like scalesPageToFix=true on Android.
// So we use scalesPageToFix=false on iOS along with that CSS rule.
2017-11-20 19:01:19 +00:00
// `baseUrl` is where the images will be loaded from. So images must use a path relative to resourceDir.
2019-12-29 18:58:40 +01:00
return {
source : {
2020-10-15 11:40:03 +01:00
// html: html,
uri : 'file://' + tempFile ,
2019-12-29 18:58:40 +01:00
baseUrl : ` file:// ${ Setting . value ( 'resourceDir' ) } / ` ,
} ,
injectedJs : injectedJs ,
2017-11-21 19:47:29 +00:00
} ;
2019-12-29 18:58:40 +01:00
}
onLoadEnd() {
2020-10-09 18:35:46 +01:00
shim . setTimeout ( ( ) = > {
2019-12-29 18:58:40 +01:00
if ( this . props . onLoadEnd ) this . props . onLoadEnd ( ) ;
} , 100 ) ;
if ( this . state . webViewLoaded ) return ;
// Need to display after a delay to avoid a white flash before
// the content is displayed.
2020-10-09 18:35:46 +01:00
shim . setTimeout ( ( ) = > {
2019-12-29 18:58:40 +01:00
if ( ! this . isMounted_ ) return ;
this . setState ( { webViewLoaded : true } ) ;
} , 100 ) ;
}
2020-10-14 17:25:40 +01:00
shouldComponentUpdate ( nextProps :any , nextState :any ) {
const safeGetNoteProp = ( props :any , propName :string ) = > {
2019-12-29 18:58:40 +01:00
if ( ! props ) return null ;
if ( ! props . note ) return null ;
return props . note [ propName ] ;
} ;
// To address https://github.com/laurent22/joplin/issues/433
// If a checkbox in a note is ticked, the body changes, which normally would trigger a re-render
// of this component, which has the unfortunate side effect of making the view scroll back to the top.
// This re-rendering however is uncessary since the component is already visually updated via JS.
// So here, if the note has not changed, we prevent the component from updating.
// This fixes the above issue. A drawback of this is if the note is updated via sync, this change
// will not be displayed immediately.
const currentNoteId = safeGetNoteProp ( this . props , 'id' ) ;
const nextNoteId = safeGetNoteProp ( nextProps , 'id' ) ;
if ( currentNoteId !== nextNoteId || nextState . webViewLoaded !== this . state . webViewLoaded ) return true ;
// If the length of the body has changed, then it's something other than a checkbox that has changed,
// for example a resource that has been attached to the note while in View mode. In that case, update.
return ( ` ${ safeGetNoteProp ( this . props , 'body' ) } ` ) . length !== ( ` ${ safeGetNoteProp ( nextProps , 'body' ) } ` ) . length ;
}
rebuildMd() {
2020-04-08 17:40:32 +00:00
this . forceUpdate_ = true ;
2019-12-29 18:58:40 +01:00
this . forceUpdate ( ) ;
}
2017-11-20 19:01:19 +00:00
2020-04-08 17:40:32 +00:00
watchFn() {
// react-async will not fetch the data again after the first render
// so we use this watchFn function to force it to reload in certain
// cases. It is used in particular when re-rendering the note when
// a resource has been downloaded in auto mode.
return this . forceUpdate_ ;
}
2020-10-14 17:25:40 +01:00
async onResourceLongPress ( msg :string ) {
2020-10-12 10:25:59 +01:00
try {
const resourceId = msg . split ( ':' ) [ 1 ] ;
const resource = await Resource . load ( resourceId ) ;
const name = resource . title ? resource.title : resource.file_name ;
const action = await dialogs . pop ( this , name , [
{ text : _ ( 'Open' ) , id : 'open' } ,
{ text : _ ( 'Share' ) , id : 'share' } ,
] ) ;
if ( action === 'open' ) {
this . props . onJoplinLinkClick ( ` joplin:// ${ resourceId } ` ) ;
} else if ( action === 'share' ) {
const filename = resource . file_name ?
` ${ resource . file_name } . ${ resource . file_extension } ` :
resource . title ;
const targetPath = ` ${ Setting . value ( 'resourceDir' ) } / ${ filename } ` ;
await shim . fsDriver ( ) . copy ( Resource . fullPath ( resource ) , targetPath ) ;
await Share . open ( {
type : resource . mime ,
filename : resource.title ,
url : ` file:// ${ targetPath } ` ,
failOnCancel : false ,
} ) ;
await shim . fsDriver ( ) . remove ( targetPath ) ;
}
} catch ( e ) {
reg . logger ( ) . error ( 'Could not handle link long press' , e ) ;
ToastAndroid . show ( 'An error occurred, check log for details' , ToastAndroid . SHORT ) ;
}
}
2019-12-29 18:58:40 +01:00
render() {
2019-06-14 08:11:15 +01:00
// Note: useWebKit={false} is needed to go around this bug:
// https://github.com/react-native-community/react-native-webview/issues/376
2019-06-14 09:14:01 +01:00
// However, if we add the <meta> tag as described there, it is no longer necessary and WebKit can be used!
// https://github.com/react-native-community/react-native-webview/issues/312#issuecomment-501991406
2019-06-19 23:16:37 +01:00
//
// However, on iOS, due to the bug below, we cannot use WebKit:
// https://github.com/react-native-community/react-native-webview/issues/312#issuecomment-503754654
2019-06-14 08:11:15 +01:00
2019-12-29 18:58:40 +01:00
2020-10-14 17:25:40 +01:00
const webViewStyle :any = { backgroundColor : this.props.webViewStyle.backgroundColor } ;
2019-12-29 18:58:40 +01:00
// On iOS, the onLoadEnd() event is never fired so always
// display the webview (don't do the little trick
// to avoid the white flash).
if ( Platform . OS !== 'ios' ) {
webViewStyle . opacity = this . state . webViewLoaded ? 1 : 0.01 ;
}
2020-10-15 11:40:03 +01:00
const useWebkit = true ; //Platform.OS !== 'ios'
2017-07-30 21:51:18 +02:00
return (
2019-12-29 18:58:40 +01:00
< View style = { this . props . style } >
2020-04-08 17:40:32 +00:00
< Async promiseFn = { this . reloadNote } watchFn = { this . watchFn } >
2020-10-14 17:25:40 +01:00
{ ( args :any ) = > {
const { data , error , isPending } = args ;
2019-12-29 18:58:40 +01:00
if ( error ) {
console . error ( error ) ;
return < Text > { error . message } < / Text > ;
2017-07-30 21:51:18 +02:00
}
2019-12-29 18:58:40 +01:00
if ( isPending ) return null ;
return (
< WebView
2020-10-15 11:40:03 +01:00
useWebKit = { useWebkit }
allowingReadAccessToURL = { ` file:// ${ Setting . value ( 'resourceDir' ) } ` }
2019-12-29 18:58:40 +01:00
style = { webViewStyle }
source = { data . source }
injectedJavaScript = { data . injectedJs . join ( '\n' ) }
originWhitelist = { [ 'file://*' , './*' , 'http://*' , 'https://*' ] }
mixedContentMode = "always"
allowFileAccess = { true }
onLoadEnd = { ( ) = > this . onLoadEnd ( ) }
onError = { ( ) = > reg . logger ( ) . error ( 'WebView error' ) }
2020-10-14 17:25:40 +01:00
onMessage = { ( event :any ) = > {
2019-12-29 18:58:40 +01:00
// Since RN 58 (or 59) messages are now escaped twice???
let msg = unescape ( unescape ( event . nativeEvent . data ) ) ;
console . info ( 'Got IPC message: ' , msg ) ;
if ( msg . indexOf ( 'checkboxclick:' ) === 0 ) {
const newBody = shared . toggleCheckbox ( msg , this . props . note . body ) ;
if ( this . props . onCheckboxChange ) this . props . onCheckboxChange ( newBody ) ;
} else if ( msg . indexOf ( 'markForDownload:' ) === 0 ) {
2020-10-14 17:25:40 +01:00
const splittedMsg = msg . split ( ':' ) ;
const resourceId = splittedMsg [ 1 ] ;
2019-12-29 18:58:40 +01:00
if ( this . props . onMarkForDownload ) this . props . onMarkForDownload ( { resourceId : resourceId } ) ;
2020-10-12 10:25:59 +01:00
} else if ( msg . startsWith ( 'longclick:' ) ) {
this . onResourceLongPress ( msg ) ;
} else if ( msg . startsWith ( 'joplin:' ) ) {
2019-12-29 18:58:40 +01:00
this . props . onJoplinLinkClick ( msg ) ;
2020-10-14 17:25:40 +01:00
} else if ( msg . startsWith ( 'error:' ) ) {
console . error ( 'Webview injected script error: ' + msg ) ;
2019-12-29 18:58:40 +01:00
}
} }
/ >
) ;
2017-07-30 21:51:18 +02:00
} }
2019-12-29 18:58:40 +01:00
< / Async >
2020-10-12 10:25:59 +01:00
< BackButtonDialogBox
2020-10-14 17:25:40 +01:00
ref = { ( dialogbox :any ) = > {
2020-10-12 10:25:59 +01:00
this . dialogbox = dialogbox ;
} }
/ >
2017-07-30 21:51:18 +02:00
< / View >
) ;
}
}