1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-07-13 00:10:37 +02:00

Android: Add support for renderer plugins (#10135)

This commit is contained in:
Henry Heino
2024-03-20 04:01:09 -07:00
committed by GitHub
parent 44e8950f1b
commit e92f89df99
19 changed files with 1100 additions and 351 deletions

View File

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

10
.gitignore vendored
View File

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

View File

@ -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<WebViewControl>(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,
const onPostMessage = useOnMessage(props.noteBody, {
onMarkForDownload: props.onMarkForDownload,
onJoplinLinkClick: props.onJoplinLinkClick,
onRequestEditResource: props.onRequestEditResource,
onCheckboxChange: props.onCheckboxChange,
onResourceLongPress,
onMainContainerScroll: props.onScroll,
},
);
});
const [webViewLoaded, setWebViewLoaded] = useState(false);
const [onWebViewMessage, setOnWebViewMessage] = useState<OnWebViewMessageHandler>(()=>()=>{});
// 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 (
<View style={props.style}>
<ExtendedWebView
ref={webviewRef}
webviewInstanceId='NoteBodyViewer'
html={html}
injectedJavaScript={injectedJs.join('\n')}
allowFileAccessFromJs={true}
injectedJavaScript={injectedJs}
mixedContentMode="always"
onLoadEnd={onLoadEnd}
onError={onError}
onMessage={onMessage}
onMessage={onWebViewMessage}
/>
<BackButtonDialogBox_ ref={dialogBoxRef}/>
</View>

View File

@ -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<RendererSetupOptions>) => {
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('<p><strong>test</strong></p>');
await renderer.rerender(
{ language: MarkupLanguage.Markdown, markup: '*test*' },
defaultRendererSettings,
);
expect(getRenderedContent().innerHTML.trim()).toBe('<p><em>test</em></p>');
});
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 '<div id="test">Test from ' + context.pluginId + '</div>';
};
},
};
})
`,
assetPath: Setting.value('tempDir'),
pluginId: 'com.example.test-plugin',
},
]);
await renderer.rerender(
{ language: MarkupLanguage.Markdown, markup: '```\ntest\n```' },
defaultRendererSettings,
);
expect(getRenderedContent().innerHTML.trim()).toBe('<div id="test">Test from com.example.test-plugin</div>');
// 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<string, any>) => {
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 '<div id="setting-value">Setting value: ' + settingValue + '</div>';
};
},
};
})
`,
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');
});
});

View File

@ -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<string, any>;
}
export interface RendererSettings {
theme: string;
onResourceLoaded: ()=> void;
highlightedKeywords: string[];
resources: Record<string, any>;
codeTheme: string;
noteHash: string;
initialScroll: number;
createEditPopupSyntax: string;
destroyEditPopupSyntax: string;
pluginSettings: Record<string, any>;
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<string, HTMLStyleElement> = {};
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;
}
}

View File

@ -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<NoteViewerLocalApi, NoteViewerRemoteApi>(
'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);

View File

@ -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<string, any>;
}
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<any>;
fsDriver: RendererFsDriver;
}

View File

@ -0,0 +1,76 @@
import { RenderResultPluginAsset } from '@joplin/renderer/types';
type PluginAssetRecord = {
element: HTMLElement;
};
const pluginAssetsAdded_: Record<string, PluginAssetRecord> = {};
// 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;

View File

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

View File

@ -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<string, ExtraContentScriptSource[]>;
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;

View File

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

View File

@ -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<WebViewControl>;
onScroll: OnScrollCallback;
onPostMessage: (message: string)=> void;
setOnWebViewMessage: Dispatch<SetStateAction<OnWebViewMessageHandler>>;
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<NoteViewerRemoteApi, NoteViewerLocalApi>(
'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;

View File

@ -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<Record<string, boolean>>({});
// 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<string, any> = { };
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;

View File

@ -1,221 +1,45 @@
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<string>('');
const [injectedJs, setInjectedJs] = useState<string[]>([]);
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),
};
}, [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());
const rendererWebViewOptions: RendererWebViewOptions = {
settings: {
safeMode: Setting.value('isSafeMode'),
tempDir: tempDirPath,
resourceDir: Setting.value('resourceDir'),
resourceDownloadMode: Setting.value('sync.resourceDownloadMode'),
},
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,
pluginOptions,
};
// 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);
return `
window.rendererWebViewOptions = ${JSON.stringify(rendererWebViewOptions)};
const result = await markupToHtml.render(
noteMarkupLanguage,
bodyToRender,
rendererTheme,
mdOptions,
);
if (!window.injectedJsLoaded) {
window.injectedJsLoaded = true;
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));
${shim.injectedJs('webviewLib')}
${shim.injectedJs('noteBodyViewerBundle')}
}
};
`;
}, [tempDirPath]);
// 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;');
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
@ -233,13 +57,12 @@ export default function useSource(
}
body {
padding-left: ${Number(theme.marginLeft)}px;
padding-right: ${Number(theme.marginRight)}px;
padding-left: ${Number(paddingLeft)}px;
padding-right: ${Number(paddingRight)}px;
}
`;
html =
`
return `
<!DOCTYPE html>
<html>
<head>
@ -248,39 +71,17 @@ export default function useSource(
<style>
${defaultCss}
${shim.mobilePlatform() === 'ios' ? iOSSpecificCss : ''}
${editPopupCss}
</style>
${assetsToHeaders(result.pluginAssets, { asHtml: true })}
</head>
<body>
${html}
<div id="joplin-container-pluginAssetsContainer"></div>
<div id="joplin-container-content"></div>
</body>
</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);
}, [paddingLeft, paddingRight]);
return { html, injectedJs };
}
};
export default useSource;

View File

@ -0,0 +1,4 @@
import { WebViewMessageEvent } from 'react-native-webview';
export type OnScrollCallback = (scrollTop: number)=> void;
export type OnWebViewMessageHandler = (event: WebViewMessageEvent)=> void;

View File

@ -1474,6 +1474,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
onLoadEnd={this.onBodyViewerLoadEnd}
onScroll={this.onBodyViewerScroll}
initialScroll={this.lastBodyScroll}
pluginStates={this.props.plugins}
/>
);
} else {

View File

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

View File

@ -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`),
},

View File

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