1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00
joplin/packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.ts
2024-08-02 14:51:49 +01:00

239 lines
7.6 KiB
TypeScript

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;
};
// True if asset and resource files should be transferred to the WebView before rendering.
// This must be true on web, where asset and resource files are virtual and can't be accessed
// without transferring.
useTransferredFiles: boolean;
fsDriver: RendererFsDriver;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
pluginOptions: Record<string, any>;
}
export interface RendererSettings {
theme: string;
onResourceLoaded: ()=> void;
highlightedKeywords: string[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
resources: Record<string, any>;
codeTheme: string;
noteHash: string;
initialScroll: number;
createEditPopupSyntax: string;
destroyEditPopupSyntax: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
pluginSettings: Record<string, any>;
requestPluginSetting: (pluginId: string, settingKey: string)=> void;
readAssetBlob: (assetPath: string)=> Promise<Blob>;
}
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;
private resourcePathOverrides: Record<string, string> = Object.create(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,
});
}
// Intended for web, where resources can't be linked to normally.
public async setResourceFile(id: string, file: Blob) {
this.resourcePathOverrides[id] = URL.createObjectURL(file);
}
public getResourcePathOverride(resourceId: string) {
if (Object.prototype.hasOwnProperty.call(this.resourcePathOverrides, resourceId)) {
return this.resourcePathOverrides[resourceId];
}
return null;
}
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,
itemIdToUrl: this.setupOptions.useTransferredFiles ? (id: string) => this.getResourcePathOverride(id) : undefined,
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;
// Adding plugin assets can be slow -- run it asynchronously.
void (async () => {
await addPluginAssets(pluginAssets, {
inlineAssets: this.setupOptions.useTransferredFiles,
readAssetBlob: settings.readAssetBlob,
});
// Some plugins require this event to be dispatched just after being added.
document.dispatchEvent(new Event('joplin-noteDidUpdate'));
})();
this.afterRender(settings);
}
private afterRender(renderSettings: RendererSettings) {
const readyStateCheckInterval = setInterval(() => {
if (document.readyState === 'complete') {
clearInterval(readyStateCheckInterval);
if (this.setupOptions.settings.resourceDownloadMode === 'manual') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(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);
}
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;
}
}