1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-11-29 22:48:10 +02:00

Mobile: Add a Rich Text Editor (#12748)

This commit is contained in:
Henry Heino
2025-07-29 12:25:43 -07:00
committed by GitHub
parent c899f63a41
commit 4c3eca1f18
154 changed files with 6405 additions and 1805 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
export interface WebViewLib {
initialize(config: unknown): void;
setupResourceManualDownload(): void;
}

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
import { isResourceUrl, isSupportedImageMimeType, resourceFilename, resourceFullPath, resourceUrlToId } from '@joplin/lib/models/utils/resourceUtils';
import { OptionsResourceModel } from '@joplin/renderer/types';
const makeResourceModel = (resourceDirPath: string): OptionsResourceModel => {
return {
isResourceUrl,
urlToId: resourceUrlToId,
filename: resourceFilename,
isSupportedImageMimeType,
fullPath: (resource, encryptedBlob) => {
return resourceFullPath(resource, resourceDirPath, encryptedBlob);
},
};
};
export default makeResourceModel;

View File

@@ -0,0 +1,73 @@
import type { FsDriver as RendererFsDriver, RenderResult, ResourceInfos } from '@joplin/renderer/types';
import type Renderer from './contentScript/Renderer';
import { MarkupLanguage, PluginOptions } from '@joplin/renderer/MarkupToHtml';
// Joplin settings (as from Setting.value(...)) that should
// remain constant during editing.
export interface ForwardedJoplinSettings {
safeMode: boolean;
tempDir: string;
resourceDir: string;
resourceDownloadMode: string;
}
export interface RendererWebViewOptions {
settings: ForwardedJoplinSettings;
// 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;
// Enabled/disabled Markdown plugins
pluginOptions: PluginOptions;
}
export interface ExtraContentScriptSource {
id: string;
js: string;
assetPath: string;
pluginId: string;
}
export interface RendererProcessApi {
renderer: Renderer;
jumpToHash: (hash: string)=> void;
}
export interface MainProcessApi {
onScroll(scrollTop: number): void;
onPostMessage(message: string): void;
onPostPluginMessage(contentScriptId: string, message: unknown): Promise<unknown>;
fsDriver: RendererFsDriver;
}
export type OnScrollCallback = (scrollTop: number)=> void;
export interface MarkupRecord {
language: MarkupLanguage;
markup: string;
}
export interface RenderOptions {
themeId: number;
highlightedKeywords: string[];
resources: ResourceInfos;
themeOverrides: Record<string, string|number>;
// If null, plugin assets will not be added to the document.
pluginAssetContainerSelector: string|null;
noteHash: string;
initialScroll: number;
// Forwarded renderer settings
splitted?: boolean;
mapsToLine?: boolean;
}
type CancelEvent = { cancelled: boolean };
export interface RendererControl {
rerenderToBody(markup: MarkupRecord, options: RenderOptions, cancelEvent?: CancelEvent): Promise<string|void>;
render(markup: MarkupRecord, options: RenderOptions): Promise<RenderResult>;
clearCache(markupLanguage: MarkupLanguage): void;
}

View File

@@ -0,0 +1,277 @@
import { RefObject, useEffect, useMemo, useRef } from 'react';
import shim from '@joplin/lib/shim';
import Setting from '@joplin/lib/models/Setting';
import { Platform } from 'react-native';
import { SetUpResult } from '../types';
import { themeStyle } from '../../components/global-style';
import Logger from '@joplin/utils/Logger';
import { WebViewControl } from '../../components/ExtendedWebView/types';
import { MainProcessApi, OnScrollCallback, RendererControl, RendererProcessApi, RendererWebViewOptions, RenderOptions } from './types';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
import useEditPopup from './utils/useEditPopup';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { RenderSettings } from './contentScript/Renderer';
import resolvePathWithinDir from '@joplin/lib/utils/resolvePathWithinDir';
import Resource from '@joplin/lib/models/Resource';
import { ResourceInfos } from '@joplin/renderer/types';
import useContentScripts from './utils/useContentScripts';
import uuid from '@joplin/lib/uuid';
const logger = Logger.create('renderer/useWebViewSetup');
interface Props {
webviewRef: RefObject<WebViewControl>;
onBodyScroll: OnScrollCallback|null;
onPostMessage: (message: string)=> void;
pluginStates: PluginStates;
themeId: number;
}
const useSource = (tempDirPath: string) => {
const injectedJs = useMemo(() => {
const subValues = Setting.subValues('markdown.plugin', Setting.toPlainObject());
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const pluginOptions: any = {};
for (const n in subValues) {
pluginOptions[n] = { enabled: subValues[n] };
}
const rendererWebViewStaticOptions: RendererWebViewOptions = {
settings: {
safeMode: Setting.value('isSafeMode'),
tempDir: tempDirPath,
resourceDir: Setting.value('resourceDir'),
resourceDownloadMode: Setting.value('sync.resourceDownloadMode'),
},
// Web needs files to be transferred manually, since image SRCs can't reference
// the Origin Private File System.
useTransferredFiles: Platform.OS === 'web',
pluginOptions,
};
return `
if (!window.rendererJsLoaded) {
window.rendererJsLoaded = true;
${shim.injectedJs('webviewLib')}
${shim.injectedJs('rendererBundle')}
rendererBundle.initialize(${JSON.stringify(rendererWebViewStaticOptions)});
}
`;
}, [tempDirPath]);
return { css: '', injectedJs };
};
const onPostPluginMessage = async (contentScriptId: string, message: unknown) => {
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);
};
type UseMessengerProps = Props & { tempDirPath: string };
const useMessenger = (props: UseMessengerProps) => {
const onScrollRef = useRef(props.onBodyScroll);
onScrollRef.current = props.onBodyScroll;
const onPostMessageRef = useRef(props.onPostMessage);
onPostMessageRef.current = props.onPostMessage;
const messenger = useMemo(() => {
const fsDriver = shim.fsDriver();
const localApi = {
onScroll: (fraction: number) => onScrollRef.current?.(fraction),
onPostMessage: (message: string) => onPostMessageRef.current?.(message),
onPostPluginMessage,
fsDriver: {
writeFile: async (path: string, content: string, encoding?: string) => {
if (!await fsDriver.exists(props.tempDirPath)) {
await fsDriver.mkdir(props.tempDirPath);
}
// To avoid giving the WebView access to the entire main tempDir,
// we use props.tempDir (which should be different).
path = fsDriver.resolveRelativePathWithinDir(props.tempDirPath, path);
return await fsDriver.writeFile(path, content, encoding);
},
exists: fsDriver.exists,
cacheCssToFile: fsDriver.cacheCssToFile,
},
};
return new RNToWebViewMessenger<MainProcessApi, RendererProcessApi>(
'renderer', props.webviewRef, localApi,
);
}, [props.webviewRef, props.tempDirPath]);
return messenger;
};
const useTempDirPath = () => {
// The renderer can write to whichever temporary directory is chosen here. As such,
// use a subdirectory of the main temporary directory for security reasons.
const tempDirPath = useMemo(() => {
return `${Setting.value('tempDir')}/${uuid.createNano()}`;
}, []);
useEffect(() => {
return () => {
void (async () => {
if (await shim.fsDriver().exists(tempDirPath)) {
await shim.fsDriver().remove(tempDirPath);
}
})();
};
}, [tempDirPath]);
return tempDirPath;
};
const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
const tempDirPath = useTempDirPath();
const { css, injectedJs } = useSource(tempDirPath);
const { editPopupCss, createEditPopupSyntax, destroyEditPopupSyntax } = useEditPopup(props.themeId);
const messenger = useMessenger({ ...props, tempDirPath });
const pluginSettingKeysRef = useRef(new Set<string>());
const contentScripts = useContentScripts(props.pluginStates);
useEffect(() => {
void messenger.remoteApi.renderer.setExtraContentScriptsAndRerender(contentScripts);
}, [messenger, contentScripts]);
const rendererControl = useMemo((): RendererControl => {
const renderer = messenger.remoteApi.renderer;
const transferResources = async (resources: ResourceInfos) => {
// On web, resources are virtual files and thus need to be transferred to the WebView.
if (shim.mobilePlatform() === 'web') {
for (const [resourceId, resource] of Object.entries(resources)) {
try {
await renderer.setResourceFile(
resourceId,
await shim.fsDriver().fileAtPath(Resource.fullPath(resource.item)),
);
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
// This can happen if a resource hasn't been downloaded yet
logger.warn('Error: Resource file not found (ENOENT)', Resource.fullPath(resource.item), 'for ID', resource.item.id);
}
}
}
};
const prepareRenderer = async (options: RenderOptions) => {
const theme = themeStyle(options.themeId);
const loadPluginSettings = () => {
const output: Record<string, unknown> = Object.create(null);
for (const key of pluginSettingKeysRef.current) {
output[key] = Setting.value(`plugin-${key}`);
}
return output;
};
let settingsChanged = false;
const settings: RenderSettings = {
...options,
codeTheme: theme.codeThemeCss,
// We .stringify the theme to avoid a JSON serialization error involving
// the color package.
theme: JSON.stringify({
...theme,
...options.themeOverrides,
}),
createEditPopupSyntax,
destroyEditPopupSyntax,
pluginSettings: loadPluginSettings(),
requestPluginSetting: (pluginId: string, settingKey: string) => {
const key = `${pluginId}.${settingKey}`;
if (!pluginSettingKeysRef.current.has(key)) {
pluginSettingKeysRef.current.add(key);
settingsChanged = true;
}
},
readAssetBlob: (assetPath: string): Promise<Blob> => {
// Built-in assets are in resourceDir, external plugin assets are in cacheDir.
const assetsDirs = [Setting.value('resourceDir'), Setting.value('cacheDir')];
let resolvedPath = null;
for (const assetDir of assetsDirs) {
resolvedPath ??= resolvePathWithinDir(assetDir, assetPath);
if (resolvedPath) break;
}
if (!resolvedPath) {
throw new Error(`Failed to load asset at ${assetPath} -- not in any of the allowed asset directories: ${assetsDirs.join(',')}.`);
}
return shim.fsDriver().fileAtPath(resolvedPath);
},
};
await transferResources(options.resources);
return {
settings,
getSettingsChanged() {
return settingsChanged;
},
};
};
return {
rerenderToBody: async (markup, options, cancelEvent) => {
const { settings, getSettingsChanged } = await prepareRenderer(options);
if (cancelEvent?.cancelled) return null;
const output = await renderer.rerenderToBody(markup, settings);
if (cancelEvent?.cancelled) return null;
if (getSettingsChanged()) {
return await renderer.rerenderToBody(markup, settings);
}
return output;
},
render: async (markup, options) => {
const { settings, getSettingsChanged } = await prepareRenderer(options);
const output = await renderer.render(markup, settings);
if (getSettingsChanged()) {
return await renderer.render(markup, settings);
}
return output;
},
clearCache: async markupLanguage => {
await renderer.clearCache(markupLanguage);
},
};
}, [createEditPopupSyntax, destroyEditPopupSyntax, messenger]);
return useMemo(() => {
return {
api: rendererControl,
pageSetup: {
css: `${css} ${editPopupCss}`,
js: injectedJs,
},
webViewEventHandlers: {
onLoadEnd: messenger.onWebViewLoaded,
onMessage: messenger.onWebViewMessage,
},
};
}, [css, injectedJs, messenger, editPopupCss, rendererControl]);
};
export default useWebViewSetup;

View File

@@ -0,0 +1,107 @@
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import { dirname } from '@joplin/lib/path-utils';
import { ContentScriptType } from '@joplin/lib/services/plugins/api/types';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import shim from '@joplin/lib/shim';
import { useRef, useState } from 'react';
import Logger from '@joplin/utils/Logger';
import { ExtraContentScriptSource } from '../types';
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 exports = {};
const module = { exports: exports };
${content}
return (module.exports || exports).default;
})()`;
if (contentScriptModule.length > 1024 * 1024) {
const size = Math.round(contentScriptModule.length / 1024) / 1024;
logger.warn(
`Plugin ${pluginId}:`,
`Loaded large content script with size ${size} MiB and ID ${contentScript.id}.`,
'Large content scripts can slow down the renderer.',
);
}
if (oldContentScripts[newContentScripts.length]?.js !== contentScriptModule) {
differentFromLastContentScripts = true;
}
loadedPluginContentScripts.push({
id: contentScript.id,
js: contentScriptModule,
assetPath: dirname(contentScript.path),
pluginId: pluginId,
});
}
newContentScriptsCache[pluginId] = loadedPluginContentScripts;
newContentScripts.push(...loadedPluginContentScripts);
}
differentFromLastContentScripts ||= newContentScripts.length !== oldContentScripts.length;
if (differentFromLastContentScripts) {
contentScriptsCache = newContentScriptsCache;
setContentScripts(newContentScripts);
} else {
logger.debug(`Re-using all ${oldContentScripts.length} content scripts.`);
}
}, [pluginStates, setContentScripts]);
return contentScripts;
};
export default useContentScripts;

View File

@@ -0,0 +1,86 @@
/**
* @jest-environment jsdom
*/
import { writeFileSync } from 'fs-extra';
import { join } from 'path';
import Setting from '@joplin/lib/models/Setting';
// Mock react-native-vector-icons -- it uses ESM imports, which, by default, are not
// supported by jest.
jest.doMock('react-native-vector-icons/Ionicons', () => {
return {
default: {
getImageSourceSync: () => {
// Create an empty file that can be read/used as an image resource.
const iconPath = join(Setting.value('cacheDir'), 'test-icon.png');
writeFileSync(iconPath, '', 'utf-8');
return { uri: iconPath };
},
},
};
});
import lightTheme from '@joplin/lib/themes/light';
import { editPopupClass, getEditPopupSource } from './useEditPopup';
import { describe, it, expect, beforeAll, jest } from '@jest/globals';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
const createEditPopup = (target: HTMLElement) => {
const { createEditPopupSyntax } = getEditPopupSource(lightTheme);
eval(`(${createEditPopupSyntax})`)(target, 'someresourceid', '() => {}');
};
const destroyEditPopup = () => {
const { destroyEditPopupSyntax } = getEditPopupSource(lightTheme);
eval(`(${destroyEditPopupSyntax})`)();
};
describe('useEditPopup', () => {
beforeAll(async () => {
// useEditPopup relies on the resourceDir setting, which is set by
// switchClient.
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
});
it('should attach an edit popup to an image', () => {
const container = document.createElement('div');
const targetImage = document.createElement('img');
container.appendChild(targetImage);
createEditPopup(targetImage);
// Popup should be present in the document
expect(container.querySelector(`.${editPopupClass}`)).not.toBeNull();
// Destroy the edit popup
jest.useFakeTimers();
destroyEditPopup();
// Give time for the popup's fade out animation to run.
jest.advanceTimersByTime(1000 * 10);
// Popup should be destroyed.
expect(container.querySelector(`.${editPopupClass}`)).toBeNull();
targetImage.remove();
});
it('should auto-remove the edit popup after a delay', () => {
jest.useFakeTimers();
const container = document.createElement('div');
const targetImage = document.createElement('img');
container.appendChild(targetImage);
jest.useFakeTimers();
createEditPopup(targetImage);
expect(container.querySelector(`.${editPopupClass}`)).not.toBeNull();
jest.advanceTimersByTime(1000 * 20); // ms
expect(container.querySelector(`.${editPopupClass}`)).toBeNull();
});
});

View File

@@ -0,0 +1,159 @@
import { _ } from '@joplin/lib/locale';
import Setting from '@joplin/lib/models/Setting';
import { themeStyle } from '@joplin/lib/theme';
import { Theme } from '@joplin/lib/themes/type';
import { useMemo } from 'react';
import { extname } from 'path';
import shim from '@joplin/lib/shim';
import { Platform } from 'react-native';
const Icon = require('react-native-vector-icons/Ionicons').default;
export const editPopupClass = 'joplin-editPopup';
const getEditIconSrc = (theme: Theme) => {
// Use an inline edit icon on web -- getImageSourceSync isn't supported there.
if (Platform.OS === 'web') {
const svgData = `
<svg viewBox="-103 60 180 180" width="30" height="30" version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
<path d="m 100,19 c -11.7,0 -21.1,9.5 -21.2,21.2 0,0 42.3,0 42.3,0 0,-11.7 -9.5,-21.2 -21.2,-21.2 z M 79,43 v 143 l 21.3,26.4 21,-26.5 V 42.8 Z" style="transform: rotate(45deg)" fill=${JSON.stringify(theme.color2)}/>
</svg>
`.replace(/[ \t\n]+/, ' ');
return `data:image/svg+xml;utf8,${encodeURIComponent(svgData)}`;
}
const iconUri = Icon.getImageSourceSync('pencil', 20, theme.color2).uri;
// Copy to a location that can be read within a WebView
// (necessary on iOS)
const destPath = `${Setting.value('resourceDir')}/edit-icon${extname(iconUri)}`;
// Copy in the background -- the edit icon popover script doesn't need the
// icon immediately.
void (async () => {
// Can be '' in a testing environment.
if (iconUri) {
await shim.fsDriver().copy(iconUri, destPath);
}
})();
return destPath;
};
// Creates JavaScript/CSS that can be used to create an "Edit" button.
// Exported to facilitate testing.
export const getEditPopupSource = (theme: Theme) => {
const fadeOutDelay = 400;
const editPopupDestroyDelay = 5000;
const editPopupCss = `
@keyframes fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes fade-out {
0% { opacity: 1; }
100% { opacity: 0; }
}
.${editPopupClass} {
display: inline-block;
position: relative;
/* Don't take up any space in the line, overlay the button */
width: 0;
height: 0;
overflow: visible;
--edit-popup-width: 40px;
--edit-popup-padding: 10px;
/* Shift the popup such that it overlaps with the previous element. */
left: calc(0px - var(--edit-popup-width));
/* Match the top of the image */
vertical-align: top;
}
.${editPopupClass} > button {
padding: var(--edit-popup-padding);
width: var(--edit-popup-width);
animation: fade-in 0.4s ease;
background-color: ${theme.backgroundColor2};
color: ${theme.color2};
border: none;
}
.${editPopupClass} img {
/* Make the image take up as much space as possible (minus padding) */
width: calc(var(--edit-popup-width) - var(--edit-popup-padding));
}
.${editPopupClass}.fadeOut {
animation: fade-out ${fadeOutDelay}ms ease;
}
`;
const destroyEditPopupSyntax = `() => {
if (!window.editPopup) {
return;
}
const popup = editPopup;
popup.classList.add('fadeOut');
window.editPopup = null;
setTimeout(() => {
popup.remove();
}, ${fadeOutDelay});
}`;
const createEditPopupSyntax = `(parent, resourceId, onclick) => {
if (window.editPopupTimeout) {
clearTimeout(window.editPopupTimeout);
window.editPopupTimeout = undefined;
}
window.editPopupTimeout = setTimeout(${destroyEditPopupSyntax}, ${editPopupDestroyDelay});
if (window.lastEditPopupTarget !== parent) {
(${destroyEditPopupSyntax})();
} else if (window.editPopup) {
return;
}
window.editPopup = document.createElement('div');
const popupButton = document.createElement('button');
const popupIcon = new Image();
popupIcon.alt = ${JSON.stringify(_('Edit'))};
popupIcon.title = popupIcon.alt;
popupIcon.src = ${JSON.stringify(getEditIconSrc(theme))};
popupButton.appendChild(popupIcon);
popupButton.onclick = onclick;
editPopup.appendChild(popupButton);
editPopup.classList.add(${JSON.stringify(editPopupClass)});
parent.insertAdjacentElement('afterEnd', editPopup);
// Ensure that the edit popup is focused immediately by screen
// readers.
editPopup.focus();
window.lastEditPopupTarget = parent;
}`;
return { createEditPopupSyntax, destroyEditPopupSyntax, editPopupCss };
};
const useEditPopup = (themeId: number) => {
return useMemo(() => {
return getEditPopupSource(themeStyle(themeId));
}, [themeId]);
};
export default useEditPopup;