diff --git a/.eslintignore b/.eslintignore index 78059d16f..ad01345b8 100644 --- a/.eslintignore +++ b/.eslintignore @@ -852,6 +852,9 @@ packages/app-mobile/components/CustomButton.js.map packages/app-mobile/components/Dropdown.d.ts packages/app-mobile/components/Dropdown.js packages/app-mobile/components/Dropdown.js.map +packages/app-mobile/components/ExtendedWebView.d.ts +packages/app-mobile/components/ExtendedWebView.js +packages/app-mobile/components/ExtendedWebView.js.map packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.d.ts packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js.map diff --git a/.gitignore b/.gitignore index bf6a0a3dd..0bd9ca57a 100644 --- a/.gitignore +++ b/.gitignore @@ -840,6 +840,9 @@ packages/app-mobile/components/CustomButton.js.map packages/app-mobile/components/Dropdown.d.ts packages/app-mobile/components/Dropdown.js packages/app-mobile/components/Dropdown.js.map +packages/app-mobile/components/ExtendedWebView.d.ts +packages/app-mobile/components/ExtendedWebView.js +packages/app-mobile/components/ExtendedWebView.js.map packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.d.ts packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js.map diff --git a/packages/app-mobile/components/ExtendedWebView.tsx b/packages/app-mobile/components/ExtendedWebView.tsx new file mode 100644 index 000000000..50c9dfb2f --- /dev/null +++ b/packages/app-mobile/components/ExtendedWebView.tsx @@ -0,0 +1,143 @@ +// Wraps react-native-webview. Allows loading HTML directly. + +import * as React from 'react'; + +import { + forwardRef, Ref, useEffect, useImperativeHandle, useRef, useState, +} from 'react'; +import { WebView, WebViewMessageEvent } from 'react-native-webview'; +import { WebViewErrorEvent, WebViewEvent, WebViewSource } from 'react-native-webview/lib/WebViewTypes'; + +import Setting from '@joplin/lib/models/Setting'; +import { themeStyle } from '@joplin/lib/theme'; +import { Theme } from '@joplin/lib/themes/type'; +import shim from '@joplin/lib/shim'; +import { StyleProp, ViewStyle } from 'react-native'; + + +export interface WebViewControl { + // Evaluate the given [script] in the context of the page. + // Unlike react-native-webview/WebView, this does not need to return true. + injectJS(script: string): void; +} + +interface SourceFileUpdateEvent { + uri: string; + baseUrl: string; + + filePath: string; +} + +type OnMessageCallback = (event: WebViewMessageEvent)=> void; +type OnErrorCallback = (event: WebViewErrorEvent)=> void; +type OnLoadEndCallback = (event: WebViewEvent)=> void; +type OnFileUpdateCallback = (event: SourceFileUpdateEvent)=> void; + +interface Props { + themeId: number; + + // If HTML is still being loaded, [html] should be an empty string. + html: string; + + // Allow a secure origin to load content from any other origin. + // Defaults to 'never'. + // See react-native-webview's prop with the same name. + mixedContentMode?: 'never' | 'always'; + + // Initial javascript. Must evaluate to true. + injectedJavaScript: string; + + style?: StyleProp; + onMessage: OnMessageCallback; + onError: OnErrorCallback; + onLoadEnd?: OnLoadEndCallback; + + // Triggered when the file containing [html] is overwritten with new content. + onFileUpdate?: OnFileUpdateCallback; +} + +const ExtendedWebView = (props: Props, ref: Ref) => { + const theme: Theme = themeStyle(props.themeId); + const webviewRef = useRef(null); + const [source, setSource] = useState(undefined); + + useImperativeHandle(ref, (): WebViewControl => { + return { + injectJS(js: string) { + webviewRef.current.injectJavaScript(` + try { + ${js} + } + catch(e) { + logMessage('Error in injected JS:' + e, e); + throw e; + }; + + true;`); + }, + }; + }); + + useEffect(() => { + let cancelled = false; + async function createHtmlFile() { + const tempFile = `${Setting.value('resourceDir')}/NoteEditor.html`; + await shim.fsDriver().writeFile(tempFile, props.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. + const newSource = { + uri: `file://${tempFile}?r=${Math.round(Math.random() * 100000000)}`, + baseUrl: `file://${Setting.value('resourceDir')}/`, + }; + setSource(newSource); + + props.onFileUpdate?.({ + ...newSource, + filePath: tempFile, + }); + } + + if (props.html && props.html.length > 0) { + void createHtmlFile(); + } else { + setSource(undefined); + } + + return () => { + cancelled = true; + }; + }, [props.html, props.onFileUpdate]); + + // - `setSupportMultipleWindows` must be `true` for security reasons: + // https://github.com/react-native-webview/react-native-webview/releases/tag/v11.0.0 + // - `scrollEnabled` prevents iOS from scrolling the document (has no effect on Android) + // when an editable region (e.g. a the full-screen NoteEditor) is focused. + return ( + + ); +}; + +export default forwardRef(ExtendedWebView); diff --git a/packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.tsx b/packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.tsx index 14a66bae8..df623f106 100644 --- a/packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.tsx +++ b/packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.tsx @@ -1,16 +1,14 @@ import { useRef, useCallback } from 'react'; -import Setting from '@joplin/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('../global-style.js'); +import { View } from 'react-native'; import BackButtonDialogBox from '../BackButtonDialogBox'; import { reg } from '@joplin/lib/registry'; +import ExtendedWebView from '../ExtendedWebView'; interface Props { themeId: number; @@ -32,11 +30,9 @@ const webViewStyle = { }; export default function NoteBodyViewer(props: Props) { - const theme = themeStyle(props.themeId); - const dialogBoxRef = useRef(null); - const { source, injectedJs } = useSource( + const { html, injectedJs } = useSource( props.noteBody, props.noteMarkupLanguage, props.themeId, @@ -67,6 +63,8 @@ export default function NoteBodyViewer(props: Props) { reg.logger().error('WebView error'); } + const BackButtonDialogBox_ = BackButtonDialogBox as any; + // On iOS scalesPageToFit work like this: // // Find the widest image, resize it *and everything else* by x% so that @@ -88,21 +86,14 @@ export default function NoteBodyViewer(props: Props) { // 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. - - const BackButtonDialogBox_ = BackButtonDialogBox as any; - return ( - (undefined); + const [html, setHtml] = useState(''); const [injectedJs, setInjectedJs] = useState([]); const [resourceLoadedTime, setResourceLoadedTime] = useState(0); const [isFirstRender, setIsFirstRender] = useState(true); @@ -169,20 +165,7 @@ export default function useSource(noteBody: string, noteMarkupLanguage: number, `; - 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')}/`, - }); - + setHtml(html); setInjectedJs(js); } @@ -194,7 +177,7 @@ export default function useSource(noteBody: string, noteMarkupLanguage: number, if (isFirstRender) { setIsFirstRender(false); - setSource(undefined); + setHtml(''); setInjectedJs([]); } else { void renderNote(); @@ -206,5 +189,5 @@ export default function useSource(noteBody: string, noteMarkupLanguage: number, // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied }, effectDependencies); - return { source, injectedJs }; + return { html, injectedJs }; } diff --git a/packages/app-mobile/components/NoteEditor/NoteEditor.tsx b/packages/app-mobile/components/NoteEditor/NoteEditor.tsx index 43b4cdf60..70a58dd8f 100644 --- a/packages/app-mobile/components/NoteEditor/NoteEditor.tsx +++ b/packages/app-mobile/components/NoteEditor/NoteEditor.tsx @@ -3,11 +3,11 @@ import shim from '@joplin/lib/shim'; import { themeStyle } from '@joplin/lib/theme'; import EditLinkDialog from './EditLinkDialog'; import { defaultSearchState, SearchPanel } from './SearchPanel'; +import ExtendedWebView from '../ExtendedWebView'; const React = require('react'); import { forwardRef, RefObject, useImperativeHandle } from 'react'; import { useEffect, useMemo, useState, useCallback, useRef } from 'react'; -const { WebView } = require('react-native-webview'); import { View, ViewStyle } from 'react-native'; const { editorFont } = require('../global-style'); @@ -207,7 +207,6 @@ const useEditorControl = ( }; function NoteEditor(props: Props, ref: any) { - const [source, setSource] = useState(undefined); const webviewRef = useRef(null); const setInitialSelectionJS = props.initialSelection ? ` @@ -284,18 +283,9 @@ function NoteEditor(props: Props, ref: any) { searchStateRef.current = searchState; }, [searchState]); - // Runs [js] in the context of the CodeMirror frame. + // / Runs [js] in the context of the CodeMirror frame. const injectJS = (js: string) => { - webviewRef.current.injectJavaScript(` - try { - ${js} - } - catch(e) { - logMessage('Error in injected JS:' + e, e); - throw e; - }; - - true;`); + webviewRef.current.injectJS(js); }; const editorControl = useEditorControl( @@ -306,26 +296,6 @@ function NoteEditor(props: Props, ref: any) { return editorControl; }); - useEffect(() => { - let cancelled = false; - async function createHtmlFile() { - const tempFile = `${Setting.value('resourceDir')}/NoteEditor.html`; - await shim.fsDriver().writeFile(tempFile, html, 'utf8'); - if (cancelled) return; - - setSource({ - uri: `file://${tempFile}?r=${Math.round(Math.random() * 100000000)}`, - baseUrl: `file://${Setting.value('resourceDir')}/`, - }); - } - - void createHtmlFile(); - - return () => { - cancelled = true; - }; - }, [html]); - const onMessage = useCallback((event: any) => { const data = event.nativeEvent.data; @@ -382,16 +352,10 @@ function NoteEditor(props: Props, ref: any) { } }, [props.onSelectionChange, props.onUndoRedoDepthChange, props.onChange, editorControl]); - // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied const onError = useCallback(() => { console.error('NoteEditor: webview error'); }, []); - - // - `setSupportMultipleWindows` must be `true` for security reasons: - // https://github.com/react-native-webview/react-native-webview/releases/tag/v11.0.0 - // - `scrollEnabled` prevents iOS from scrolling the document (has no effect on Android) - // when the editor is focused. return ( -