You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-11-26 22:41:17 +02:00
Mobile: Add a Rich Text Editor (#12748)
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
/** @jest-environment jsdom */
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import Renderer, { RenderSettings, RendererSetupOptions } from './Renderer';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
|
||||
const defaultRendererSettings: RenderSettings = {
|
||||
theme: JSON.stringify({ cacheKey: 'test' }),
|
||||
highlightedKeywords: [],
|
||||
resources: {},
|
||||
codeTheme: 'atom-one-light.css',
|
||||
noteHash: '',
|
||||
initialScroll: 0,
|
||||
readAssetBlob: async (_path: string) => new Blob(),
|
||||
|
||||
createEditPopupSyntax: '',
|
||||
destroyEditPopupSyntax: '',
|
||||
pluginAssetContainerSelector: '#asset-container',
|
||||
splitted: false,
|
||||
|
||||
pluginSettings: {},
|
||||
requestPluginSetting: () => { },
|
||||
};
|
||||
|
||||
const makeRenderer = (options: Partial<RendererSetupOptions>) => {
|
||||
const defaultSetupOptions: RendererSetupOptions = {
|
||||
settings: {
|
||||
safeMode: false,
|
||||
tempDir: Setting.value('tempDir'),
|
||||
resourceDir: Setting.value('resourceDir'),
|
||||
resourceDownloadMode: 'auto',
|
||||
},
|
||||
useTransferredFiles: false,
|
||||
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 = 'asset-container';
|
||||
document.body.appendChild(pluginAssetsContainer);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelector('#joplin-container-content')?.remove();
|
||||
document.querySelector('#asset-container')?.remove();
|
||||
});
|
||||
|
||||
test('should support rendering markdown', async () => {
|
||||
const renderer = makeRenderer({});
|
||||
await renderer.rerenderToBody(
|
||||
{ language: MarkupLanguage.Markdown, markup: '**test**' },
|
||||
defaultRendererSettings,
|
||||
);
|
||||
|
||||
expect(getRenderedContent().innerHTML.trim()).toBe('<p><strong>test</strong></p>');
|
||||
|
||||
await renderer.rerenderToBody(
|
||||
{ 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.rerenderToBody(
|
||||
{ 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.rerenderToBody(
|
||||
{ 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();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const rerenderToBody = (pluginSettings: Record<string, any>) => {
|
||||
return renderer.rerenderToBody(
|
||||
{ language: MarkupLanguage.Markdown, markup: '```\ntest\n```' },
|
||||
{ ...defaultRendererSettings, pluginSettings, requestPluginSetting },
|
||||
);
|
||||
};
|
||||
|
||||
await rerenderToBody({});
|
||||
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 rerenderToBody({ someOtherSetting: 1 });
|
||||
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 rerenderToBody({ 'setting': 'test' });
|
||||
expect(requestPluginSetting).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Should not request plugin settings when all settings are present.
|
||||
await rerenderToBody({ [`${pluginId}.setting`]: 'test' });
|
||||
expect(requestPluginSetting).toHaveBeenCalledTimes(3);
|
||||
expect(getRenderedContent().querySelector('#setting-value').innerHTML).toBe('Setting value: test');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { MarkupLanguage, MarkupToHtml } from '@joplin/renderer';
|
||||
import type { MarkupToHtmlConverter, RenderOptions, FsDriver as RendererFsDriver, ResourceInfos } from '@joplin/renderer/types';
|
||||
import makeResourceModel from './utils/makeResourceModel';
|
||||
import addPluginAssets from './utils/addPluginAssets';
|
||||
import { ExtraContentScriptSource, ForwardedJoplinSettings, MarkupRecord } from '../types';
|
||||
import { ExtraContentScript } from '@joplin/lib/services/plugins/utils/loadContentScripts';
|
||||
import { PluginOptions } from '@joplin/renderer/MarkupToHtml';
|
||||
import afterFullPageRender from './utils/afterFullPageRender';
|
||||
|
||||
export interface RendererSetupOptions {
|
||||
settings: ForwardedJoplinSettings;
|
||||
useTransferredFiles: boolean;
|
||||
pluginOptions: PluginOptions;
|
||||
fsDriver: RendererFsDriver;
|
||||
}
|
||||
|
||||
export interface RenderSettings {
|
||||
theme: string;
|
||||
highlightedKeywords: string[];
|
||||
resources: ResourceInfos;
|
||||
codeTheme: string;
|
||||
noteHash: string;
|
||||
initialScroll: number;
|
||||
// If [null], plugin assets are not added to the document
|
||||
pluginAssetContainerSelector: string|null;
|
||||
|
||||
splitted?: boolean; // Move CSS into a separate output
|
||||
mapsToLine?: boolean; // Sourcemaps
|
||||
|
||||
createEditPopupSyntax: string;
|
||||
destroyEditPopupSyntax: string;
|
||||
|
||||
pluginSettings: Record<string, unknown>;
|
||||
requestPluginSetting: (pluginId: string, settingKey: string)=> void;
|
||||
readAssetBlob: (assetPath: string)=> Promise<Blob>;
|
||||
}
|
||||
|
||||
export interface RendererOutput {
|
||||
getOutputElement: ()=> HTMLElement;
|
||||
afterRender: (setupOptions: RendererSetupOptions, renderSettings: RenderSettings)=> void;
|
||||
}
|
||||
|
||||
export default class Renderer {
|
||||
private markupToHtml_: MarkupToHtmlConverter;
|
||||
private lastBodyRenderSettings_: RenderSettings|null = null;
|
||||
private extraContentScripts_: ExtraContentScript[] = [];
|
||||
private lastBodyMarkup_: MarkupRecord|null = null;
|
||||
private lastPluginSettingsCacheKey_: string|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 = ((0, 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.lastBodyMarkup_) {
|
||||
await this.rerenderToBody(this.lastBodyMarkup_, this.lastBodyRenderSettings_);
|
||||
}
|
||||
}
|
||||
|
||||
public async render(markup: MarkupRecord, settings: RenderSettings) {
|
||||
const options: RenderOptions = {
|
||||
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];
|
||||
},
|
||||
splitted: settings.splitted,
|
||||
mapsToLine: settings.mapsToLine,
|
||||
whiteBackgroundNoteRendering: markup.language === MarkupLanguage.Html,
|
||||
};
|
||||
|
||||
const pluginSettingsCacheKey = JSON.stringify(settings.pluginSettings);
|
||||
if (pluginSettingsCacheKey !== this.lastPluginSettingsCacheKey_) {
|
||||
this.lastPluginSettingsCacheKey_ = pluginSettingsCacheKey;
|
||||
this.markupToHtml_.clearCache(markup.language);
|
||||
}
|
||||
|
||||
const result = await this.markupToHtml_.render(
|
||||
markup.language,
|
||||
markup.markup,
|
||||
JSON.parse(settings.theme),
|
||||
options,
|
||||
);
|
||||
|
||||
// Adding plugin assets can be slow -- run it asynchronously.
|
||||
if (settings.pluginAssetContainerSelector) {
|
||||
void (async () => {
|
||||
await addPluginAssets(result.pluginAssets, {
|
||||
inlineAssets: this.setupOptions_.useTransferredFiles,
|
||||
readAssetBlob: settings.readAssetBlob,
|
||||
container: document.querySelector(settings.pluginAssetContainerSelector),
|
||||
});
|
||||
|
||||
// Some plugins require this event to be dispatched just after being added.
|
||||
document.dispatchEvent(new Event('joplin-noteDidUpdate'));
|
||||
})();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async rerenderToBody(markup: MarkupRecord, settings: RenderSettings) {
|
||||
this.lastBodyMarkup_ = markup;
|
||||
this.lastBodyRenderSettings_ = settings;
|
||||
|
||||
const contentContainer = document.getElementById('joplin-container-content') ?? document.body;
|
||||
|
||||
let html = '';
|
||||
try {
|
||||
const result = await this.render(markup, settings);
|
||||
html = result.html;
|
||||
} catch (error) {
|
||||
if (!contentContainer) {
|
||||
alert(`Renderer error: ${error}`);
|
||||
} else {
|
||||
contentContainer.textContent = `
|
||||
Error: ${error}
|
||||
|
||||
${error.stack ?? ''}
|
||||
`;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (contentContainer) {
|
||||
contentContainer.innerHTML = html;
|
||||
}
|
||||
|
||||
afterFullPageRender(this.setupOptions_, settings);
|
||||
}
|
||||
|
||||
public clearCache(markupLanguage: MarkupLanguage) {
|
||||
this.markupToHtml_.clearCache(markupLanguage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Renderer from './Renderer';
|
||||
import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger';
|
||||
import { RendererProcessApi, MainProcessApi, RendererWebViewOptions } from '../types';
|
||||
|
||||
interface WebViewLib {
|
||||
initialize(config: unknown): void;
|
||||
}
|
||||
|
||||
interface WebViewApi {
|
||||
postMessage: (contentScriptId: string, args: unknown)=> void;
|
||||
}
|
||||
|
||||
interface ExtendedWindow extends Window {
|
||||
webviewLib: WebViewLib;
|
||||
webviewApi: WebViewApi;
|
||||
joplinPostMessage_: (message: string, args: unknown)=> void;
|
||||
}
|
||||
|
||||
declare const window: ExtendedWindow;
|
||||
declare const webviewLib: WebViewLib;
|
||||
|
||||
const initializeMessenger = (options: RendererWebViewOptions) => {
|
||||
const messenger = new WebViewToRNMessenger<RendererProcessApi, MainProcessApi>(
|
||||
'renderer',
|
||||
null,
|
||||
);
|
||||
|
||||
window.joplinPostMessage_ = (message: string, _args: unknown) => {
|
||||
return messenger.remoteApi.onPostMessage(message);
|
||||
};
|
||||
|
||||
window.webviewApi = {
|
||||
postMessage: messenger.remoteApi.onPostPluginMessage,
|
||||
};
|
||||
|
||||
webviewLib.initialize({
|
||||
postMessage: (message: string) => {
|
||||
messenger.remoteApi.onPostMessage(message);
|
||||
},
|
||||
});
|
||||
// Share the webview library globally so that the renderer can access it.
|
||||
window.webviewLib = webviewLib;
|
||||
|
||||
const renderer = new Renderer({
|
||||
...options,
|
||||
fsDriver: messenger.remoteApi.fsDriver,
|
||||
});
|
||||
|
||||
messenger.setLocalInterface({
|
||||
renderer,
|
||||
jumpToHash: (hash: string) => {
|
||||
location.hash = `#${hash}`;
|
||||
},
|
||||
});
|
||||
|
||||
return { messenger };
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export -- This is a bundle entrypoint
|
||||
export const initialize = (options: RendererWebViewOptions) => {
|
||||
const { messenger } = initializeMessenger(options);
|
||||
|
||||
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,5 @@
|
||||
export interface WebViewLib {
|
||||
initialize(config: unknown): void;
|
||||
setupResourceManualDownload(): void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { RenderResultPluginAsset } from '@joplin/renderer/types';
|
||||
import { join, dirname } from 'path';
|
||||
|
||||
type PluginAssetRecord = {
|
||||
element: HTMLElement;
|
||||
};
|
||||
const pluginAssetsAdded_: Record<string, PluginAssetRecord> = {};
|
||||
|
||||
const assetUrlMap_: Map<string, ()=> Promise<string>> = new Map();
|
||||
|
||||
// Some resources (e.g. CSS) reference other resources with relative paths. On web, due to sandboxing
|
||||
// and how plugin assets are stored, these links need to be rewritten.
|
||||
const rewriteInternalAssetLinks = async (asset: RenderResultPluginAsset, content: string) => {
|
||||
if (asset.mime === 'text/css') {
|
||||
const urlRegex = /(url\()([^)]+)(\))/g;
|
||||
|
||||
// Converting resource paths to URLs is async. To handle this, we do two passes.
|
||||
// In the first, the original URLs are collected. In the second, the URLs are replaced.
|
||||
const replacements: [string, string][] = [];
|
||||
let replacementIndex = 0;
|
||||
content = content.replace(urlRegex, (match, _group1, url, _group3) => {
|
||||
const target = join(dirname(asset.path), url);
|
||||
if (!assetUrlMap_.has(target)) return match;
|
||||
const replaceString = `<<to-replace-with-url-${replacementIndex++}>>`;
|
||||
replacements.push([replaceString, target]);
|
||||
return `url(${replaceString})`;
|
||||
});
|
||||
|
||||
for (const [replacement, path] of replacements) {
|
||||
const url = await assetUrlMap_.get(path)();
|
||||
content = content.replace(replacement, url);
|
||||
}
|
||||
|
||||
return content;
|
||||
} else {
|
||||
return content;
|
||||
}
|
||||
};
|
||||
|
||||
interface Options {
|
||||
inlineAssets: boolean;
|
||||
container: HTMLElement;
|
||||
readAssetBlob?(path: string): Promise<Blob>;
|
||||
}
|
||||
|
||||
// Note that this function keeps track of what's been added so as not to
|
||||
// add the same CSS files multiple times.
|
||||
const addPluginAssets = async (assets: RenderResultPluginAsset[], options: Options) => {
|
||||
if (!assets) return;
|
||||
|
||||
const pluginAssetsContainer = options.container;
|
||||
|
||||
const prepareAssetBlobUrls = () => {
|
||||
for (const asset of assets) {
|
||||
const path = asset.path;
|
||||
if (!assetUrlMap_.has(path)) {
|
||||
// Fetching assets can be expensive -- avoid refetching assets where possible.
|
||||
let url: string|null = null;
|
||||
assetUrlMap_.set(path, async () => {
|
||||
if (url !== null) return url;
|
||||
|
||||
const blob = await options.readAssetBlob(path);
|
||||
if (!blob) {
|
||||
url = '';
|
||||
} else {
|
||||
url = URL.createObjectURL(blob);
|
||||
}
|
||||
return url;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (options.inlineAssets) {
|
||||
prepareAssetBlobUrls();
|
||||
}
|
||||
|
||||
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 (options.inlineAssets) {
|
||||
if (asset.mime === 'application/javascript') {
|
||||
element = document.createElement('script');
|
||||
} else if (asset.mime === 'text/css') {
|
||||
element = document.createElement('style');
|
||||
}
|
||||
|
||||
if (element) {
|
||||
const blob = await options.readAssetBlob(asset.path);
|
||||
if (blob) {
|
||||
const assetContent = await blob.text();
|
||||
element.appendChild(
|
||||
document.createTextNode(await rewriteInternalAssetLinks(asset, assetContent)),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (asset.mime === 'application/javascript') {
|
||||
element = document.createElement('script');
|
||||
element.src = encodedPath;
|
||||
} else if (asset.mime === 'text/css') {
|
||||
element = document.createElement('link');
|
||||
element.rel = 'stylesheet';
|
||||
element.href = encodedPath;
|
||||
}
|
||||
}
|
||||
if (element) {
|
||||
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,45 @@
|
||||
import { RenderSettings, RendererSetupOptions } from '../Renderer';
|
||||
import { WebViewLib } from '../types';
|
||||
|
||||
interface ExtendedWindow extends Window {
|
||||
webviewLib: WebViewLib;
|
||||
}
|
||||
|
||||
declare const window: ExtendedWindow;
|
||||
|
||||
const afterFullPageRender = (
|
||||
setupOptions: RendererSetupOptions,
|
||||
renderSettings: RenderSettings,
|
||||
) => {
|
||||
const readyStateCheckInterval = setInterval(() => {
|
||||
if (document.readyState === 'complete') {
|
||||
clearInterval(readyStateCheckInterval);
|
||||
if (setupOptions.settings.resourceDownloadMode === 'manual') {
|
||||
window.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);
|
||||
};
|
||||
|
||||
export default afterFullPageRender;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user