1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-11-27 08:21:03 +02:00

Fixed webview issues

This commit is contained in:
Laurent Cozic 2020-10-16 11:56:21 +01:00
parent f537d22d7f
commit 1e0d2b7b86
17 changed files with 460 additions and 449 deletions

View File

@ -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

6
.gitignore vendored
View File

@ -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

View File

@ -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: {

View File

@ -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_);

View File

@ -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;

View File

@ -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 =
`
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
${assetsToHeaders(result.pluginAssets, { asHtml: true })}
</head>
<body>
${html}
</body>
</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 <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
//
// 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 (
<View style={this.props.style}>
<Async promiseFn={this.reloadNote} watchFn={this.watchFn}>
{(args:any) => {
const { data, error, isPending } = args;
if (error) {
console.error(error);
return <Text>{error.message}</Text>;
}
if (isPending) return null;
return (
<WebView
useWebKit={useWebkit}
allowingReadAccessToURL={`file://${Setting.value('resourceDir')}`}
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')}
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);
}
}}
/>
);
}}
</Async>
<BackButtonDialogBox
ref={(dialogbox:any) => {
this.dialogbox = dialogbox;
}}
/>
</View>
);
}
}

View File

@ -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 (
<View style={props.style}>
<WebView
theme={theme}
useWebKit={true}
allowingReadAccessToURL={`file://${Setting.value('resourceDir')}`}
style={webViewStyle}
source={source}
injectedJavaScript={injectedJs.join('\n')}
originWhitelist={['file://*', './*', 'http://*', 'https://*']}
mixedContentMode="always"
allowFileAccess={true} // TODO: Implement logic to avoid race condition between source and allowFileAccess
onLoadEnd={onLoadEnd}
onError={onError}
onMessage={onMessage}
/>
<BackButtonDialogBox ref={dialogBoxRef}/>
</View>
);
}

View File

@ -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]);
}

View File

@ -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]);
}

View File

@ -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<Source>(undefined);
const [injectedJs, setInjectedJs] = useState<string[]>([]);
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 =
`
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
${assetsToHeaders(result.pluginAssets, { asHtml: true })}
</head>
<body>
${html}
</body>
</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 };
}

View File

@ -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 <CameraView themeId={this.props.themeId} style={{ flex: 1 }} onPhoto={this.cameraView_onPhoto} onCancel={this.cameraView_onCancel} />;
}
// 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 : (
<NoteBodyViewer
onJoplinLinkClick={this.onJoplinLinkClick_}
ref="noteBodyViewer"
style={this.styles().noteBodyViewer}
webViewStyle={theme}
// Extra bottom padding to make it possible to scroll past the
// action button (so that it doesn't overlap the text)
paddingBottom="150"
note={note}
paddingBottom={150}
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}
/>
);
} 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 {
<SelectDateTimeDialog themeId={this.props.themeId} shown={this.state.alarmDialogShown} date={dueDate} onAccept={this.onAlarmDialogAccept} onReject={this.onAlarmDialogReject} />
<DialogBox
ref={dialogbox => {
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;

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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

View File

@ -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;
}

View File

@ -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');

View File

@ -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"