diff --git a/.eslintignore b/.eslintignore index 69b53a542b..145439316c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -202,7 +202,11 @@ ReactNativeClient/lib/commands/historyForward.js ReactNativeClient/lib/commands/synchronize.js ReactNativeClient/lib/components/BackButtonDialogBox.js ReactNativeClient/lib/components/CameraView.js -ReactNativeClient/lib/components/NoteBodyViewer.js +ReactNativeClient/lib/components/NoteBodyViewer/hooks/useOnMessage.js +ReactNativeClient/lib/components/NoteBodyViewer/hooks/useOnResourceLongPress.js +ReactNativeClient/lib/components/NoteBodyViewer/hooks/useSource.js +ReactNativeClient/lib/components/NoteBodyViewer/NoteBodyViewer.js +ReactNativeClient/lib/components/screens/Note.js ReactNativeClient/lib/components/screens/UpgradeSyncTargetScreen.js ReactNativeClient/lib/components/SelectDateTimeDialog.js ReactNativeClient/lib/errorUtils.js diff --git a/.gitignore b/.gitignore index 2afdeb0dee..48bd9aae5d 100644 --- a/.gitignore +++ b/.gitignore @@ -196,7 +196,11 @@ ReactNativeClient/lib/commands/historyForward.js ReactNativeClient/lib/commands/synchronize.js ReactNativeClient/lib/components/BackButtonDialogBox.js ReactNativeClient/lib/components/CameraView.js -ReactNativeClient/lib/components/NoteBodyViewer.js +ReactNativeClient/lib/components/NoteBodyViewer/hooks/useOnMessage.js +ReactNativeClient/lib/components/NoteBodyViewer/hooks/useOnResourceLongPress.js +ReactNativeClient/lib/components/NoteBodyViewer/hooks/useSource.js +ReactNativeClient/lib/components/NoteBodyViewer/NoteBodyViewer.js +ReactNativeClient/lib/components/screens/Note.js ReactNativeClient/lib/components/screens/UpgradeSyncTargetScreen.js ReactNativeClient/lib/components/SelectDateTimeDialog.js ReactNativeClient/lib/errorUtils.js diff --git a/ReactNativeClient/MarkdownEditor/MarkdownEditor.js b/ReactNativeClient/MarkdownEditor/MarkdownEditor.js index 99bf8dc9e8..89da45cc69 100644 --- a/ReactNativeClient/MarkdownEditor/MarkdownEditor.js +++ b/ReactNativeClient/MarkdownEditor/MarkdownEditor.js @@ -13,7 +13,7 @@ import { Image, } from 'react-native'; import { renderFormatButtons } from './renderButtons'; -import NoteBodyViewer from 'lib/components/NoteBodyViewer'; +import NoteBodyViewer from 'lib/components/NoteBodyViewer/NoteBodyViewer'; const styles = StyleSheet.create({ buttonContainer: { diff --git a/ReactNativeClient/lib/BaseApplication.ts b/ReactNativeClient/lib/BaseApplication.ts index 56a20894e1..15b63c1222 100644 --- a/ReactNativeClient/lib/BaseApplication.ts +++ b/ReactNativeClient/lib/BaseApplication.ts @@ -698,6 +698,7 @@ export default class BaseApplication { initArgs = Object.assign(initArgs, extraFlags); this.logger_.addTarget(TargetType.File, { path: `${profileDir}/log.txt` }); + // this.logger_.addTarget(TargetType.Console, { level: Logger.LEVEL_DEBUG }); this.logger_.setLevel(initArgs.logLevel); reg.setLogger(this.logger_); diff --git a/ReactNativeClient/lib/PoorManIntervals.ts b/ReactNativeClient/lib/PoorManIntervals.ts index 906b9bdcf1..05af91d988 100644 --- a/ReactNativeClient/lib/PoorManIntervals.ts +++ b/ReactNativeClient/lib/PoorManIntervals.ts @@ -1,3 +1,11 @@ +// On mobile all the setTimeout and setInterval should go through this class +// as it will either use the native timeout/interval for short intervals or +// the custom one for long intervals. + +// For custom intervals, they are triggered +// whenever the update() function is called, and in mobile it's called for +// example on the Redux action middleware or when the app gets focus. + const { time } = require('lib/time-utils.js'); type IntervalId = number; diff --git a/ReactNativeClient/lib/components/NoteBodyViewer.tsx b/ReactNativeClient/lib/components/NoteBodyViewer.tsx deleted file mode 100644 index f58d4c06b7..0000000000 --- a/ReactNativeClient/lib/components/NoteBodyViewer.tsx +++ /dev/null @@ -1,338 +0,0 @@ -import Setting from 'lib/models/Setting'; -import shim from 'lib/shim'; - -const Async = require('react-async').default; -const React = require('react'); -const Component = React.Component; -const { Platform, View, Text, ToastAndroid } = require('react-native'); -const { WebView } = require('react-native-webview'); -const { themeStyle } = require('lib/components/global-style.js'); -const BackButtonDialogBox = require('lib/components/BackButtonDialogBox').default; -const { _ } = require('lib/locale.js'); -const { reg } = require('lib/registry.js'); -const { assetsToHeaders } = require('lib/joplin-renderer'); -const shared = require('lib/components/shared/note-screen-shared.js'); -const markupLanguageUtils = require('lib/markupLanguageUtils'); -const { dialogs } = require('lib/dialogs.js'); -const Resource = require('lib/models/Resource.js'); -const Share = require('react-native-share').default; - -export default class NoteBodyViewer extends Component { - - private forceUpdate_:boolean = false; - private isMounted_:boolean = false; - private markupToHtml_:any; - - constructor() { - super(); - - this.state = { - resources: {}, - webViewLoaded: false, - bodyHtml: '', - }; - - this.markupToHtml_ = markupLanguageUtils.newMarkupToHtml(); - - this.reloadNote = this.reloadNote.bind(this); - this.watchFn = this.watchFn.bind(this); - } - - componentDidMount() { - this.isMounted_ = true; - } - - componentWillUnmount() { - this.markupToHtml_ = null; - this.isMounted_ = false; - } - - async reloadNote() { - this.forceUpdate_ = false; - - const note = this.props.note; - const theme = themeStyle(this.props.themeId); - - const bodyToRender = note ? note.body : ''; - - const mdOptions = { - onResourceLoaded: () => { - if (this.resourceLoadedTimeoutId_) { - shim.clearTimeout(this.resourceLoadedTimeoutId_); - this.resourceLoadedTimeoutId_ = null; - } - - this.resourceLoadedTimeoutId_ = shim.setTimeout(() => { - this.resourceLoadedTimeoutId_ = null; - this.forceUpdate(); - }, 100); - }, - highlightedKeywords: this.props.highlightedKeywords, - resources: this.props.noteResources, - codeTheme: theme.codeThemeCss, - postMessageSyntax: 'window.joplinPostMessage_', - enableLongPress: shim.mobilePlatform() === 'android', // On iOS, there's already a built-on open/share menu - longPressDelay: 500, // TODO use system value - }; - - const result = await this.markupToHtml_.render( - note.markup_language, - bodyToRender, - { - 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) - ...this.props.webViewStyle, - }, - mdOptions - ); - let html = result.html; - - const resourceDownloadMode = Setting.value('sync.resourceDownloadMode'); - - const injectedJs = []; - injectedJs.push('try {'); - injectedJs.push(shim.injectedJs('webviewLib')); - // 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). - injectedJs.push('window.joplinPostMessage_ = (msg, args) => { return window.ReactNativeWebView.postMessage(msg); };'); - injectedJs.push('webviewLib.initialize({ postMessage: msg => { return window.ReactNativeWebView.postMessage(msg); } });'); - injectedJs.push(` - const readyStateCheckInterval = setInterval(function() { - if (document.readyState === "complete") { - clearInterval(readyStateCheckInterval); - if ("${resourceDownloadMode}" === "manual") webviewLib.setupResourceManualDownload(); - const hash = "${this.props.noteHash}"; - // Gives it a bit of time before scrolling to the anchor - // so that images are loaded. - if (hash) { - setTimeout(() => { - const e = document.getElementById(hash); - if (!e) { - console.warn('Cannot find hash', hash); - return; - } - e.scrollIntoView(); - }, 500); - } - } - }, 10); - `); - injectedJs.push('} catch (e) {'); - injectedJs.push(' window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))'); - injectedJs.push(' true;'); - injectedJs.push('}'); - injectedJs.push('true;'); - - html = - ` - - - - - ${assetsToHeaders(result.pluginAssets, { asHtml: true })} - - - ${html} - - - `; - - const tempFile = `${Setting.value('resourceDir')}/NoteBodyViewer.html` - await shim.fsDriver().writeFile(tempFile, html, 'utf8'); - - // 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. - - // `baseUrl` is where the images will be loaded from. So images must use a path relative to resourceDir. - return { - source: { - // html: html, - uri: 'file://' + tempFile, - baseUrl: `file://${Setting.value('resourceDir')}/`, - }, - injectedJs: injectedJs, - }; - } - - onLoadEnd() { - shim.setTimeout(() => { - 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. - shim.setTimeout(() => { - if (!this.isMounted_) return; - this.setState({ webViewLoaded: true }); - }, 100); - } - - shouldComponentUpdate(nextProps:any, nextState:any) { - const safeGetNoteProp = (props:any, propName:string) => { - 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() { - this.forceUpdate_ = true; - this.forceUpdate(); - } - - 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_; - } - - async onResourceLongPress(msg:string) { - 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); - } - } - - render() { - // Note: useWebKit={false} is needed to go around this bug: - // https://github.com/react-native-community/react-native-webview/issues/376 - // However, if we add the 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 - // - // 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 - - - const webViewStyle:any = { backgroundColor: this.props.webViewStyle.backgroundColor }; - // 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; - } - - const useWebkit = true; //Platform.OS !== 'ios' - - return ( - - - {(args:any) => { - const { data, error, isPending } = args; - - if (error) { - console.error(error); - return {error.message}; - } - - if (isPending) return null; - - return ( - this.onLoadEnd()} - onError={() => reg.logger().error('WebView error')} - onMessage={(event:any) => { - // 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) { - const splittedMsg = msg.split(':'); - const resourceId = splittedMsg[1]; - if (this.props.onMarkForDownload) this.props.onMarkForDownload({ resourceId: resourceId }); - } else if (msg.startsWith('longclick:')) { - this.onResourceLongPress(msg); - } else if (msg.startsWith('joplin:')) { - this.props.onJoplinLinkClick(msg); - } else if (msg.startsWith('error:')) { - console.error('Webview injected script error: ' + msg); - } - }} - /> - ); - }} - - { - this.dialogbox = dialogbox; - }} - /> - - ); - } -} diff --git a/ReactNativeClient/lib/components/NoteBodyViewer/NoteBodyViewer.tsx b/ReactNativeClient/lib/components/NoteBodyViewer/NoteBodyViewer.tsx new file mode 100644 index 0000000000..4b8acc65c8 --- /dev/null +++ b/ReactNativeClient/lib/components/NoteBodyViewer/NoteBodyViewer.tsx @@ -0,0 +1,111 @@ +import { useRef, useMemo, useCallback } from 'react'; + +import Setting from 'lib/models/Setting'; +import useSource from './hooks/useSource'; +import useOnMessage from './hooks/useOnMessage'; +import useOnResourceLongPress from './hooks/useOnResourceLongPress'; + +const React = require('react'); +const { View } = require('react-native'); +const { WebView } = require('react-native-webview'); +const { themeStyle } = require('lib/components/global-style.js'); +const BackButtonDialogBox = require('lib/components/BackButtonDialogBox').default; +const { reg } = require('lib/registry.js'); + +interface Props { + themeId: number, + style: any, + noteBody: string, + noteMarkupLanguage: number, + highlightedKeywords: string[], + noteResources: any, + paddingBottom: number, + noteHash: string, + onJoplinLinkClick: Function, + onCheckboxChange?: Function, + onMarkForDownload?: Function, + onLoadEnd?: Function, +} + +export default function NoteBodyViewer(props:Props) { + const theme = themeStyle(props.themeId); + + const webViewStyle:any = useMemo(() => { + return { backgroundColor: theme.backgroundColor }; + }, [theme.backgroundColor]); + + const dialogBoxRef = useRef(null); + + const { source, injectedJs } = useSource( + props.noteBody, + props.noteMarkupLanguage, + props.themeId, + props.highlightedKeywords, + props.noteResources, + props.paddingBottom, + props.noteHash + ); + + const onResourceLongPress = useOnResourceLongPress( + props.onJoplinLinkClick, + dialogBoxRef + ); + + const onMessage = useOnMessage( + props.onCheckboxChange, + props.noteBody, + props.onMarkForDownload, + props.onJoplinLinkClick, + onResourceLongPress + ); + + const onLoadEnd = useCallback(() => { + if (props.onLoadEnd) props.onLoadEnd(); + }, [props.onLoadEnd]); + + function onError() { + reg.logger().error('WebView error') + } + + // 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. + // + // 2020-10-15: As we've now fully switched to WebKit for iOS (useWebKit=true) and + // since the WebView package went through many versions it's possible that + // the above no longer applies. + + return ( + + + + + ); +} \ No newline at end of file diff --git a/ReactNativeClient/lib/components/NoteBodyViewer/hooks/useOnMessage.ts b/ReactNativeClient/lib/components/NoteBodyViewer/hooks/useOnMessage.ts new file mode 100644 index 0000000000..410f3525b9 --- /dev/null +++ b/ReactNativeClient/lib/components/NoteBodyViewer/hooks/useOnMessage.ts @@ -0,0 +1,26 @@ +import { useCallback } from 'react'; +const shared = require('lib/components/shared/note-screen-shared'); + +export default function useOnMessage(onCheckboxChange:Function, noteBody:string, onMarkForDownload:Function, onJoplinLinkClick:Function, onResourceLongPress:Function) { + return useCallback((event:any) => { + // 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, noteBody); + if (onCheckboxChange) onCheckboxChange(newBody); + } else if (msg.indexOf('markForDownload:') === 0) { + const splittedMsg = msg.split(':'); + const resourceId = splittedMsg[1]; + if (onMarkForDownload) onMarkForDownload({ resourceId: resourceId }); + } else if (msg.startsWith('longclick:')) { + onResourceLongPress(msg); + } else if (msg.startsWith('joplin:')) { + onJoplinLinkClick(msg); + } else if (msg.startsWith('error:')) { + console.error('Webview injected script error: ' + msg); + } + }, [onCheckboxChange, noteBody, onMarkForDownload, onJoplinLinkClick, onResourceLongPress]); +} \ No newline at end of file diff --git a/ReactNativeClient/lib/components/NoteBodyViewer/hooks/useOnResourceLongPress.ts b/ReactNativeClient/lib/components/NoteBodyViewer/hooks/useOnResourceLongPress.ts new file mode 100644 index 0000000000..e92dc71989 --- /dev/null +++ b/ReactNativeClient/lib/components/NoteBodyViewer/hooks/useOnResourceLongPress.ts @@ -0,0 +1,48 @@ +import { useCallback } from 'react'; +import Setting from 'lib/models/Setting'; +import shim from 'lib/shim'; + +const { ToastAndroid } = require('react-native'); +const { _ } = require('lib/locale.js'); +const { reg } = require('lib/registry.js'); +const { dialogs } = require('lib/dialogs.js'); +const Resource = require('lib/models/Resource.js'); +const Share = require('react-native-share').default; + +export default function onResourceLongPress(onJoplinLinkClick:Function, dialogBoxRef:any) { + return useCallback(async (msg:string) => { + 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({ dialogbox: dialogBoxRef.current }, name, [ + { text: _('Open'), id: 'open' }, + { text: _('Share'), id: 'share' }, + ]); + + if (action === 'open') { + 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); + } + }, [onJoplinLinkClick]); +} diff --git a/ReactNativeClient/lib/components/NoteBodyViewer/hooks/useSource.ts b/ReactNativeClient/lib/components/NoteBodyViewer/hooks/useSource.ts new file mode 100644 index 0000000000..6e962f5018 --- /dev/null +++ b/ReactNativeClient/lib/components/NoteBodyViewer/hooks/useSource.ts @@ -0,0 +1,152 @@ +import { useEffect, useState, useMemo } from 'react'; +import shim from 'lib/shim'; +import Setting from 'lib/models/Setting'; +const { themeStyle } = require('lib/components/global-style.js'); +const markupLanguageUtils = require('lib/markupLanguageUtils'); +const { assetsToHeaders } = require('lib/joplin-renderer'); + +interface Source { + uri: string, + baseUrl: string, +} + +interface UseSourceResult { + source: Source, + injectedJs: string[], +} + +let markupToHtml_:any = null; + +function markupToHtml() { + if (markupToHtml_) return markupToHtml_; + markupToHtml_ = markupLanguageUtils.newMarkupToHtml(); + return markupToHtml_; +} + +export default function useSource(noteBody:string, noteMarkupLanguage:number, themeId:number, highlightedKeywords:string[], noteResources:any, paddingBottom:number, noteHash:string):UseSourceResult { + const [source, setSource] = useState(undefined); + const [injectedJs, setInjectedJs] = useState([]); + const [resourceLoadedTime, setResourceLoadedTime] = useState(0); + + const rendererTheme = useMemo(() => { + return { + bodyPaddingTop: '.8em', // Extra top padding on the rendered MD so it doesn't touch the border + bodyPaddingBottom: paddingBottom, // Extra bottom padding to make it possible to scroll past the action button (so that it doesn't overlap the text) + ...themeStyle(themeId), + }; + }, [themeId, paddingBottom]); + + useEffect(() => { + let cancelled = false; + + async function renderNote() { + const theme = themeStyle(themeId); + + const bodyToRender = noteBody || ''; + + const mdOptions = { + onResourceLoaded: () => { + setResourceLoadedTime(Date.now()); + }, + highlightedKeywords: highlightedKeywords, + resources: noteResources, + codeTheme: theme.codeThemeCss, + postMessageSyntax: 'window.joplinPostMessage_', + enableLongPress: shim.mobilePlatform() === 'android', // On iOS, there's already a built-on open/share menu + longPressDelay: 500, // TODO use system value + }; + + // Whenever a resource state changes, for example when it goes from "not downloaded" to "downloaded", the "noteResources" + // props changes, thus triggering a render. The **content** of this noteResources array however is not changed because + // it doesn't contain info about the resource download state. Because of that, if we were to use the markupToHtml() cache + // it wouldn't re-render at all. We don't need this cache in any way because this hook is only triggered when we know + // something has changed. + markupToHtml().clearCache(noteMarkupLanguage); + + const result = await markupToHtml().render( + noteMarkupLanguage, + bodyToRender, + rendererTheme, + mdOptions + ); + + if (cancelled) return; + + let html = result.html; + + const resourceDownloadMode = Setting.value('sync.resourceDownloadMode'); + + const js = []; + js.push('try {'); + js.push(shim.injectedJs('webviewLib')); + // 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). + js.push('window.joplinPostMessage_ = (msg, args) => { return window.ReactNativeWebView.postMessage(msg); };'); + js.push('webviewLib.initialize({ postMessage: msg => { return window.ReactNativeWebView.postMessage(msg); } });'); + js.push(` + const readyStateCheckInterval = setInterval(function() { + if (document.readyState === "complete") { + clearInterval(readyStateCheckInterval); + if ("${resourceDownloadMode}" === "manual") webviewLib.setupResourceManualDownload(); + const hash = "${noteHash}"; + // Gives it a bit of time before scrolling to the anchor + // so that images are loaded. + if (hash) { + setTimeout(() => { + const e = document.getElementById(hash); + if (!e) { + console.warn('Cannot find hash', hash); + return; + } + e.scrollIntoView(); + }, 500); + } + } + }, 10); + `); + js.push('} catch (e) {'); + js.push(' window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))'); + js.push(' true;'); + js.push('}'); + js.push('true;'); + + html = + ` + + + + + ${assetsToHeaders(result.pluginAssets, { asHtml: true })} + + + ${html} + + + `; + + const tempFile = `${Setting.value('resourceDir')}/NoteBodyViewer.html` + await shim.fsDriver().writeFile(tempFile, html, 'utf8'); + + if (cancelled) return; + + // Now that we are sending back a file instead of an HTML string, we're always sending back the + // same file. So we add a cache busting query parameter to it, to make sure that the WebView re-renders. + // + // `baseUrl` is where the images will be loaded from. So images must use a path relative to resourceDir. + setSource({ + uri: 'file://' + tempFile + '?r=' + Math.round(Math.random() * 100000000), + baseUrl: `file://${Setting.value('resourceDir')}/`, + }); + + setInjectedJs(js); + } + + renderNote(); + + return () => { + cancelled = true; + } + }, [resourceLoadedTime, noteBody, noteMarkupLanguage, themeId, rendererTheme, highlightedKeywords, noteResources, noteHash]); + + return { source, injectedJs }; +} \ No newline at end of file diff --git a/ReactNativeClient/lib/components/screens/note.js b/ReactNativeClient/lib/components/screens/Note.tsx similarity index 91% rename from ReactNativeClient/lib/components/screens/note.js rename to ReactNativeClient/lib/components/screens/Note.tsx index 1a2bc09323..763607339a 100644 --- a/ReactNativeClient/lib/components/screens/note.js +++ b/ReactNativeClient/lib/components/screens/Note.tsx @@ -1,16 +1,18 @@ -import FileViewer from 'react-native-file-viewer'; import AsyncActionQueue from '../../AsyncActionQueue'; +import UndoRedoService from 'lib/services/UndoRedoService'; +import uuid from 'lib/uuid'; +import Setting from 'lib/models/Setting'; +import shim from 'lib/shim'; +import NoteBodyViewer from 'lib/components/NoteBodyViewer/NoteBodyViewer'; +const FileViewer = require('react-native-file-viewer').default; const React = require('react'); const { Platform, Keyboard, View, TextInput, StyleSheet, Linking, Image, Share } = require('react-native'); const { connect } = require('react-redux'); -const uuid = require('lib/uuid').default; const { MarkdownEditor } = require('../../../MarkdownEditor/index.js'); const RNFS = require('react-native-fs'); const Note = require('lib/models/Note.js'); -const UndoRedoService = require('lib/services/UndoRedoService.js').default; const BaseItem = require('lib/models/BaseItem.js'); -const Setting = require('lib/models/Setting').default; const Resource = require('lib/models/Resource.js'); const Folder = require('lib/models/Folder.js'); const Clipboard = require('@react-native-community/clipboard').default; @@ -27,13 +29,11 @@ const { time } = require('lib/time-utils.js'); const { Checkbox } = require('lib/components/checkbox.js'); const { _ } = require('lib/locale'); const { reg } = require('lib/registry.js'); -const shim = require('lib/shim').default; const ResourceFetcher = require('lib/services/ResourceFetcher'); const { BaseScreenComponent } = require('lib/components/base-screen.js'); const { themeStyle, editorFont } = require('lib/components/global-style.js'); const { dialogs } = require('lib/dialogs.js'); const DialogBox = require('react-native-dialogbox').default; -const NoteBodyViewer = require('lib/components/NoteBodyViewer').default; const DocumentPicker = require('react-native-document-picker').default; const ImageResizer = require('react-native-image-resizer').default; const shared = require('lib/components/shared/note-screen-shared.js'); @@ -43,8 +43,10 @@ const ShareExtension = require('lib/ShareExtension.js').default; const CameraView = require('lib/components/CameraView').default; const urlUtils = require('lib/urlUtils'); +const emptyArray:any[] = []; + class NoteScreenComponent extends BaseScreenComponent { - static navigationOptions() { + static navigationOptions():any { return { header: null }; } @@ -152,7 +154,7 @@ class NoteScreenComponent extends BaseScreenComponent { this.setState({ noteTagDialogShown: false }); }; - this.onJoplinLinkClick_ = async msg => { + this.onJoplinLinkClick_ = async (msg:string) => { try { if (msg.indexOf('joplin://') === 0) { const resourceUrlInfo = urlUtils.parseResourceUrl(msg); @@ -195,7 +197,7 @@ class NoteScreenComponent extends BaseScreenComponent { } }; - this.refreshResource = async (resource, noteBody = null) => { + this.refreshResource = async (resource:any, noteBody:string = null) => { if (noteBody === null && this.state.note && this.state.note.body) noteBody = this.state.note.body; if (noteBody === null) return; @@ -203,9 +205,7 @@ class NoteScreenComponent extends BaseScreenComponent { if (resourceIds.indexOf(resource.id) >= 0) { shared.clearResourceCache(); const attachedResources = await shared.attachedResources(noteBody); - this.setState({ noteResources: attachedResources }, () => { - if (this.refs.noteBodyViewer) this.refs.noteBodyViewer.rebuildMd(); - }); + this.setState({ noteResources: attachedResources }); } }; @@ -231,6 +231,8 @@ class NoteScreenComponent extends BaseScreenComponent { this.screenHeader_undoButtonPress = this.screenHeader_undoButtonPress.bind(this); this.screenHeader_redoButtonPress = this.screenHeader_redoButtonPress.bind(this); this.body_selectionChange = this.body_selectionChange.bind(this); + this.onBodyViewerLoadEnd = this.onBodyViewerLoadEnd.bind(this); + this.onBodyViewerCheckboxChange = this.onBodyViewerCheckboxChange.bind(this); } undoRedoService_stackChange() { @@ -240,11 +242,11 @@ class NoteScreenComponent extends BaseScreenComponent { } }); } - async undoRedo(type) { + async undoRedo(type:string) { const undoState = await this.undoRedoService_[type](this.undoState()); if (!undoState) return; - this.setState((state) => { + this.setState((state:any) => { const newNote = Object.assign({}, state.note); newNote.body = undoState.body; return { @@ -271,7 +273,7 @@ class NoteScreenComponent extends BaseScreenComponent { this.styles_ = {}; // TODO: Clean up these style names and nesting - const styles = { + const styles:any = { screen: { flex: 1, backgroundColor: theme.backgroundColor, @@ -300,12 +302,6 @@ class NoteScreenComponent extends BaseScreenComponent { paddingLeft: theme.marginLeft, paddingRight: theme.marginRight, }, - noteBodyViewerPreview: { - borderTopColor: theme.dividerColor, - borderTopWidth: 1, - borderBottomColor: theme.dividerColor, - borderBottomWidth: 1, - }, checkbox: { color: theme.color, paddingRight: 10, @@ -319,6 +315,14 @@ class NoteScreenComponent extends BaseScreenComponent { }, }; + styles.noteBodyViewerPreview = { + ...styles.noteBodyViewer, + borderTopColor: theme.dividerColor, + borderTopWidth: 1, + borderBottomColor: theme.dividerColor, + borderBottomWidth: 1, + } + styles.titleContainer = { flex: 0, flexDirection: 'row', @@ -354,7 +358,7 @@ class NoteScreenComponent extends BaseScreenComponent { return shared.isModified(this); } - undoState(noteBody = null) { + undoState(noteBody:string = null) { return { body: noteBody === null ? this.state.note.body : noteBody, }; @@ -378,11 +382,11 @@ class NoteScreenComponent extends BaseScreenComponent { } } - onMarkForDownload(event) { + onMarkForDownload(event:any) { ResourceFetcher.instance().markForDownload(event.resourceId); } - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps:any) { if (this.doFocusUpdate_) { this.doFocusUpdate_ = false; this.focusUpdate(); @@ -413,13 +417,13 @@ class NoteScreenComponent extends BaseScreenComponent { if (this.undoRedoService_) this.undoRedoService_.off('stackChange', this.undoRedoService_stackChange); } - title_changeText(text) { + title_changeText(text:string) { shared.noteComponent_change(this, 'title', text); this.setState({ newAndNoTitleChangeNoteId: null }); this.scheduleSave(); } - body_changeText(text) { + body_changeText(text:string) { if (!this.undoRedoService_.canUndo) { this.undoRedoService_.push(this.undoState()); } else { @@ -429,7 +433,7 @@ class NoteScreenComponent extends BaseScreenComponent { this.scheduleSave(); } - body_selectionChange(event) { + body_selectionChange(event:any) { this.selection = event.nativeEvent.selection; } @@ -439,7 +443,7 @@ class NoteScreenComponent extends BaseScreenComponent { }; } - saveActionQueue(noteId) { + saveActionQueue(noteId:string) { if (!this.saveActionQueues_[noteId]) { this.saveActionQueues_[noteId] = new AsyncActionQueue(500); } @@ -450,13 +454,13 @@ class NoteScreenComponent extends BaseScreenComponent { this.saveActionQueue(this.state.note.id).push(this.makeSaveAction()); } - async saveNoteButton_press(folderId = null) { + async saveNoteButton_press(folderId:string = null) { await shared.saveNoteButton_press(this, folderId); Keyboard.dismiss(); } - async saveOneProperty(name, value) { + async saveOneProperty(name:string, value:any) { await shared.saveOneProperty(this, name, value); } @@ -492,32 +496,32 @@ class NoteScreenComponent extends BaseScreenComponent { } } - async imageDimensions(uri) { + async imageDimensions(uri:string) { return new Promise((resolve, reject) => { Image.getSize( uri, - (width, height) => { + (width:number, height:number) => { resolve({ width: width, height: height }); }, - error => { + (error:any) => { reject(error); } ); }); } - showImagePicker(options) { + showImagePicker(options:any) { return new Promise((resolve) => { - ImagePicker.launchImageLibrary(options, response => { + ImagePicker.launchImageLibrary(options, (response:any) => { resolve(response); }); }); } - async resizeImage(localFilePath, targetPath, mimeType) { + async resizeImage(localFilePath:string, targetPath:string, mimeType:string) { const maxSize = Resource.IMAGE_MAX_DIMENSION; - const dimensions = await this.imageDimensions(localFilePath); + const dimensions:any = await this.imageDimensions(localFilePath); reg.logger().info('Original dimensions ', dimensions); @@ -563,7 +567,7 @@ class NoteScreenComponent extends BaseScreenComponent { return true; } - async attachFile(pickerResponse, fileType) { + async attachFile(pickerResponse:any, fileType:string) { if (!pickerResponse) { // User has cancelled return; @@ -673,7 +677,7 @@ class NoteScreenComponent extends BaseScreenComponent { this.setState({ showCamera: true }); } - cameraView_onPhoto(data) { + cameraView_onPhoto(data:any) { this.attachFile( { uri: data.uri, @@ -723,7 +727,7 @@ class NoteScreenComponent extends BaseScreenComponent { this.setState({ alarmDialogShown: true }); } - async onAlarmDialogAccept(date) { + async onAlarmDialogAccept(date:Date) { const newNote = Object.assign({}, this.state.note); newNote.todo_due = date ? date.getTime() : 0; @@ -899,11 +903,11 @@ class NoteScreenComponent extends BaseScreenComponent { return output; } - async todoCheckbox_change(checked) { + async todoCheckbox_change(checked:boolean) { await this.saveOneProperty('todo_completed', checked ? time.unixMs() : 0); } - titleTextInput_contentSizeChange(event) { + titleTextInput_contentSizeChange(event:any) { if (!this.enableMultilineTitle_) return; const height = event.nativeEvent.contentSize.height; @@ -937,7 +941,7 @@ class NoteScreenComponent extends BaseScreenComponent { } } - async folderPickerOptions_valueChanged(itemValue) { + async folderPickerOptions_valueChanged(itemValue:any) { const note = this.state.note; const isProvisionalNote = this.props.provisionalNoteIds.includes(note.id); @@ -971,6 +975,19 @@ class NoteScreenComponent extends BaseScreenComponent { return this.folderPickerOptions_; } + onBodyViewerLoadEnd() { + shim.setTimeout(() => { + this.setState({ HACK_webviewLoadingState: 1 }); + shim.setTimeout(() => { + this.setState({ HACK_webviewLoadingState: 0 }); + }, 50); + }, 5); + } + + onBodyViewerCheckboxChange(newBody:string) { + this.saveOneProperty('body', newBody); + } + render() { if (this.state.isLoading) { return ( @@ -988,62 +1005,33 @@ class NoteScreenComponent extends BaseScreenComponent { return ; } + // Currently keyword highlighting is supported only when FTS is available. + const keywords = this.props.searchQuery && !!this.props.ftsEnabled ? this.props.highlightedWords : emptyArray; + let bodyComponent = null; if (this.state.mode == 'view' && !this.useBetaEditor()) { - const onCheckboxChange = newBody => { - this.saveOneProperty('body', newBody); - }; - - // Currently keyword highlighting is supported only when FTS is available. - let keywords = []; - if (this.props.searchQuery && !!this.props.ftsEnabled) { - keywords = this.props.highlightedWords; - } - // Note: as of 2018-12-29 it's important not to display the viewer if the note body is empty, // to avoid the HACK_webviewLoadingState related bug. bodyComponent = !note || !note.body.trim() ? null : ( { - onCheckboxChange(newBody); - }} + onCheckboxChange={this.onBodyViewerCheckboxChange} onMarkForDownload={this.onMarkForDownload} - onLoadEnd={() => { - shim.setTimeout(() => { - this.setState({ HACK_webviewLoadingState: 1 }); - shim.setTimeout(() => { - this.setState({ HACK_webviewLoadingState: 0 }); - }, 50); - }, 5); - }} + onLoadEnd={this.onBodyViewerLoadEnd} /> ); } else { - // autoFocus={fieldToFocus === 'body'} - - // Currently keyword highlighting is supported only when FTS is available. - let keywords = []; - if (this.props.searchQuery && !!this.props.ftsEnabled) { - keywords = this.props.highlightedWords; - } - - const onCheckboxChange = newBody => { - this.saveOneProperty('body', newBody); - }; - bodyComponent = this.useBetaEditor() // Note: blurOnSubmit is necessary to get multiline to work. // See https://github.com/facebook/react-native/issues/12717#issuecomment-327001997 @@ -1055,7 +1043,7 @@ class NoteScreenComponent extends BaseScreenComponent { value={note.body} borderColor={this.styles().markdownButtons.borderColor} markdownButtonsColor={this.styles().markdownButtons.color} - saveText={text => this.body_changeText(text)} + saveText={(text:string) => this.body_changeText(text)} blurOnSubmit={false} selectionColor={theme.textSelectionColor} keyboardAppearance={theme.keyboardAppearance} @@ -1063,29 +1051,18 @@ class NoteScreenComponent extends BaseScreenComponent { placeholderTextColor={theme.colorFaded} noteBodyViewer={{ onJoplinLinkClick: this.onJoplinLinkClick_, - ref: 'noteBodyViewer', - style: { - ...this.styles().noteBodyViewer, - ...this.styles().noteBodyViewerPreview, - }, + style: this.styles().noteBodyViewerPreview, + paddingBottom: 0, webViewStyle: theme, - note: note, + noteBody: note.body, + noteMarkupLanguage: note.markup_language, noteResources: this.state.noteResources, highlightedKeywords: keywords, themeId: this.props.themeId, noteHash: this.props.noteHash, - onCheckboxChange: newBody => { - onCheckboxChange(newBody); - }, + onCheckboxChange: this.onBodyViewerCheckboxChange, onMarkForDownload: this.onMarkForDownload, - onLoadEnd: () => { - shim.setTimeout(() => { - this.setState({ HACK_webviewLoadingState: 1 }); - shim.setTimeout(() => { - this.setState({ HACK_webviewLoadingState: 0 }); - }, 50); - }, 5); - }, + onLoadEnd: this.onBodyViewerLoadEnd, }} /> @@ -1112,7 +1089,7 @@ class NoteScreenComponent extends BaseScreenComponent { ref="noteBodyTextField" multiline={true} value={note.body} - onChangeText={(text) => this.body_changeText(text)} + onChangeText={(text:string) => this.body_changeText(text)} onSelectionChange={this.body_selectionChange} blurOnSubmit={false} selectionColor={theme.textSelectionColor} @@ -1198,7 +1175,7 @@ class NoteScreenComponent extends BaseScreenComponent { { + ref={(dialogbox:any) => { this.dialogbox = dialogbox; }} /> @@ -1208,7 +1185,7 @@ class NoteScreenComponent extends BaseScreenComponent { } } -const NoteScreen = connect(state => { +const NoteScreen = connect((state:any) => { return { noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null, noteHash: state.selectedNoteHash, @@ -1226,4 +1203,4 @@ const NoteScreen = connect(state => { }; })(NoteScreenComponent); -module.exports = { NoteScreen }; +export default NoteScreen; diff --git a/ReactNativeClient/lib/hooks/usePropsDebugger.ts b/ReactNativeClient/lib/hooks/usePropsDebugger.ts index a4de438fa1..340da207c8 100644 --- a/ReactNativeClient/lib/hooks/usePropsDebugger.ts +++ b/ReactNativeClient/lib/hooks/usePropsDebugger.ts @@ -1,6 +1,10 @@ +// Use this to show which props have been changed within a component. +// +// Usage: usePropsDebugger(props); + import useEffectDebugger from './useEffectDebugger'; -export default function usePropsDebugger(effectHook:any, props:any) { +export default function usePropsDebugger(props:any) { const dependencies:any[] = []; const dependencyNames:string[] = []; @@ -9,5 +13,5 @@ export default function usePropsDebugger(effectHook:any, props:any) { dependencyNames.push(k); } - useEffectDebugger(effectHook, dependencies, dependencyNames); + useEffectDebugger(() => {}, dependencies, dependencyNames); } diff --git a/ReactNativeClient/lib/joplin-renderer/MarkupToHtml.js b/ReactNativeClient/lib/joplin-renderer/MarkupToHtml.js index 2ad1b084ee..b92d2e9cac 100644 --- a/ReactNativeClient/lib/joplin-renderer/MarkupToHtml.js +++ b/ReactNativeClient/lib/joplin-renderer/MarkupToHtml.js @@ -63,6 +63,11 @@ class MarkupToHtml { return output; } + clearCache(markupLanguage) { + const r = this.renderer(markupLanguage); + if (r.clearCache) r.clearCache(); + } + async render(markupLanguage, markup, theme, options) { return this.renderer(markupLanguage).render(markup, theme, options); } diff --git a/ReactNativeClient/lib/joplin-renderer/MdToHtml.js b/ReactNativeClient/lib/joplin-renderer/MdToHtml.js index 41e1f5d0f9..8a0c3979d3 100644 --- a/ReactNativeClient/lib/joplin-renderer/MdToHtml.js +++ b/ReactNativeClient/lib/joplin-renderer/MdToHtml.js @@ -178,7 +178,11 @@ class MdToHtml { return html.substring(3, html.length - 5); } - // "style" here is really the theme, as returned by themeStyle() + clearCache() { + this.cachedOutputs_ = {}; + } + + // "theme" is the theme as returned by themeStyle() async render(body, theme = null, options = null) { options = Object.assign({}, { // In bodyOnly mode, the rendered Markdown is returned without the wrapper DIV diff --git a/ReactNativeClient/lib/services/ResourceEditWatcher/index.ts b/ReactNativeClient/lib/services/ResourceEditWatcher/index.ts index 0105c82cc1..3514d8db5b 100644 --- a/ReactNativeClient/lib/services/ResourceEditWatcher/index.ts +++ b/ReactNativeClient/lib/services/ResourceEditWatcher/index.ts @@ -127,7 +127,7 @@ export default class ResourceEditWatcher { // handle and once in the "raw" event handler, due to a bug in chokidar. So having // this check means we don't unecessarily save the resource twice when the file is // modified by the user. - this.logger().debug(`ResourceEditWatcher: No timestamp change - skip: ${resourceId}`); + this.logger().debug(`ResourceEditWatcher: No timestamp and file size change - skip: ${resourceId}`); return; } diff --git a/ReactNativeClient/root.js b/ReactNativeClient/root.js index c6c533da2f..81855b30e0 100644 --- a/ReactNativeClient/root.js +++ b/ReactNativeClient/root.js @@ -35,7 +35,7 @@ const { JoplinDatabase } = require('lib/joplin-database.js'); const { Database } = require('lib/database.js'); const { NotesScreen } = require('lib/components/screens/notes.js'); const { TagsScreen } = require('lib/components/screens/tags.js'); -const { NoteScreen } = require('lib/components/screens/note.js'); +const NoteScreen = require('lib/components/screens/Note').default; const { ConfigScreen } = require('lib/components/screens/config.js'); const { FolderScreen } = require('lib/components/screens/folder.js'); const { LogScreen } = require('lib/components/screens/log.js'); diff --git a/joplin.code-workspace b/joplin.code-workspace index 843366a0ac..7b92b18f5e 100644 --- a/joplin.code-workspace +++ b/joplin.code-workspace @@ -706,7 +706,12 @@ "ReactNativeClient/lib/components/CameraView.js": true, "ReactNativeClient/lib/components/NoteBodyViewer.js": true, "CliClient/tests/InMemoryCache.js": true, - "ReactNativeClient/lib/InMemoryCache.js": true + "ReactNativeClient/lib/InMemoryCache.js": true, + "ReactNativeClient/lib/components/NoteBodyViewer/hooks/useOnMessage.js": true, + "ReactNativeClient/lib/components/NoteBodyViewer/hooks/useOnResourceLongPress.js": true, + "ReactNativeClient/lib/components/NoteBodyViewer/hooks/useSource.js": true, + "ReactNativeClient/lib/components/NoteBodyViewer/NoteBodyViewer.js": true, + "ReactNativeClient/lib/components/screens/Note.js": true }, "spellright.language": [ "en"