You've already forked joplin
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:
@ -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
10
.gitignore
vendored
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
@ -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;
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
|
4
packages/app-mobile/components/NoteBodyViewer/types.ts
Normal file
4
packages/app-mobile/components/NoteBodyViewer/types.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { WebViewMessageEvent } from 'react-native-webview';
|
||||
|
||||
export type OnScrollCallback = (scrollTop: number)=> void;
|
||||
export type OnWebViewMessageHandler = (event: WebViewMessageEvent)=> void;
|
@ -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 {
|
||||
|
@ -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',
|
||||
),
|
||||
));
|
||||
|
||||
|
@ -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`),
|
||||
},
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user