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; } 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; 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; requestPluginSetting: (pluginId: string, settingKey: string)=> void; readAssetBlob: (assetPath: string)=> Promise; } 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 = 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 = {}; 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; } }