diff --git a/.eslintignore b/.eslintignore index 6f9c09e39b9..923e61fdc88 100644 --- a/.eslintignore +++ b/.eslintignore @@ -497,11 +497,21 @@ packages/app-mobile/components/FolderPicker.js packages/app-mobile/components/Icon.js packages/app-mobile/components/Modal.js packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js +packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.test.js +packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.js +packages/app-mobile/components/NoteBodyViewer/bundledJs/noteBodyViewerBundle.js +packages/app-mobile/components/NoteBodyViewer/bundledJs/types.js +packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/addPluginAssets.js +packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/makeResourceModel.js +packages/app-mobile/components/NoteBodyViewer/hooks/useContentScripts.js packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.test.js packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.js packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.js packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js +packages/app-mobile/components/NoteBodyViewer/hooks/useRenderer.js +packages/app-mobile/components/NoteBodyViewer/hooks/useRerenderHandler.js packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js +packages/app-mobile/components/NoteBodyViewer/types.js packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js packages/app-mobile/components/NoteEditor/EditLinkDialog.js packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.js diff --git a/.gitignore b/.gitignore index 1acac8706bf..94709bad68b 100644 --- a/.gitignore +++ b/.gitignore @@ -477,11 +477,21 @@ packages/app-mobile/components/FolderPicker.js packages/app-mobile/components/Icon.js packages/app-mobile/components/Modal.js packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js +packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.test.js +packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.js +packages/app-mobile/components/NoteBodyViewer/bundledJs/noteBodyViewerBundle.js +packages/app-mobile/components/NoteBodyViewer/bundledJs/types.js +packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/addPluginAssets.js +packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/makeResourceModel.js +packages/app-mobile/components/NoteBodyViewer/hooks/useContentScripts.js packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.test.js packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.js packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.js packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js +packages/app-mobile/components/NoteBodyViewer/hooks/useRenderer.js +packages/app-mobile/components/NoteBodyViewer/hooks/useRerenderHandler.js packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js +packages/app-mobile/components/NoteBodyViewer/types.js packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js packages/app-mobile/components/NoteEditor/EditLinkDialog.js packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.js diff --git a/packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.tsx b/packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.tsx index a9d19946799..4b189834996 100644 --- a/packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.tsx +++ b/packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.tsx @@ -1,14 +1,19 @@ -import { useRef, useCallback } from 'react'; +import * as React from 'react'; -import useSource from './hooks/useSource'; -import useOnMessage, { HandleMessageCallback, HandleScrollCallback, OnMarkForDownloadCallback } from './hooks/useOnMessage'; -import useOnResourceLongPress from './hooks/useOnResourceLongPress'; - -const React = require('react'); +import useOnMessage, { HandleMessageCallback, OnMarkForDownloadCallback } from './hooks/useOnMessage'; +import { useRef, useCallback, useState, useMemo } from 'react'; import { View } from 'react-native'; import BackButtonDialogBox from '../BackButtonDialogBox'; -import { reg } from '@joplin/lib/registry'; import ExtendedWebView, { WebViewControl } from '../ExtendedWebView'; +import useOnResourceLongPress from './hooks/useOnResourceLongPress'; +import useRenderer from './hooks/useRenderer'; +import { OnWebViewMessageHandler } from './types'; +import useRerenderHandler from './hooks/useRerenderHandler'; +import useSource from './hooks/useSource'; +import Setting from '@joplin/lib/models/Setting'; +import uuid from '@joplin/lib/uuid'; +import { PluginStates } from '@joplin/lib/services/plugins/reducer'; +import useContentScripts from './hooks/useContentScripts'; interface Props { themeId: number; @@ -24,24 +29,18 @@ interface Props { onCheckboxChange?: HandleMessageCallback; onRequestEditResource?: HandleMessageCallback; onMarkForDownload?: OnMarkForDownloadCallback; - onScroll: HandleScrollCallback; + onScroll: (scrollTop: number)=> void; onLoadEnd?: ()=> void; + pluginStates: PluginStates; } export default function NoteBodyViewer(props: Props) { const dialogBoxRef = useRef(null); const webviewRef = useRef(null); - const { html, injectedJs } = useSource( - props.noteBody, - props.noteMarkupLanguage, - props.themeId, - props.highlightedKeywords, - props.noteResources, - props.paddingBottom, - props.noteHash, - props.initialScroll, - ); + const onScroll = useCallback(async (scrollTop: number) => { + props.onScroll(scrollTop); + }, [props.onScroll]); const onResourceLongPress = useOnResourceLongPress( { @@ -51,60 +50,70 @@ export default function NoteBodyViewer(props: Props) { dialogBoxRef, ); - const onMessage = useOnMessage( - props.noteBody, - { - onCheckboxChange: props.onCheckboxChange, - onMarkForDownload: props.onMarkForDownload, - onJoplinLinkClick: props.onJoplinLinkClick, - onRequestEditResource: props.onRequestEditResource, - onResourceLongPress, - onMainContainerScroll: props.onScroll, - }, - ); + const onPostMessage = useOnMessage(props.noteBody, { + onMarkForDownload: props.onMarkForDownload, + onJoplinLinkClick: props.onJoplinLinkClick, + onRequestEditResource: props.onRequestEditResource, + onCheckboxChange: props.onCheckboxChange, + onResourceLongPress, + }); + + const [webViewLoaded, setWebViewLoaded] = useState(false); + const [onWebViewMessage, setOnWebViewMessage] = useState(()=>()=>{}); + + + // The renderer can write to whichever temporary directory we choose. As such, + // we use a subdirectory of the main temporary directory for security reasons. + const tempDir = useMemo(() => { + return `${Setting.value('tempDir')}/${uuid.createNano()}`; + }, []); + + const renderer = useRenderer({ + webViewLoaded, + onScroll, + webviewRef, + onPostMessage, + setOnWebViewMessage, + tempDir, + }); + + const contentScripts = useContentScripts(props.pluginStates); + + useRerenderHandler({ + renderer, + noteBody: props.noteBody, + noteMarkupLanguage: props.noteMarkupLanguage, + themeId: props.themeId, + highlightedKeywords: props.highlightedKeywords, + noteResources: props.noteResources, + noteHash: props.noteHash, + initialScroll: props.initialScroll, + + paddingBottom: props.paddingBottom, + + contentScripts, + }); const onLoadEnd = useCallback(() => { + setWebViewLoaded(true); if (props.onLoadEnd) props.onLoadEnd(); }, [props.onLoadEnd]); - function onError() { - 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 - // 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. + const { html, injectedJs } = useSource(tempDir, props.themeId); + return ( diff --git a/packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.test.ts b/packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.test.ts new file mode 100644 index 00000000000..9933cba5d42 --- /dev/null +++ b/packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.test.ts @@ -0,0 +1,161 @@ +import Setting from '@joplin/lib/models/Setting'; +import Renderer, { RendererSettings, RendererSetupOptions } from './Renderer'; +import shim from '@joplin/lib/shim'; +import { MarkupLanguage } from '@joplin/renderer'; + +const defaultRendererSettings: RendererSettings = { + theme: JSON.stringify({ cacheKey: 'test' }), + onResourceLoaded: ()=>{}, + highlightedKeywords: [], + resources: {}, + codeTheme: 'atom-one-light.css', + noteHash: '', + initialScroll: 0, + + createEditPopupSyntax: '', + destroyEditPopupSyntax: '', + + pluginSettings: {}, + requestPluginSetting: ()=>{}, +}; + +const makeRenderer = (options: Partial) => { + const defaultSetupOptions: RendererSetupOptions = { + settings: { + safeMode: false, + tempDir: Setting.value('tempDir'), + resourceDir: Setting.value('resourceDir'), + resourceDownloadMode: 'auto', + }, + fsDriver: shim.fsDriver(), + pluginOptions: {}, + }; + return new Renderer({ ...options, ...defaultSetupOptions }); +}; + +const getRenderedContent = () => { + return document.querySelector('#joplin-container-content > #rendered-md'); +}; + +describe('Renderer', () => { + beforeEach(() => { + const contentContainer = document.createElement('div'); + contentContainer.id = 'joplin-container-content'; + document.body.appendChild(contentContainer); + + const pluginAssetsContainer = document.createElement('div'); + pluginAssetsContainer.id = 'joplin-container-pluginAssetsContainer'; + document.body.appendChild(pluginAssetsContainer); + }); + + afterEach(() => { + document.querySelector('#joplin-container-content')?.remove(); + document.querySelector('#joplin-container-pluginAssetsContainer')?.remove(); + }); + + test('should support rendering markdown', async () => { + const renderer = makeRenderer({}); + await renderer.rerender( + { language: MarkupLanguage.Markdown, markup: '**test**' }, + defaultRendererSettings, + ); + + expect(getRenderedContent().innerHTML.trim()).toBe('

test

'); + + await renderer.rerender( + { language: MarkupLanguage.Markdown, markup: '*test*' }, + defaultRendererSettings, + ); + expect(getRenderedContent().innerHTML.trim()).toBe('

test

'); + }); + + test('should support adding and removing plugin scripts', async () => { + const renderer = makeRenderer({}); + await renderer.setExtraContentScriptsAndRerender([ + { + id: 'test', + js: ` + ((context) => { + return { + plugin: (markdownIt) => { + markdownIt.renderer.rules.fence = (tokens, idx) => { + return '
Test from ' + context.pluginId + '
'; + }; + }, + }; + }) + `, + assetPath: Setting.value('tempDir'), + pluginId: 'com.example.test-plugin', + }, + ]); + await renderer.rerender( + { language: MarkupLanguage.Markdown, markup: '```\ntest\n```' }, + defaultRendererSettings, + ); + expect(getRenderedContent().innerHTML.trim()).toBe('
Test from com.example.test-plugin
'); + + // Should support removing plugin scripts + await renderer.setExtraContentScriptsAndRerender([]); + await renderer.rerender( + { language: MarkupLanguage.Markdown, markup: '```\ntest\n```' }, + defaultRendererSettings, + ); + expect(getRenderedContent().innerHTML.trim()).not.toContain('com.example.test-plugin'); + expect(getRenderedContent().querySelectorAll('pre.joplin-source')).toHaveLength(1); + }); + + test('should call .requestPluginSetting when a setting is missing', async () => { + const renderer = makeRenderer({}); + + const requestPluginSetting = jest.fn(); + const rerender = (pluginSettings: Record) => { + return renderer.rerender( + { language: MarkupLanguage.Markdown, markup: '```\ntest\n```' }, + { ...defaultRendererSettings, pluginSettings, requestPluginSetting }, + ); + }; + + await rerender({}); + expect(requestPluginSetting).toHaveBeenCalledTimes(0); + + const pluginId = 'com.example.test-plugin'; + await renderer.setExtraContentScriptsAndRerender([ + { + id: 'test-content-script', + js: ` + (() => { + return { + plugin: (markdownIt, options) => { + const settingValue = options.settingValue('setting'); + markdownIt.renderer.rules.fence = (tokens, idx) => { + return '
Setting value: ' + settingValue + '
'; + }; + }, + }; + }) + `, + assetPath: Setting.value('tempDir'), + pluginId, + }, + ]); + + // Should call .requestPluginSetting for missing settings + expect(requestPluginSetting).toHaveBeenCalledTimes(1); + await rerender({}); + expect(requestPluginSetting).toHaveBeenCalledTimes(2); + expect(requestPluginSetting).toHaveBeenLastCalledWith('com.example.test-plugin', 'setting'); + + // Should still render + expect(getRenderedContent().querySelector('#setting-value').innerHTML).toBe('Setting value: undefined'); + + // Should expect only namespaced plugin settings + await rerender({ 'setting': 'test' }); + expect(requestPluginSetting).toHaveBeenCalledTimes(3); + + // Should not request plugin settings when all settings are present. + await rerender({ [`${pluginId}.setting`]: 'test' }); + expect(requestPluginSetting).toHaveBeenCalledTimes(3); + expect(getRenderedContent().querySelector('#setting-value').innerHTML).toBe('Setting value: test'); + }); +}); diff --git a/packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.ts b/packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.ts new file mode 100644 index 00000000000..32f2faea86a --- /dev/null +++ b/packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.ts @@ -0,0 +1,207 @@ +import { MarkupLanguage, MarkupToHtml } from '@joplin/renderer'; +import type { MarkupToHtmlConverter, RenderResultPluginAsset, FsDriver as RendererFsDriver } from '@joplin/renderer/types'; +import makeResourceModel from './utils/makeResourceModel'; +import addPluginAssets from './utils/addPluginAssets'; +import { ExtraContentScriptSource } from './types'; +import { ExtraContentScript } from '@joplin/lib/services/plugins/utils/loadContentScripts'; + +export interface RendererSetupOptions { + settings: { + safeMode: boolean; + tempDir: string; + resourceDir: string; + resourceDownloadMode: string; + }; + fsDriver: RendererFsDriver; + pluginOptions: Record; +} + +export interface RendererSettings { + theme: string; + onResourceLoaded: ()=> void; + highlightedKeywords: string[]; + resources: Record; + codeTheme: string; + noteHash: string; + initialScroll: number; + + createEditPopupSyntax: string; + destroyEditPopupSyntax: string; + + pluginSettings: Record; + requestPluginSetting: (pluginId: string, settingKey: string)=> void; +} + +export interface MarkupRecord { + language: MarkupLanguage; + markup: string; +} + +export default class Renderer { + private markupToHtml: MarkupToHtmlConverter; + private lastSettings: RendererSettings|null = null; + private extraContentScripts: ExtraContentScript[] = []; + private lastRenderMarkup: MarkupRecord|null = null; + + public constructor(private setupOptions: RendererSetupOptions) { + this.recreateMarkupToHtml(); + } + + private recreateMarkupToHtml() { + this.markupToHtml = new MarkupToHtml({ + extraRendererRules: this.extraContentScripts, + fsDriver: this.setupOptions.fsDriver, + isSafeMode: this.setupOptions.settings.safeMode, + tempDir: this.setupOptions.settings.tempDir, + ResourceModel: makeResourceModel(this.setupOptions.settings.resourceDir), + pluginOptions: this.setupOptions.pluginOptions, + }); + } + + public async setExtraContentScriptsAndRerender( + extraContentScripts: ExtraContentScriptSource[], + ) { + this.extraContentScripts = extraContentScripts.map(script => { + const scriptModule = (eval(script.js))({ + pluginId: script.pluginId, + contentScriptId: script.id, + }); + + if (!scriptModule.plugin) { + throw new Error(` + Expected content script ${script.id} to export a function that returns an object with a "plugin" property. + Found: ${scriptModule}, which has keys ${Object.keys(scriptModule)}. + `); + } + + return { + ...script, + module: scriptModule, + }; + }); + this.recreateMarkupToHtml(); + + // If possible, rerenders with the last rendering settings. The goal + // of this is to reduce the number of IPC calls between the viewer and + // React Native. We want the first render to be as fast as possible. + if (this.lastRenderMarkup) { + await this.rerender(this.lastRenderMarkup, this.lastSettings); + } + } + + public async rerender(markup: MarkupRecord, settings: RendererSettings) { + this.lastSettings = settings; + this.lastRenderMarkup = markup; + + const options = { + onResourceLoaded: settings.onResourceLoaded, + highlightedKeywords: settings.highlightedKeywords, + resources: settings.resources, + codeTheme: settings.codeTheme, + postMessageSyntax: 'window.joplinPostMessage_', + enableLongPress: true, + + // Show an 'edit' popup over SVG images + editPopupFiletypes: ['image/svg+xml'], + createEditPopupSyntax: settings.createEditPopupSyntax, + destroyEditPopupSyntax: settings.destroyEditPopupSyntax, + + settingValue: (pluginId: string, settingName: string) => { + const settingKey = `${pluginId}.${settingName}`; + + if (!(settingKey in settings.pluginSettings)) { + // This should make the setting available on future renders. + settings.requestPluginSetting(pluginId, settingName); + return undefined; + } + + return settings.pluginSettings[settingKey]; + }, + }; + + this.markupToHtml.clearCache(markup.language); + + const contentContainer = document.getElementById('joplin-container-content'); + + let html = ''; + let pluginAssets: RenderResultPluginAsset[] = []; + try { + const result = await this.markupToHtml.render( + markup.language, + markup.markup, + JSON.parse(settings.theme), + options, + ); + html = result.html; + pluginAssets = result.pluginAssets; + } catch (error) { + if (!contentContainer) { + alert(`Renderer error: ${error}`); + } else { + contentContainer.innerText = ` + Error: ${error} + + ${error.stack ?? ''} + `; + } + throw error; + } + + contentContainer.innerHTML = html; + addPluginAssets(pluginAssets); + + this.afterRender(settings); + } + + private afterRender(renderSettings: RendererSettings) { + const readyStateCheckInterval = setInterval(() => { + if (document.readyState === 'complete') { + clearInterval(readyStateCheckInterval); + if (this.setupOptions.settings.resourceDownloadMode === 'manual') { + (window as any).webviewLib.setupResourceManualDownload(); + } + + const hash = renderSettings.noteHash; + const initialScroll = renderSettings.initialScroll; + + // Don't scroll to a hash if we're given initial scroll (initial scroll + // overrides scrolling to a hash). + if ((initialScroll ?? null) !== null) { + const scrollingElement = document.scrollingElement ?? document.documentElement; + scrollingElement.scrollTop = initialScroll; + } else if (hash) { + // Gives it a bit of time before scrolling to the anchor + // so that images are loaded. + setTimeout(() => { + const e = document.getElementById(hash); + if (!e) { + console.warn('Cannot find hash', hash); + return; + } + e.scrollIntoView(); + }, 500); + } + } + }, 10); + + // Used by some parts of the renderer (e.g. to rerender mermaid.js diagrams). + document.dispatchEvent(new Event('joplin-noteDidUpdate')); + } + + public clearCache(markupLanguage: MarkupLanguage) { + this.markupToHtml.clearCache(markupLanguage); + } + + private extraCssElements: Record = {}; + public setExtraCss(key: string, css: string) { + if (this.extraCssElements.hasOwnProperty(key)) { + this.extraCssElements[key].remove(); + } + + const extraCssElement = document.createElement('style'); + extraCssElement.appendChild(document.createTextNode(css)); + document.head.appendChild(extraCssElement); + + this.extraCssElements[key] = extraCssElement; + } +} diff --git a/packages/app-mobile/components/NoteBodyViewer/bundledJs/noteBodyViewerBundle.ts b/packages/app-mobile/components/NoteBodyViewer/bundledJs/noteBodyViewerBundle.ts new file mode 100644 index 00000000000..07392221679 --- /dev/null +++ b/packages/app-mobile/components/NoteBodyViewer/bundledJs/noteBodyViewerBundle.ts @@ -0,0 +1,59 @@ + +import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger'; +import { NoteViewerLocalApi, NoteViewerRemoteApi, RendererWebViewOptions } from './types'; +import Renderer from './Renderer'; + +declare global { + interface Window { + rendererWebViewOptions: RendererWebViewOptions; + } +} + +declare const webviewLib: any; + +const messenger = new WebViewToRNMessenger( + 'note-viewer', + null, +); + +(window as any).joplinPostMessage_ = (message: string, _args: any) => { + return messenger.remoteApi.onPostMessage(message); +}; + +(window as any).webviewApi = { + postMessage: messenger.remoteApi.onPostPluginMessage, +}; + +webviewLib.initialize({ + postMessage: (message: string) => { + messenger.remoteApi.onPostMessage(message); + }, +}); + +const renderer = new Renderer({ + ...window.rendererWebViewOptions, + fsDriver: messenger.remoteApi.fsDriver, +}); + +messenger.setLocalInterface({ + renderer, + jumpToHash: (hash: string) => { + location.hash = `#${hash}`; + }, +}); + +const lastScrollTop: number|null = null; +const onMainContentScroll = () => { + const newScrollTop = document.scrollingElement.scrollTop; + if (lastScrollTop !== newScrollTop) { + messenger.remoteApi.onScroll(newScrollTop); + } +}; + +// Listen for events on both scrollingElement and window +// - On Android, scrollingElement.addEventListener('scroll', callback) doesn't call callback on +// scroll. However, window.addEventListener('scroll', callback) does. +// - iOS needs a listener to be added to scrollingElement -- events aren't received when +// the listener is added to window with window.addEventListener('scroll', ...). +document.scrollingElement.addEventListener('scroll', onMainContentScroll); +window.addEventListener('scroll', onMainContentScroll); diff --git a/packages/app-mobile/components/NoteBodyViewer/bundledJs/types.ts b/packages/app-mobile/components/NoteBodyViewer/bundledJs/types.ts new file mode 100644 index 00000000000..61ae314e5f0 --- /dev/null +++ b/packages/app-mobile/components/NoteBodyViewer/bundledJs/types.ts @@ -0,0 +1,31 @@ +import type { FsDriver as RendererFsDriver } from '@joplin/renderer/types'; +import Renderer from './Renderer'; + +export interface RendererWebViewOptions { + settings: { + safeMode: boolean; + tempDir: string; + resourceDir: string; + resourceDownloadMode: string; + }; + pluginOptions: Record; +} + +export interface ExtraContentScriptSource { + id: string; + js: string; + assetPath: string; + pluginId: string; +} + +export interface NoteViewerLocalApi { + renderer: Renderer; + jumpToHash: (hash: string)=> void; +} + +export interface NoteViewerRemoteApi { + onScroll(scrollTop: number): void; + onPostMessage(message: string): void; + onPostPluginMessage(contentScriptId: string, message: any): Promise; + fsDriver: RendererFsDriver; +} diff --git a/packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/addPluginAssets.ts b/packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/addPluginAssets.ts new file mode 100644 index 00000000000..e55d75c02e4 --- /dev/null +++ b/packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/addPluginAssets.ts @@ -0,0 +1,76 @@ +import { RenderResultPluginAsset } from '@joplin/renderer/types'; + +type PluginAssetRecord = { + element: HTMLElement; +}; +const pluginAssetsAdded_: Record = {}; + +// Note that this function keeps track of what's been added so as not to +// add the same CSS files multiple times. +// +// Shared with app-desktop/gui-note-viewer. +// +// TODO: If possible, refactor such that this function is not duplicated. +const addPluginAssets = (assets: RenderResultPluginAsset[]) => { + if (!assets) return; + + const pluginAssetsContainer = document.getElementById('joplin-container-pluginAssetsContainer'); + + const processedAssetIds = []; + + for (let i = 0; i < assets.length; i++) { + const asset = assets[i]; + + // # and ? can be used in valid paths and shouldn't be treated as the start of a query or fragment + const encodedPath = asset.path + .replace(/#/g, '%23') + .replace(/\?/g, '%3F'); + + const assetId = asset.name ? asset.name : encodedPath; + + processedAssetIds.push(assetId); + + if (pluginAssetsAdded_[assetId]) continue; + + let element = null; + + if (asset.mime === 'application/javascript') { + element = document.createElement('script'); + element.src = encodedPath; + pluginAssetsContainer.appendChild(element); + } else if (asset.mime === 'text/css') { + element = document.createElement('link'); + element.rel = 'stylesheet'; + element.href = encodedPath; + pluginAssetsContainer.appendChild(element); + } + + pluginAssetsAdded_[assetId] = { + element, + }; + } + + // Once we have added the relevant assets, we also remove those that + // are no longer needed. It's necessary in particular for the CSS + // generated by noteStyle - if we don't remove it, we might end up + // with two or more stylesheet and that will create conflicts. + // + // It was happening for example when automatically switching from + // light to dark theme, and then back to light theme - in that case + // the viewer would remain dark because it would use the dark + // stylesheet that would still be in the DOM. + for (const [assetId, asset] of Object.entries(pluginAssetsAdded_)) { + if (!processedAssetIds.includes(assetId)) { + try { + asset.element.remove(); + } catch (error) { + // We don't throw an exception but we log it since + // it shouldn't happen + console.warn('Tried to remove an asset but got an error', error); + } + pluginAssetsAdded_[assetId] = null; + } + } +}; + +export default addPluginAssets; diff --git a/packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/makeResourceModel.ts b/packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/makeResourceModel.ts new file mode 100644 index 00000000000..753d64276d7 --- /dev/null +++ b/packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/makeResourceModel.ts @@ -0,0 +1,16 @@ +import { isResourceUrl, isSupportedImageMimeType, resourceFilename, resourceFullPath, resourceUrlToId } from '@joplin/lib/models/utils/resourceUtils'; +import { OptionsResourceModel } from '@joplin/renderer/types'; + +const makeResourceModel = (resourceDirPath: string): OptionsResourceModel => { + return { + isResourceUrl, + urlToId: resourceUrlToId, + filename: resourceFilename, + isSupportedImageMimeType, + fullPath: (resource, encryptedBlob) => { + return resourceFullPath(resource, resourceDirPath, encryptedBlob); + }, + }; +}; + +export default makeResourceModel; diff --git a/packages/app-mobile/components/NoteBodyViewer/hooks/useContentScripts.ts b/packages/app-mobile/components/NoteBodyViewer/hooks/useContentScripts.ts new file mode 100644 index 00000000000..90f7e9a60f4 --- /dev/null +++ b/packages/app-mobile/components/NoteBodyViewer/hooks/useContentScripts.ts @@ -0,0 +1,107 @@ +import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect'; +import { dirname } from '@joplin/lib/path-utils'; +import { ContentScriptType } from '@joplin/lib/services/plugins/api/types'; +import { PluginStates } from '@joplin/lib/services/plugins/reducer'; +import shim from '@joplin/lib/shim'; +import { useRef, useState } from 'react'; +import { ExtraContentScriptSource } from '../bundledJs/types'; +import Logger from '@joplin/utils/Logger'; + +const logger = Logger.create('NoteBodyViewer/hooks/useContentScripts'); + +// Most of the time, we don't actually need to reload the content scripts from a file, +// which can be slow. +// +// As such, we cache content scripts and do two renders: +// 1. The first render uses the cached content scripts. +// While the first render is happening, we load content scripts from disk and compare them +// to the cache. +// If the same, we skip the second render. +// 2. The second render happens only if the cached content scripts changed. +// +type ContentScriptsCache = Record; +let contentScriptsCache: ContentScriptsCache = {}; + +const useContentScripts = (pluginStates: PluginStates) => { + const [contentScripts, setContentScripts] = useState(() => { + const initialContentScripts = []; + + for (const pluginId in pluginStates) { + if (pluginId in contentScriptsCache) { + initialContentScripts.push(...contentScriptsCache[pluginId]); + } + } + + return initialContentScripts; + }); + + const contentScriptsRef = useRef(null); + contentScriptsRef.current = contentScripts; + + // We load content scripts asynchronously because dynamic require doesn't + // work in React Native. + useAsyncEffect(async (event) => { + const newContentScripts: ExtraContentScriptSource[] = []; + const oldContentScripts = contentScriptsRef.current; + let differentFromLastContentScripts = false; + const newContentScriptsCache: ContentScriptsCache = {}; + + logger.debug('Loading content scripts...'); + + for (const pluginId in pluginStates) { + const markdownItContentScripts = pluginStates[pluginId].contentScripts[ContentScriptType.MarkdownItPlugin]; + if (!markdownItContentScripts) continue; + const loadedPluginContentScripts: ExtraContentScriptSource[] = []; + + for (const contentScript of markdownItContentScripts) { + logger.info('Loading content script from', contentScript.path); + const content = await shim.fsDriver().readFile(contentScript.path, 'utf8'); + if (event.cancelled) return; + + const contentScriptModule = `(function () { + const module = { exports: null }; + const exports = {}; + + ${content} + + return (module.exports || exports).default; + })()`; + + if (contentScriptModule.length > 1024 * 1024) { + const size = Math.round(contentScriptModule.length / 1024) / 1024; + logger.warn( + `Plugin ${pluginId}:`, + `Loaded large content script with size ${size} MiB and ID ${contentScript.id}.`, + 'Large content scripts can slow down the renderer.', + ); + } + + if (oldContentScripts[newContentScripts.length]?.js !== contentScriptModule) { + differentFromLastContentScripts = true; + } + + loadedPluginContentScripts.push({ + id: contentScript.id, + js: contentScriptModule, + assetPath: dirname(contentScript.path), + pluginId: pluginId, + }); + } + + newContentScriptsCache[pluginId] = loadedPluginContentScripts; + newContentScripts.push(...loadedPluginContentScripts); + } + + differentFromLastContentScripts ||= newContentScripts.length !== oldContentScripts.length; + if (differentFromLastContentScripts) { + contentScriptsCache = newContentScriptsCache; + setContentScripts(newContentScripts); + } else { + logger.debug(`Re-using all ${oldContentScripts.length} content scripts.`); + } + }, [pluginStates, setContentScripts]); + + return contentScripts; +}; + +export default useContentScripts; diff --git a/packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.ts b/packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.ts index c1b682184f4..89e0a76696e 100644 --- a/packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.ts +++ b/packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.ts @@ -3,7 +3,6 @@ import shared from '@joplin/lib/components/shared/note-screen-shared'; export type HandleMessageCallback = (message: string)=> void; export type OnMarkForDownloadCallback = (resource: { resourceId: string })=> void; -export type HandleScrollCallback = (scrollTop: number)=> void; interface MessageCallbacks { onMarkForDownload?: OnMarkForDownloadCallback; @@ -11,7 +10,6 @@ interface MessageCallbacks { onResourceLongPress: HandleMessageCallback; onRequestEditResource?: HandleMessageCallback; onCheckboxChange: HandleMessageCallback; - onMainContainerScroll: HandleScrollCallback; } export default function useOnMessage( @@ -26,18 +24,9 @@ export default function useOnMessage( // Thus, useCallback should depend on each callback individually. const { onMarkForDownload, onResourceLongPress, onCheckboxChange, onRequestEditResource, onJoplinLinkClick, - onMainContainerScroll, } = callbacks; - return useCallback((event: any) => { - // 2021-05-19: Historically this was unescaped twice as it was - // apparently needed after an upgrade to RN 58 (or 59). However this is - // no longer needed and in fact would break certain URLs so it can be - // removed. Keeping the comment here anyway in case we find some URLs - // that end up being broken after removing the double unescaping. - // https://github.com/laurent22/joplin/issues/4494 - const msg = event.nativeEvent.data; - + return useCallback((msg: string) => { const isScrollMessage = msg.startsWith('onscroll:'); // Scroll messages are very frequent so we avoid logging them. @@ -46,15 +35,7 @@ export default function useOnMessage( console.info('Got IPC message: ', msg); } - if (isScrollMessage) { - const eventData = JSON.parse(msg.substring(msg.indexOf(':') + 1)); - - if (typeof eventData.scrollTop !== 'number') { - throw new Error(`Invalid scroll message, ${msg}`); - } - - onMainContainerScroll?.(eventData.scrollTop); - } else if (msg.indexOf('checkboxclick:') === 0) { + if (msg.indexOf('checkboxclick:') === 0) { const newBody = shared.toggleCheckbox(msg, noteBody); onCheckboxChange?.(newBody); } else if (msg.indexOf('markForDownload:') === 0) { @@ -79,6 +60,5 @@ export default function useOnMessage( onJoplinLinkClick, onResourceLongPress, onRequestEditResource, - onMainContainerScroll, ]); } diff --git a/packages/app-mobile/components/NoteBodyViewer/hooks/useRenderer.ts b/packages/app-mobile/components/NoteBodyViewer/hooks/useRenderer.ts new file mode 100644 index 00000000000..c8582898ff9 --- /dev/null +++ b/packages/app-mobile/components/NoteBodyViewer/hooks/useRenderer.ts @@ -0,0 +1,79 @@ +import { Dispatch, RefObject, SetStateAction, useEffect, useMemo } from 'react'; +import { WebViewControl } from '../../ExtendedWebView'; +import { OnScrollCallback, OnWebViewMessageHandler } from '../types'; +import RNToWebViewMessenger from '../../../utils/ipc/RNToWebViewMessenger'; +import { NoteViewerLocalApi, NoteViewerRemoteApi } from '../bundledJs/types'; +import shim from '@joplin/lib/shim'; +import { WebViewMessageEvent } from 'react-native-webview'; +import PluginService from '@joplin/lib/services/plugins/PluginService'; +import Logger from '@joplin/utils/Logger'; + +const logger = Logger.create('useRenderer'); + +interface Props { + webviewRef: RefObject; + onScroll: OnScrollCallback; + onPostMessage: (message: string)=> void; + setOnWebViewMessage: Dispatch>; + webViewLoaded: boolean; + + tempDir: string; +} + +const onPostPluginMessage = async (contentScriptId: string, message: any) => { + logger.debug(`Handling message from content script: ${contentScriptId}:`, message); + + const pluginService = PluginService.instance(); + const pluginId = pluginService.pluginIdByContentScriptId(contentScriptId); + if (!pluginId) { + throw new Error(`Plugin not found for content script with ID ${contentScriptId}`); + } + + const plugin = pluginService.pluginById(pluginId); + return plugin.emitContentScriptMessage(contentScriptId, message); +}; + +const useRenderer = (props: Props) => { + const messenger = useMemo(() => { + const fsDriver = shim.fsDriver(); + const localApi = { + onScroll: props.onScroll, + onPostMessage: props.onPostMessage, + onPostPluginMessage, + fsDriver: { + writeFile: async (path: string, content: string, encoding?: string) => { + if (!await fsDriver.exists(props.tempDir)) { + await fsDriver.mkdir(props.tempDir); + } + // To avoid giving the WebView access to the entire main tempDir, + // we use props.tempDir (which should be different). + path = fsDriver.resolveRelativePathWithinDir(props.tempDir, path); + return await fsDriver.writeFile(path, content, encoding); + }, + exists: fsDriver.exists, + cacheCssToFile: fsDriver.cacheCssToFile, + }, + }; + return new RNToWebViewMessenger( + 'note-viewer', props.webviewRef, localApi, + ); + }, [props.onScroll, props.onPostMessage, props.webviewRef, props.tempDir]); + + useEffect(() => { + props.setOnWebViewMessage(() => (event: WebViewMessageEvent) => { + messenger.onWebViewMessage(event); + }); + }, [messenger, props.setOnWebViewMessage]); + + useEffect(() => { + if (props.webViewLoaded) { + messenger.onWebViewLoaded(); + } + }, [messenger, props.webViewLoaded]); + + return useMemo(() => { + return messenger.remoteApi.renderer; + }, [messenger]); +}; + +export default useRenderer; diff --git a/packages/app-mobile/components/NoteBodyViewer/hooks/useRerenderHandler.ts b/packages/app-mobile/components/NoteBodyViewer/hooks/useRerenderHandler.ts new file mode 100644 index 00000000000..0dce161f16e --- /dev/null +++ b/packages/app-mobile/components/NoteBodyViewer/hooks/useRerenderHandler.ts @@ -0,0 +1,174 @@ +import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect'; +import usePrevious from '@joplin/lib/hooks/usePrevious'; +import { themeStyle } from '@joplin/lib/theme'; +import { MarkupLanguage } from '@joplin/renderer'; +import useEditPopup from './useEditPopup'; +import Renderer from '../bundledJs/Renderer'; +import { useEffect, useState } from 'react'; +import Logger from '@joplin/utils/Logger'; +import { ExtraContentScriptSource } from '../bundledJs/types'; +import Setting from '@joplin/lib/models/Setting'; + +interface Props { + renderer: Renderer; + + noteBody: string; + noteMarkupLanguage: MarkupLanguage; + themeId: number; + + highlightedKeywords: string[]; + noteResources: string[]; + noteHash: string; + initialScroll: number|undefined; + + paddingBottom: number; + + contentScripts: ExtraContentScriptSource[]; +} + +const onlyCheckboxHasChangedHack = (previousBody: string, newBody: string) => { + if (previousBody.length !== newBody.length) return false; + + for (let i = 0; i < previousBody.length; i++) { + const c1 = previousBody.charAt(i); + const c2 = newBody.charAt(i); + + if (c1 !== c2) { + if (c1 === ' ' && (c2 === 'x' || c2 === 'X')) continue; + if (c2 === ' ' && (c1 === 'x' || c1 === 'X')) continue; + return false; + } + } + + return true; +}; + +const logger = Logger.create('useRerenderHandler'); + +const useRerenderHandler = (props: Props) => { + const { createEditPopupSyntax, destroyEditPopupSyntax, editPopupCss } = useEditPopup(props.themeId); + const [lastResourceLoadCounter, setLastResourceLoadCounter] = useState(0); + const [pluginSettingKeys, setPluginSettingKeys] = useState>({}); + + // 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 unnecessary 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. + // + // 2022-05-03: However we sometimes need the HTML to be updated, even when + // only the body has changed - for example when attaching a resource, or + // when adding text via speech recognition. So the logic has been narrowed + // down so that updates are skipped only when checkbox has been changed. + // Checkboxes still work as expected, without making the note scroll, and + // other text added to the note is displayed correctly. + // + // IMPORTANT: KEEP noteBody AS THE FIRST dependency in the array as the + // below logic rely on this. + const effectDependencies = [ + props.noteBody, props.noteMarkupLanguage, props.renderer, props.highlightedKeywords, + props.noteHash, props.noteResources, props.themeId, props.paddingBottom, lastResourceLoadCounter, + createEditPopupSyntax, destroyEditPopupSyntax, pluginSettingKeys, + ]; + const previousDeps = usePrevious(effectDependencies, []); + const changedDeps = effectDependencies.reduce((accum: any, dependency: any, index: any) => { + if (dependency !== previousDeps[index]) { + return { ...accum, [index]: true }; + } + return accum; + }, {}); + const onlyNoteBodyHasChanged = Object.keys(changedDeps).length === 1 && changedDeps[0]; + const onlyCheckboxesHaveChanged = previousDeps[0] && changedDeps[0] && onlyCheckboxHasChangedHack(previousDeps[0], props.noteBody); + const previousHash = usePrevious(props.noteHash, ''); + const hashChanged = previousHash !== props.noteHash; + + useEffect(() => { + // 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. + props.renderer.clearCache(props.noteMarkupLanguage); + }, [lastResourceLoadCounter, props.renderer, props.noteMarkupLanguage]); + + useEffect(() => { + void props.renderer.setExtraContentScriptsAndRerender(props.contentScripts); + }, [props.contentScripts, props.renderer]); + + useAsyncEffect(async event => { + if (onlyNoteBodyHasChanged && onlyCheckboxesHaveChanged) { + logger.info('Only a checkbox has changed - not updating HTML'); + return; + } + + const pluginSettings: Record = { }; + for (const key in pluginSettingKeys) { + pluginSettings[key] = Setting.value(`plugin-${key}`); + } + let newPluginSettingKeys = pluginSettingKeys; + + const theme = themeStyle(props.themeId); + const config = { + // We .stringify the theme to avoid a JSON serialization error involving + // the color package. + theme: JSON.stringify({ + bodyPaddingTop: '0.8em', + bodyPaddingBottom: props.paddingBottom, + + ...theme, + }), + codeTheme: theme.codeThemeCss, + + onResourceLoaded: () => { + // Force a rerender when a resource loads + setLastResourceLoadCounter(lastResourceLoadCounter + 1); + }, + highlightedKeywords: props.highlightedKeywords, + resources: props.noteResources, + + // If the hash changed, we don't set initial scroll -- we want to scroll to the hash + // instead. + initialScroll: (previousHash && hashChanged) ? undefined : props.initialScroll, + noteHash: props.noteHash, + + pluginSettings, + requestPluginSetting: (pluginId: string, settingKey: string) => { + // Don't trigger additional renders + if (event.cancelled) return; + + const key = `${pluginId}.${settingKey}`; + logger.debug(`Request plugin setting: plugin-${key}`); + + if (!(key in newPluginSettingKeys)) { + newPluginSettingKeys = { ...newPluginSettingKeys, [`${pluginId}.${settingKey}`]: true }; + setPluginSettingKeys(newPluginSettingKeys); + } + }, + + createEditPopupSyntax, + destroyEditPopupSyntax, + }; + + try { + logger.debug('Starting render...'); + + await props.renderer.rerender({ + language: props.noteMarkupLanguage, + markup: props.noteBody, + }, config); + + logger.debug('Render complete.'); + } catch (error) { + logger.error('Render failed:', error); + } + }, effectDependencies); + + useEffect(() => { + props.renderer.setExtraCss('edit-popup', editPopupCss); + }, [editPopupCss, props.renderer]); +}; + +export default useRerenderHandler; diff --git a/packages/app-mobile/components/NoteBodyViewer/hooks/useSource.ts b/packages/app-mobile/components/NoteBodyViewer/hooks/useSource.ts index ef6d61fdce2..037deb4590f 100644 --- a/packages/app-mobile/components/NoteBodyViewer/hooks/useSource.ts +++ b/packages/app-mobile/components/NoteBodyViewer/hooks/useSource.ts @@ -1,286 +1,87 @@ -import { useEffect, useState, useMemo, useRef } from 'react'; +import { useMemo } from 'react'; import shim from '@joplin/lib/shim'; import Setting from '@joplin/lib/models/Setting'; +import { RendererWebViewOptions } from '../bundledJs/types'; import { themeStyle } from '../../global-style'; -import markupLanguageUtils from '@joplin/lib/markupLanguageUtils'; -import useEditPopup from './useEditPopup'; -import Logger from '@joplin/utils/Logger'; -import { assetsToHeaders } from '@joplin/renderer'; -const logger = Logger.create('NoteBodyViewer/useSource'); - -interface UseSourceResult { - // [html] can be null if the note is still being rendered. - html: string|null; - injectedJs: string[]; -} - -function usePrevious(value: any, initialValue: any = null): any { - const ref = useRef(initialValue); - useEffect(() => { - ref.current = value; - }); - return ref.current; -} - -const onlyCheckboxHasChangedHack = (previousBody: string, newBody: string) => { - if (previousBody.length !== newBody.length) return false; - - for (let i = 0; i < previousBody.length; i++) { - const c1 = previousBody.charAt(i); - const c2 = newBody.charAt(i); - - if (c1 !== c2) { - if (c1 === ' ' && (c2 === 'x' || c2 === 'X')) continue; - if (c2 === ' ' && (c1 === 'x' || c1 === 'X')) continue; - return false; +const useSource = (tempDirPath: string, themeId: number) => { + const injectedJs = useMemo(() => { + const subValues = Setting.subValues('markdown.plugin', Setting.toPlainObject()); + const pluginOptions: any = {}; + for (const n in subValues) { + pluginOptions[n] = { enabled: subValues[n] }; } - } - return true; -}; - -export default function useSource( - noteBody: string, - noteMarkupLanguage: number, - themeId: number, - highlightedKeywords: string[], - noteResources: any, - paddingBottom: number, - noteHash: string, - initialScroll: number|null, -): UseSourceResult { - const [html, setHtml] = useState(''); - const [injectedJs, setInjectedJs] = useState([]); - const [resourceLoadedTime, setResourceLoadedTime] = useState(0); - const [isFirstRender, setIsFirstRender] = useState(true); - - const paddingTop = '.8em'; - - const rendererTheme = useMemo(() => { - return { - bodyPaddingTop: paddingTop, // 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), + const rendererWebViewOptions: RendererWebViewOptions = { + settings: { + safeMode: Setting.value('isSafeMode'), + tempDir: tempDirPath, + resourceDir: Setting.value('resourceDir'), + resourceDownloadMode: Setting.value('sync.resourceDownloadMode'), + }, + pluginOptions, }; - }, [themeId, paddingBottom]); - - const markupToHtml = useMemo(() => { - return markupLanguageUtils.newMarkupToHtml(); - // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied - }, [isFirstRender]); - - // 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 unnecessary 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. - // - // 2022-05-03: However we sometimes need the HTML to be updated, even when - // only the body has changed - for example when attaching a resource, or - // when adding text via speech recognition. So the logic has been narrowed - // down so that updates are skipped only when checkbox has been changed. - // Checkboxes still work as expected, without making the note scroll, and - // other text added to the note is displayed correctly. - // - // IMPORTANT: KEEP noteBody AS THE FIRST dependency in the array as the - // below logic rely on this. - const effectDependencies = [noteBody, resourceLoadedTime, noteMarkupLanguage, themeId, rendererTheme, highlightedKeywords, noteResources, noteHash, isFirstRender, markupToHtml]; - const previousDeps = usePrevious(effectDependencies, []); - const changedDeps = effectDependencies.reduce((accum: any, dependency: any, index: any) => { - if (dependency !== previousDeps[index]) { - return { ...accum, [index]: true }; - } - return accum; - }, {}); - const onlyNoteBodyHasChanged = Object.keys(changedDeps).length === 1 && changedDeps[0]; - const onlyCheckboxesHaveChanged = previousDeps[0] && changedDeps[0] && onlyCheckboxHasChangedHack(previousDeps[0], noteBody); - - const { createEditPopupSyntax, destroyEditPopupSyntax, editPopupCss } = useEditPopup(themeId); - - useEffect(() => { - if (onlyNoteBodyHasChanged && onlyCheckboxesHaveChanged) { - logger.info('Only a checkbox has changed - not updating HTML'); - return () => {}; - } - - 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: true, - - // Show an 'edit' popup over SVG images - editPopupFiletypes: ['image/svg+xml'], - createEditPopupSyntax, - destroyEditPopupSyntax, - }; - - // 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 scrollingElement = document.scrollingElement; - let lastScrollTop; - const onMainContentScroll = () => { - const newScrollTop = scrollingElement.scrollTop; - if (lastScrollTop !== newScrollTop) { - const eventData = { scrollTop: newScrollTop }; - window.ReactNativeWebView.postMessage('onscroll:' + JSON.stringify(eventData)); - } - }; - - // Listen for events on both scrollingElement and window - // - On Android, scrollingElement.addEventListener('scroll', callback) doesn't call callback on - // scroll. However, window.addEventListener('scroll', callback) does. - // - iOS needs a listener to be added to scrollingElement -- events aren't received when - // the listener is added to window with window.addEventListener('scroll', ...). - scrollingElement.addEventListener('scroll', onMainContentScroll); - window.addEventListener('scroll', onMainContentScroll); - - const scrollContentToPosition = (position) => { - scrollingElement.scrollTop = position; - }; - `); - js.push(` - const readyStateCheckInterval = setInterval(function() { - if (document.readyState === "complete") { - clearInterval(readyStateCheckInterval); - if ("${resourceDownloadMode}" === "manual") webviewLib.setupResourceManualDownload(); - - const hash = "${noteHash}"; - const initialScroll = ${JSON.stringify(initialScroll)}; - - // Don't scroll to a hash if we're given initial scroll (initial scroll - // overrides scrolling to a hash). - if ((initialScroll ?? null) !== null) { - scrollContentToPosition(initialScroll); - } else if (hash) { - // Gives it a bit of time before scrolling to the anchor - // so that images are loaded. - 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(' console.error(e);'); - js.push(' window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))'); - js.push(' true;'); - js.push('}'); - js.push('true;'); - - // iOS doesn't automatically adjust the WebView's font size to match users' - // accessibility settings. To do this, we need to tell it to match the system font. - // See https://github.com/ionic-team/capacitor/issues/2748#issuecomment-612923135 - const iOSSpecificCss = ` - @media screen { - :root body { - font: -apple-system-body; - } - } - `; - const defaultCss = ` - code { - white-space: pre-wrap; - overflow-x: hidden; - } - - body { - padding-left: ${Number(theme.marginLeft)}px; - padding-right: ${Number(theme.marginRight)}px; + return ` + window.rendererWebViewOptions = ${JSON.stringify(rendererWebViewOptions)}; + + if (!window.injectedJsLoaded) { + window.injectedJsLoaded = true; + + ${shim.injectedJs('webviewLib')} + ${shim.injectedJs('noteBodyViewerBundle')} + } + `; + }, [tempDirPath]); + + const [paddingLeft, paddingRight] = useMemo(() => { + const theme = themeStyle(themeId); + return [theme.marginLeft, theme.marginRight]; + }, [themeId]); + + const html = useMemo(() => { + // iOS doesn't automatically adjust the WebView's font size to match users' + // accessibility settings. To do this, we need to tell it to match the system font. + // See https://github.com/ionic-team/capacitor/issues/2748#issuecomment-612923135 + const iOSSpecificCss = ` + @media screen { + :root body { + font: -apple-system-body; } - `; - - html = - ` - - - - - - - ${assetsToHeaders(result.pluginAssets, { asHtml: true })} - - - ${html} - - - `; - - setHtml(html); - setInjectedJs(js); - } - - // When mounted, we need to render the webview in two stages; - // - First without any source, so that all webview props are setup properly - // - Secondly with the source to actually render the note - // This is necessary to prevent a race condition that could cause an ERR_ACCESS_DENIED error - // https://github.com/react-native-webview/react-native-webview/issues/656#issuecomment-551312436 - - if (isFirstRender) { - setIsFirstRender(false); - setHtml(''); - setInjectedJs([]); - } else { - void renderNote(); - } - - return () => { - cancelled = true; - }; - // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied - }, effectDependencies); + } + `; + const defaultCss = ` + code { + white-space: pre-wrap; + overflow-x: hidden; + } + + body { + padding-left: ${Number(paddingLeft)}px; + padding-right: ${Number(paddingRight)}px; + } + `; + + return ` + + + + + + + + +
+
+ + + `; + }, [paddingLeft, paddingRight]); return { html, injectedJs }; -} +}; + +export default useSource; diff --git a/packages/app-mobile/components/NoteBodyViewer/types.ts b/packages/app-mobile/components/NoteBodyViewer/types.ts new file mode 100644 index 00000000000..ba74c6da085 --- /dev/null +++ b/packages/app-mobile/components/NoteBodyViewer/types.ts @@ -0,0 +1,4 @@ +import { WebViewMessageEvent } from 'react-native-webview'; + +export type OnScrollCallback = (scrollTop: number)=> void; +export type OnWebViewMessageHandler = (event: WebViewMessageEvent)=> void; diff --git a/packages/app-mobile/components/screens/Note.tsx b/packages/app-mobile/components/screens/Note.tsx index 61e14cac62c..4b773d69d3b 100644 --- a/packages/app-mobile/components/screens/Note.tsx +++ b/packages/app-mobile/components/screens/Note.tsx @@ -1474,6 +1474,7 @@ class NoteScreenComponent extends BaseScreenComponent implements B onLoadEnd={this.onBodyViewerLoadEnd} onScroll={this.onBodyViewerScroll} initialScroll={this.lastBodyScroll} + pluginStates={this.props.plugins} /> ); } else { diff --git a/packages/app-mobile/gulpfile.ts b/packages/app-mobile/gulpfile.ts index 6872fd92d94..0d00e635a8d 100644 --- a/packages/app-mobile/gulpfile.ts +++ b/packages/app-mobile/gulpfile.ts @@ -20,6 +20,7 @@ gulp.task('buildInjectedJs', gulp.series( 'buildCodeMirrorEditor', 'buildJsDrawEditor', 'buildPluginBackgroundScript', + 'buildNoteViewerBundle', 'copyWebviewLib', )); @@ -30,6 +31,7 @@ gulp.task('watchInjectedJs', gulp.series( 'watchCodeMirrorEditor', 'watchJsDrawEditor', 'watchPluginBackgroundScript', + 'watchNoteViewerBundle', ), )); diff --git a/packages/app-mobile/tools/buildInjectedJs/gulpTasks.ts b/packages/app-mobile/tools/buildInjectedJs/gulpTasks.ts index 2648046d99a..3f095c2ce6e 100644 --- a/packages/app-mobile/tools/buildInjectedJs/gulpTasks.ts +++ b/packages/app-mobile/tools/buildInjectedJs/gulpTasks.ts @@ -19,6 +19,11 @@ const pluginBackgroundPageBundle = new BundledFile( `${mobileDir}/plugins/PluginRunner/backgroundPage/pluginRunnerBackgroundPage.ts`, ); +const noteViewerBundle = new BundledFile( + 'noteBodyViewerBundle', + `${mobileDir}/components/NoteBodyViewer/bundledJs/noteBodyViewerBundle.ts`, +); + const gulpTasks = { beforeBundle: { fn: () => mkdirp(outputDir), @@ -29,6 +34,9 @@ const gulpTasks = { buildJsDrawEditor: { fn: () => jsDrawBundle.build(), }, + buildNoteViewerBundle: { + fn: () => noteViewerBundle.build(), + }, watchCodeMirrorEditor: { fn: () => codeMirrorBundle.startWatching(), }, @@ -41,6 +49,9 @@ const gulpTasks = { watchPluginBackgroundScript: { fn: () => pluginBackgroundPageBundle.startWatching(), }, + watchNoteViewerBundle: { + fn: () => noteViewerBundle.startWatching(), + }, copyWebviewLib: { fn: () => copyJs('webviewLib', `${mobileDir}/../lib/renderers/webviewLib.js`), }, diff --git a/packages/renderer/MdToHtml.ts b/packages/renderer/MdToHtml.ts index 95f57b3ffde..bbdd39ad0a4 100644 --- a/packages/renderer/MdToHtml.ts +++ b/packages/renderer/MdToHtml.ts @@ -548,12 +548,23 @@ export default class MdToHtml implements MarkupRenderer { const allRules = { ...rules, ...this.extraRendererRules_ }; + const loadPlugin = (plugin: any, options: any) => { + // Handle the case where we're bundling with webpack -- + // some modules that are commonjs imports in nodejs + // act like ES6 imports. + if (typeof plugin !== 'function' && plugin.default) { + plugin = plugin.default; + } + + markdownIt.use(plugin, options); + }; + for (const key in allRules) { if (!this.pluginEnabled(key)) continue; const rule = allRules[key]; - markdownIt.use(rule.plugin, { + loadPlugin(rule.plugin, { context: context, ...ruleOptions, ...(ruleOptions.plugins[key] ? ruleOptions.plugins[key] : {}), @@ -563,11 +574,11 @@ export default class MdToHtml implements MarkupRenderer { }); } - markdownIt.use(markdownItAnchor, { slugify: slugify }); + loadPlugin(markdownItAnchor, { slugify: slugify }); for (const key in plugins) { if (this.pluginEnabled(key)) { - markdownIt.use(plugins[key].module, plugins[key].options); + loadPlugin(plugins[key].module, plugins[key].options); } }