You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-11-23 22:36:32 +02:00
Mobile: Add a Rich Text Editor (#12748)
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { AbstractComponent, Editor, BackgroundComponentBackgroundType, Erase, Vec2, Rect2 } from 'js-draw';
|
||||
|
||||
const applyTemplateToEditor = async (editor: Editor, templateData: string) => {
|
||||
let backgroundComponent: AbstractComponent|null = null;
|
||||
let imageSize = editor.getImportExportRect().size;
|
||||
let autoresize = true;
|
||||
|
||||
try {
|
||||
const templateJSON = JSON.parse(templateData);
|
||||
|
||||
const isEmptyTemplate =
|
||||
!('imageSize' in templateJSON) && !('backgroundData' in templateJSON);
|
||||
|
||||
// If the template is empty, add a default background
|
||||
if (isEmptyTemplate) {
|
||||
templateJSON.backgroundData = {
|
||||
'name': 'image-background',
|
||||
'zIndex': 0,
|
||||
'data': {
|
||||
'mainColor': '#ffffff',
|
||||
'backgroundType': BackgroundComponentBackgroundType.SolidColor,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if ('backgroundData' in templateJSON) {
|
||||
backgroundComponent = AbstractComponent.deserialize(
|
||||
templateJSON['backgroundData'],
|
||||
);
|
||||
}
|
||||
|
||||
if ('imageSize' in templateJSON) {
|
||||
imageSize = Vec2.ofXY(templateJSON.imageSize);
|
||||
}
|
||||
|
||||
if ('autoresize' in templateJSON) {
|
||||
autoresize = !!templateJSON.autoresize;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Warning: Invalid image template data: ', e);
|
||||
}
|
||||
|
||||
if (backgroundComponent) {
|
||||
// Remove the old background (if any)
|
||||
const previousBackground = editor.image.getBackgroundComponents();
|
||||
if (previousBackground.length > 0) {
|
||||
const removeBackgroundCommand = new Erase(previousBackground);
|
||||
await editor.dispatchNoAnnounce(removeBackgroundCommand, false);
|
||||
}
|
||||
|
||||
// Add the new background
|
||||
const addBackgroundCommand = editor.image.addElement(backgroundComponent);
|
||||
await editor.dispatchNoAnnounce(addBackgroundCommand, false);
|
||||
}
|
||||
|
||||
// Set the image size
|
||||
const imageSizeCommand = editor.setImportExportRect(new Rect2(0, 0, imageSize.x, imageSize.y));
|
||||
await editor.dispatchNoAnnounce(imageSizeCommand, false);
|
||||
|
||||
// Enable/disable autoresize
|
||||
await editor.dispatchNoAnnounce(editor.image.setAutoresizeEnabled(autoresize), false);
|
||||
|
||||
// And zoom to the template (false = don't make undoable)
|
||||
await editor.dispatchNoAnnounce(editor.viewport.zoomTo(editor.getImportExportRect()), false);
|
||||
};
|
||||
|
||||
export default applyTemplateToEditor;
|
||||
@@ -0,0 +1,115 @@
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
// Hide warnings from js-draw.
|
||||
// jsdom doesn't support ResizeObserver and HTMLCanvasElement.getContext.
|
||||
HTMLCanvasElement.prototype.getContext = () => null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
window.ResizeObserver = class { public observe() { } } as any;
|
||||
|
||||
import { describe, it, expect, jest } from '@jest/globals';
|
||||
import { Color4, EditorImage, EditorSettings, Path, pathToRenderable, StrokeComponent } from 'js-draw';
|
||||
import { RenderingMode } from 'js-draw';
|
||||
import { createJsDrawEditor } from './index';
|
||||
import { BackgroundComponent } from 'js-draw';
|
||||
import { BackgroundComponentBackgroundType } from 'js-draw';
|
||||
import { MainProcessApi } from './types';
|
||||
import applyTemplateToEditor from './applyTemplateToEditor';
|
||||
|
||||
|
||||
const createEditorWithCallbacks = (callbacks: Partial<MainProcessApi>) => {
|
||||
const toolbarState = '';
|
||||
const locale = 'en';
|
||||
|
||||
const allCallbacks: MainProcessApi = {
|
||||
save: () => {},
|
||||
saveThenClose: ()=> {},
|
||||
closeEditor: ()=> {},
|
||||
setImageHasChanges: ()=> {},
|
||||
updateEditorTemplate: ()=> {},
|
||||
updateToolbarState: ()=> {},
|
||||
onLoadedEditor: ()=> {},
|
||||
writeClipboardText: async ()=>{},
|
||||
readClipboardText: async ()=> '',
|
||||
|
||||
...callbacks,
|
||||
};
|
||||
|
||||
const editorOptions: Partial<EditorSettings> = {
|
||||
// Don't use a CanvasRenderer: jsdom doesn't support DrawingContext2D
|
||||
renderingMode: RenderingMode.DummyRenderer,
|
||||
};
|
||||
|
||||
const localizations = {
|
||||
save: 'Save',
|
||||
close: 'Close',
|
||||
undo: 'Undo',
|
||||
redo: 'Redo',
|
||||
};
|
||||
|
||||
return createJsDrawEditor(allCallbacks, toolbarState, locale, localizations, editorOptions);
|
||||
};
|
||||
|
||||
describe('imageEditor/contentScript/index', () => {
|
||||
it('should trigger autosave callback every few minutes', async () => {
|
||||
let calledAutosaveCount = 0;
|
||||
|
||||
jest.useFakeTimers();
|
||||
const editorControl = createEditorWithCallbacks({
|
||||
save: (_drawing: string, isAutosave: boolean) => {
|
||||
if (isAutosave) {
|
||||
calledAutosaveCount ++;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Load no image and an empty template so that autosave can start
|
||||
await editorControl.loadImageOrTemplate('', '{}', undefined);
|
||||
|
||||
expect(calledAutosaveCount).toBe(0);
|
||||
|
||||
// Using the synchronous version of advanceTimersByTime seems to not
|
||||
// run the asynchronous code used to autosave drawings in createJsDrawEditor.ts.
|
||||
await jest.advanceTimersByTimeAsync(1000 * 60 * 4);
|
||||
|
||||
const lastAutosaveCount = calledAutosaveCount;
|
||||
expect(calledAutosaveCount).toBeGreaterThanOrEqual(1);
|
||||
expect(calledAutosaveCount).toBeLessThan(10);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(1000 * 60 * 10);
|
||||
|
||||
expect(calledAutosaveCount).toBeGreaterThan(lastAutosaveCount);
|
||||
});
|
||||
|
||||
it('should fire has changes callback on first change', () => {
|
||||
let hasChanges = false;
|
||||
const editorControl = createEditorWithCallbacks({
|
||||
setImageHasChanges: (newHasChanges: boolean) => {
|
||||
hasChanges = newHasChanges;
|
||||
},
|
||||
});
|
||||
|
||||
expect(hasChanges).toBe(false);
|
||||
|
||||
const stroke = new StrokeComponent([
|
||||
// A filled shape
|
||||
pathToRenderable(Path.fromString('m0,0 l10,0 l0,10'), { fill: Color4.red }),
|
||||
]);
|
||||
void editorControl.editor.dispatch(EditorImage.addElement(stroke));
|
||||
|
||||
expect(hasChanges).toBe(true);
|
||||
});
|
||||
|
||||
it('default template should be a white grid background', async () => {
|
||||
const editorControl = createEditorWithCallbacks({});
|
||||
const editor = editorControl.editor;
|
||||
|
||||
await applyTemplateToEditor(editor, '{}');
|
||||
|
||||
expect(editor.image.getBackgroundComponents()).toHaveLength(1);
|
||||
|
||||
// Should have a white, solid background
|
||||
const background = editor.image.getBackgroundComponents()[0] as BackgroundComponent;
|
||||
expect(editor.estimateBackgroundColor().eq(Color4.white)).toBe(true);
|
||||
expect(background.getBackgroundType()).toBe(BackgroundComponentBackgroundType.SolidColor);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import '../../utils/polyfills';
|
||||
import { Editor, AbstractToolbar, EditorEventType, EditorSettings, getLocalizationTable, adjustEditorThemeForContrast, BaseWidget } from 'js-draw';
|
||||
import { MaterialIconProvider } from '@js-draw/material-icons';
|
||||
import 'js-draw/bundledStyles';
|
||||
import applyTemplateToEditor from './applyTemplateToEditor';
|
||||
import watchEditorForTemplateChanges from './watchEditorForTemplateChanges';
|
||||
import { MainProcessApi, LocalizedStrings, EditorProcessApi } from './types';
|
||||
import startAutosaveLoop from './startAutosaveLoop';
|
||||
import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger';
|
||||
|
||||
|
||||
const restoreToolbarState = (toolbar: AbstractToolbar, state: string) => {
|
||||
if (state) {
|
||||
// deserializeState throws on invalid argument.
|
||||
try {
|
||||
toolbar.deserializeState(state);
|
||||
} catch (e) {
|
||||
console.warn('Error deserializing toolbar state: ', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const createJsDrawEditor = (
|
||||
callbacks: MainProcessApi,
|
||||
initialToolbarState: string,
|
||||
locale: string,
|
||||
defaultLocalizations: LocalizedStrings,
|
||||
|
||||
// Intended for automated tests.
|
||||
editorSettings: Partial<EditorSettings> = {},
|
||||
) => {
|
||||
const parentElement = document.body;
|
||||
const editor = new Editor(parentElement, {
|
||||
// Try to use the Joplin locale, but fall back to the system locale if
|
||||
// js-draw doesn't support it.
|
||||
localization: {
|
||||
...getLocalizationTable([locale, ...navigator.languages]),
|
||||
...defaultLocalizations,
|
||||
},
|
||||
iconProvider: new MaterialIconProvider(),
|
||||
clipboardApi: {
|
||||
read: async () => {
|
||||
const result = new Map<string, string>();
|
||||
|
||||
const clipboardText = await callbacks.readClipboardText();
|
||||
if (clipboardText) {
|
||||
result.set('text/plain', clipboardText);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
write: async (data) => {
|
||||
const getTextForMime = async (mime: string) => {
|
||||
const text = data.get(mime);
|
||||
if (typeof text === 'string') {
|
||||
return text;
|
||||
}
|
||||
if (text) {
|
||||
return await (await text).text();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const svgData = await getTextForMime('image/svg+xml');
|
||||
if (svgData) {
|
||||
return callbacks.writeClipboardText(svgData);
|
||||
}
|
||||
|
||||
const textData = await getTextForMime('text/plain');
|
||||
return callbacks.writeClipboardText(textData);
|
||||
},
|
||||
},
|
||||
...editorSettings,
|
||||
});
|
||||
|
||||
const toolbar = editor.addToolbar();
|
||||
|
||||
const maxSpacerSize = '20px';
|
||||
toolbar.addSpacer({
|
||||
grow: 1,
|
||||
maxSize: maxSpacerSize,
|
||||
});
|
||||
|
||||
// Override the default "Exit" label:
|
||||
toolbar.addExitButton(
|
||||
() => callbacks.closeEditor(true), {
|
||||
label: defaultLocalizations.close,
|
||||
},
|
||||
);
|
||||
|
||||
toolbar.addSpacer({
|
||||
grow: 1,
|
||||
maxSize: maxSpacerSize,
|
||||
});
|
||||
|
||||
// saveButton needs to be defined after the following callbacks.
|
||||
// As such, this variable can't be made const.
|
||||
// eslint-disable-next-line prefer-const
|
||||
let saveButton: BaseWidget;
|
||||
|
||||
let lastHadChanges: boolean|null = null;
|
||||
const setImageHasChanges = (hasChanges: boolean) => {
|
||||
if (lastHadChanges !== hasChanges) {
|
||||
saveButton.setDisabled(!hasChanges);
|
||||
callbacks.setImageHasChanges(hasChanges);
|
||||
lastHadChanges = hasChanges;
|
||||
}
|
||||
};
|
||||
|
||||
const getEditorSVG = () => {
|
||||
return editor.toSVG({
|
||||
// Grow small images to this minimum size
|
||||
minDimension: 50,
|
||||
}).outerHTML;
|
||||
};
|
||||
|
||||
const saveNow = () => {
|
||||
callbacks.save(getEditorSVG(), false);
|
||||
|
||||
// The image is now up-to-date with the resource
|
||||
setImageHasChanges(false);
|
||||
};
|
||||
|
||||
saveButton = toolbar.addSaveButton(saveNow);
|
||||
|
||||
// Load and save toolbar-related state (e.g. pen sizes/colors).
|
||||
restoreToolbarState(toolbar, initialToolbarState);
|
||||
editor.notifier.on(EditorEventType.ToolUpdated, () => {
|
||||
callbacks.updateToolbarState(toolbar.serializeState());
|
||||
});
|
||||
|
||||
setImageHasChanges(false);
|
||||
|
||||
editor.notifier.on(EditorEventType.UndoRedoStackUpdated, () => {
|
||||
setImageHasChanges(true);
|
||||
});
|
||||
|
||||
// Disable save (a full save can't be done until the entire image
|
||||
// has been loaded).
|
||||
saveButton.setDisabled(true);
|
||||
|
||||
// Show a loading message until the template is loaded.
|
||||
editor.showLoadingWarning(0);
|
||||
editor.setReadOnly(true);
|
||||
|
||||
const fetchInitialSvgData = (resourceUrl: string) => {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
if (!resourceUrl) {
|
||||
resolve('');
|
||||
return;
|
||||
}
|
||||
|
||||
// fetch seems to be unable to request file:// URLs.
|
||||
// https://github.com/react-native-webview/react-native-webview/issues/1560#issuecomment-1783611805
|
||||
const request = new XMLHttpRequest();
|
||||
|
||||
const onError = () => {
|
||||
reject(new Error(`Failed to load initial SVG data: ${request.status}, ${request.statusText}, ${request.responseText}`));
|
||||
};
|
||||
|
||||
request.addEventListener('load', _ => {
|
||||
resolve(request.responseText);
|
||||
});
|
||||
request.addEventListener('error', onError);
|
||||
request.addEventListener('abort', onError);
|
||||
|
||||
request.open('GET', resourceUrl);
|
||||
request.send();
|
||||
});
|
||||
};
|
||||
|
||||
const themeStyles = document.createElement('style');
|
||||
parentElement.appendChild(themeStyles);
|
||||
|
||||
const editorControl = {
|
||||
editor,
|
||||
loadImageOrTemplate: async (resourceUrl: string, templateData: string, svgData: string|undefined) => {
|
||||
// loadFromSVG shows its own loading message. Hide the original.
|
||||
editor.hideLoadingWarning();
|
||||
|
||||
// On mobile, fetching the SVG data is much faster than transferring it via IPC. However, fetch
|
||||
// doesn't work for this when running in a web browser (virtual file system).
|
||||
svgData ??= await fetchInitialSvgData(resourceUrl);
|
||||
|
||||
// Load from a template if no initial data
|
||||
if (svgData === '') {
|
||||
await applyTemplateToEditor(editor, templateData);
|
||||
} else {
|
||||
await editor.loadFromSVG(svgData);
|
||||
}
|
||||
|
||||
// We can now edit and save safely (without data loss).
|
||||
editor.setReadOnly(false);
|
||||
|
||||
void startAutosaveLoop(editor, callbacks.save);
|
||||
watchEditorForTemplateChanges(editor, templateData, callbacks.updateEditorTemplate);
|
||||
},
|
||||
onThemeUpdate: (css: string|null) => {
|
||||
if (css) {
|
||||
themeStyles.textContent = css;
|
||||
}
|
||||
|
||||
// Slightly adjusts the given editor's theme colors. This ensures that the colors chosen for
|
||||
// the editor have proper contrast.
|
||||
adjustEditorThemeForContrast(editor);
|
||||
},
|
||||
saveNow,
|
||||
saveThenExit: async () => {
|
||||
callbacks.saveThenClose(getEditorSVG());
|
||||
},
|
||||
};
|
||||
|
||||
editorControl.onThemeUpdate(null);
|
||||
|
||||
callbacks.onLoadedEditor();
|
||||
|
||||
return editorControl;
|
||||
};
|
||||
|
||||
type EditorControl = ReturnType<typeof createJsDrawEditor>;
|
||||
export const createMessenger = (getEditor: ()=> EditorControl) => {
|
||||
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>(
|
||||
'image-editor', {
|
||||
onThemeUpdate: async (css: string) => {
|
||||
getEditor().onThemeUpdate(css);
|
||||
},
|
||||
saveThenExit: () => getEditor().saveThenExit(),
|
||||
},
|
||||
);
|
||||
return messenger;
|
||||
};
|
||||
|
||||
export default createJsDrawEditor;
|
||||
@@ -0,0 +1,26 @@
|
||||
|
||||
import { Editor } from 'js-draw';
|
||||
import { SaveDrawingCallback } from './types';
|
||||
|
||||
const startAutosaveLoop = async (
|
||||
editor: Editor,
|
||||
saveDrawing: SaveDrawingCallback,
|
||||
) => {
|
||||
// Autosave every two minutes.
|
||||
const delayTime = 1000 * 60 * 2; // ms
|
||||
|
||||
const createAutosave = async () => {
|
||||
const savedSVG = await editor.toSVGAsync();
|
||||
saveDrawing(savedSVG.outerHTML, true);
|
||||
};
|
||||
|
||||
while (true) {
|
||||
await (new Promise<void>(resolve => {
|
||||
setTimeout(() => resolve(), delayTime);
|
||||
}));
|
||||
|
||||
await createAutosave();
|
||||
}
|
||||
};
|
||||
|
||||
export default startAutosaveLoop;
|
||||
@@ -0,0 +1,33 @@
|
||||
|
||||
export type SaveDrawingCallback = (svgData: string, isAutosave: boolean)=> void;
|
||||
export type UpdateEditorTemplateCallback = (newTemplate: string)=> void;
|
||||
export type UpdateToolbarCallback = (toolbarData: string)=> void;
|
||||
|
||||
export interface MainProcessApi {
|
||||
onLoadedEditor: ()=> void;
|
||||
|
||||
save: SaveDrawingCallback;
|
||||
updateEditorTemplate: UpdateEditorTemplateCallback;
|
||||
updateToolbarState: UpdateToolbarCallback;
|
||||
|
||||
saveThenClose: (svgData: string)=> void;
|
||||
closeEditor: (promptIfUnsaved: boolean)=> void;
|
||||
setImageHasChanges: (hasChanges: boolean)=> void;
|
||||
|
||||
writeClipboardText: (text: string)=> Promise<void>;
|
||||
readClipboardText: ()=> Promise<string>;
|
||||
}
|
||||
|
||||
export interface EditorProcessApi {
|
||||
saveThenExit(): Promise<void>;
|
||||
onThemeUpdate(newCss: string): Promise<void>;
|
||||
}
|
||||
|
||||
// Overrides translations in js-draw -- as of the time of this writing,
|
||||
// Joplin has many common strings localized better than js-draw.
|
||||
export interface LocalizedStrings {
|
||||
save: string;
|
||||
close: string;
|
||||
undo: string;
|
||||
redo: string;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Editor, BackgroundComponent, EditorEventType, Vec2 } from 'js-draw';
|
||||
|
||||
const watchEditorForTemplateChanges = (
|
||||
editor: Editor, initialTemplate: string, updateTemplate: (templateData: string)=> void,
|
||||
) => {
|
||||
const computeTemplate = (): string => {
|
||||
let backgroundSize: Vec2|null = null;
|
||||
|
||||
// Only store the background size if the size isn't determined
|
||||
// by the editor content. In this case, the background always
|
||||
// appears to be full screen.
|
||||
if (!editor.image.getAutoresizeEnabled()) {
|
||||
backgroundSize = editor.getImportExportRect().size;
|
||||
|
||||
// Constrain the size: Don't allow an extremely small or extremely large template.
|
||||
// Map components to constrained components.
|
||||
backgroundSize = backgroundSize.map(component => {
|
||||
const minDimen = 45;
|
||||
const maxDimen = 5000;
|
||||
|
||||
return Math.max(Math.min(component, maxDimen), minDimen);
|
||||
});
|
||||
}
|
||||
|
||||
// Find the topmost background component (if any)
|
||||
let backgroundComponent: BackgroundComponent|null = null;
|
||||
for (const component of editor.image.getBackgroundComponents()) {
|
||||
if (component instanceof BackgroundComponent) {
|
||||
backgroundComponent = component;
|
||||
}
|
||||
}
|
||||
|
||||
const templateData = {
|
||||
imageSize: backgroundSize?.xy,
|
||||
backgroundData: backgroundComponent?.serialize(),
|
||||
autoresize: editor.image.getAutoresizeEnabled(),
|
||||
};
|
||||
return JSON.stringify(templateData);
|
||||
};
|
||||
|
||||
let lastTemplate = initialTemplate;
|
||||
const updateTemplateIfNecessary = () => {
|
||||
const newTemplate = computeTemplate();
|
||||
|
||||
if (newTemplate !== lastTemplate) {
|
||||
updateTemplate(newTemplate);
|
||||
lastTemplate = newTemplate;
|
||||
}
|
||||
};
|
||||
|
||||
// Whenever a command is done/undone, re-calculate the template & save.
|
||||
editor.notifier.on(EditorEventType.CommandDone, () => {
|
||||
updateTemplateIfNecessary();
|
||||
});
|
||||
|
||||
editor.notifier.on(EditorEventType.CommandUndone, () => {
|
||||
updateTemplateIfNecessary();
|
||||
});
|
||||
};
|
||||
|
||||
export default watchEditorForTemplateChanges;
|
||||
@@ -0,0 +1,200 @@
|
||||
import * as React from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { Theme } from '@joplin/lib/themes/type';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import useEditorMessenger from './utils/useEditorMessenger';
|
||||
import { WebViewControl } from '../../components/ExtendedWebView/types';
|
||||
import { LocalizedStrings } from './contentScript/types';
|
||||
import { SetUpResult } from '../types';
|
||||
|
||||
|
||||
type OnSaveCallback = (svgData: string)=> Promise<void>;
|
||||
type OnCancelCallback = ()=> void;
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
resourceFilename: string|null;
|
||||
onSave: OnSaveCallback;
|
||||
onAutoSave: OnSaveCallback;
|
||||
onRequestCloseEditor: OnCancelCallback;
|
||||
onSetImageChanged: (changed: boolean)=> void;
|
||||
webViewRef: React.RefObject<WebViewControl>;
|
||||
}
|
||||
|
||||
export interface ImageEditorControl {
|
||||
saveThenExit(): Promise<void>;
|
||||
}
|
||||
|
||||
const useCss = (editorTheme: Theme) => {
|
||||
return useMemo(() => {
|
||||
// Ensure we have contrast between the background and selection. Some themes
|
||||
// have the same backgroundColor and selectionColor2. (E.g. Aritim Dark)
|
||||
let selectionBackgroundColor = editorTheme.selectedColor2;
|
||||
if (selectionBackgroundColor === editorTheme.backgroundColor) {
|
||||
selectionBackgroundColor = editorTheme.selectedColor;
|
||||
}
|
||||
|
||||
return `
|
||||
:root .imageEditorContainer {
|
||||
--background-color-1: ${editorTheme.backgroundColor};
|
||||
--foreground-color-1: ${editorTheme.color};
|
||||
--background-color-2: ${editorTheme.backgroundColor3};
|
||||
--foreground-color-2: ${editorTheme.color3};
|
||||
--background-color-3: ${editorTheme.raisedBackgroundColor};
|
||||
--foreground-color-3: ${editorTheme.raisedColor};
|
||||
|
||||
--selection-background-color: ${editorTheme.backgroundColorHover3};
|
||||
--selection-foreground-color: ${editorTheme.color3};
|
||||
--primary-action-foreground-color: ${editorTheme.color4};
|
||||
|
||||
--primary-shadow-color: ${editorTheme.colorFaded};
|
||||
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body, html {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Hide the scrollbar. See scrollbar accessibility concerns
|
||||
(https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-width#accessibility_concerns)
|
||||
for why this isn't done in js-draw itself. */
|
||||
.toolbar-tool-row::-webkit-scrollbar {
|
||||
display: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Hide the save/close icons on small screens. This isn't done in the upstream
|
||||
js-draw repository partially because it isn't as well localized as Joplin
|
||||
(icons can be used to suggest the meaning of a button when a translation is
|
||||
unavailable). */
|
||||
.toolbar-edge-toolbar:not(.one-row) .toolwidget-tag--save .toolbar-icon,
|
||||
.toolbar-edge-toolbar:not(.one-row) .toolwidget-tag--exit .toolbar-icon {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
}, [editorTheme]);
|
||||
};
|
||||
|
||||
const useWebViewSetup = ({
|
||||
webViewRef,
|
||||
themeId,
|
||||
resourceFilename,
|
||||
onSetImageChanged,
|
||||
onSave,
|
||||
onAutoSave,
|
||||
onRequestCloseEditor,
|
||||
}: Props): SetUpResult<ImageEditorControl> => {
|
||||
const editorTheme: Theme = themeStyle(themeId);
|
||||
|
||||
// A set of localization overrides (Joplin is better localized than js-draw).
|
||||
// All localizable strings (some unused?) can be found at
|
||||
// https://github.com/personalizedrefrigerator/js-draw/blob/main/.github/ISSUE_TEMPLATE/translation-js-draw-new.yml
|
||||
const localizedStrings: LocalizedStrings = useMemo(() => ({
|
||||
save: _('Save'),
|
||||
close: _('Close'),
|
||||
undo: _('Undo'),
|
||||
redo: _('Redo'),
|
||||
}), []);
|
||||
|
||||
const appInfo = useMemo(() => {
|
||||
return {
|
||||
name: 'Joplin',
|
||||
description: `v${shim.appVersion()}`,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const injectedJavaScript = useMemo(() => `
|
||||
if (window.imageEditorControl === undefined) {
|
||||
${shim.injectedJs('imageEditorBundle')}
|
||||
|
||||
const messenger = imageEditorBundle.createMessenger(() => window.imageEditorControl);
|
||||
window.imageEditorControl = imageEditorBundle.createJsDrawEditor(
|
||||
messenger.remoteApi,
|
||||
${JSON.stringify(Setting.value('imageeditor.jsdrawToolbar'))},
|
||||
${JSON.stringify(Setting.value('locale'))},
|
||||
${JSON.stringify(localizedStrings)},
|
||||
${JSON.stringify({
|
||||
appInfo,
|
||||
...(shim.mobilePlatform() === 'web' ? {
|
||||
// Use the browser-default clipboard API on web.
|
||||
clipboardApi: null,
|
||||
} : {}),
|
||||
})},
|
||||
);
|
||||
}
|
||||
`, [localizedStrings, appInfo]);
|
||||
|
||||
const onReadyToLoadData = useCallback(async () => {
|
||||
const getInitialInjectedData = async () => {
|
||||
// On mobile, it's faster to load the image within the WebView with an XMLHttpRequest.
|
||||
// In this case, the image is loaded elsewhere.
|
||||
if (Platform.OS !== 'web') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// On web, however, this doesn't work, so the image needs to be loaded here.
|
||||
if (!resourceFilename) {
|
||||
return '';
|
||||
}
|
||||
return await shim.fsDriver().readFile(resourceFilename, 'utf-8');
|
||||
};
|
||||
// It can take some time for initialSVGData to be transferred to the WebView.
|
||||
// Thus, do so after the main content has been loaded.
|
||||
webViewRef.current.injectJS(`(async () => {
|
||||
if (window.imageEditorControl) {
|
||||
const initialSVGPath = ${JSON.stringify(resourceFilename)};
|
||||
const initialTemplateData = ${JSON.stringify(Setting.value('imageeditor.imageTemplate'))};
|
||||
const initialData = ${JSON.stringify(await getInitialInjectedData())};
|
||||
|
||||
imageEditorControl.loadImageOrTemplate(initialSVGPath, initialTemplateData, initialData);
|
||||
}
|
||||
})();`);
|
||||
}, [webViewRef, resourceFilename]);
|
||||
|
||||
const messenger = useEditorMessenger({
|
||||
webViewRef,
|
||||
setImageChanged: onSetImageChanged,
|
||||
onReadyToLoadData,
|
||||
onSave,
|
||||
onAutoSave,
|
||||
onRequestCloseEditor,
|
||||
});
|
||||
const messengerRef = useRef(messenger);
|
||||
messengerRef.current = messenger;
|
||||
|
||||
const css = useCss(editorTheme);
|
||||
useEffect(() => {
|
||||
void messengerRef.current.remoteApi.onThemeUpdate(css);
|
||||
}, [css]);
|
||||
|
||||
const editorControl = useMemo((): ImageEditorControl => {
|
||||
return {
|
||||
saveThenExit: () => messenger.remoteApi.saveThenExit(),
|
||||
};
|
||||
}, [messenger]);
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
pageSetup: {
|
||||
js: injectedJavaScript,
|
||||
css,
|
||||
},
|
||||
api: editorControl,
|
||||
webViewEventHandlers: {
|
||||
onLoadEnd: messenger.onWebViewLoaded,
|
||||
onMessage: messenger.onWebViewMessage,
|
||||
},
|
||||
};
|
||||
}, [editorControl, messenger, injectedJavaScript, css]);
|
||||
};
|
||||
|
||||
export default useWebViewSetup;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { RefObject, useMemo, useRef } from 'react';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import Clipboard from '@react-native-clipboard/clipboard';
|
||||
import { MainProcessApi, EditorProcessApi } from '../contentScript/types';
|
||||
import { WebViewControl } from '../../../components/ExtendedWebView/types';
|
||||
import RNToWebViewMessenger from '../../../utils/ipc/RNToWebViewMessenger';
|
||||
|
||||
interface Props {
|
||||
webViewRef: RefObject<WebViewControl>;
|
||||
setImageChanged(changed: boolean): void;
|
||||
|
||||
onReadyToLoadData(): void;
|
||||
onSave(data: string): void;
|
||||
onAutoSave(data: string): void;
|
||||
onRequestCloseEditor(promptIfUnsaved: boolean): void;
|
||||
}
|
||||
|
||||
const useEditorMessenger = ({
|
||||
webViewRef: webviewRef, setImageChanged, onReadyToLoadData, onRequestCloseEditor, onSave, onAutoSave,
|
||||
}: Props) => {
|
||||
const events = { onRequestCloseEditor, onSave, onAutoSave, onReadyToLoadData };
|
||||
// Use a ref to avoid unnecessary rerenders
|
||||
const eventsRef = useRef(events);
|
||||
eventsRef.current = events;
|
||||
|
||||
return useMemo(() => {
|
||||
const localApi: MainProcessApi = {
|
||||
updateEditorTemplate: newTemplate => {
|
||||
Setting.setValue('imageeditor.imageTemplate', newTemplate);
|
||||
},
|
||||
updateToolbarState: newData => {
|
||||
Setting.setValue('imageeditor.jsdrawToolbar', newData);
|
||||
},
|
||||
setImageHasChanges: hasChanges => {
|
||||
setImageChanged(hasChanges);
|
||||
},
|
||||
onLoadedEditor: () => {
|
||||
eventsRef.current.onReadyToLoadData();
|
||||
},
|
||||
saveThenClose: svgData => {
|
||||
eventsRef.current.onSave(svgData);
|
||||
eventsRef.current.onRequestCloseEditor(false);
|
||||
},
|
||||
save: (svgData, isAutosave) => {
|
||||
if (isAutosave) {
|
||||
return eventsRef.current.onAutoSave(svgData);
|
||||
} else {
|
||||
return eventsRef.current.onSave(svgData);
|
||||
}
|
||||
},
|
||||
closeEditor: promptIfUnsaved => {
|
||||
eventsRef.current.onRequestCloseEditor(promptIfUnsaved);
|
||||
},
|
||||
writeClipboardText: async text => {
|
||||
Clipboard.setString(text);
|
||||
},
|
||||
readClipboardText: async () => {
|
||||
return Clipboard.getString();
|
||||
},
|
||||
};
|
||||
const messenger = new RNToWebViewMessenger<MainProcessApi, EditorProcessApi>(
|
||||
'image-editor', webviewRef, localApi,
|
||||
);
|
||||
return messenger;
|
||||
}, [webviewRef, setImageChanged]);
|
||||
};
|
||||
|
||||
export default useEditorMessenger;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createEditor } from '@joplin/editor/CodeMirror';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import WebViewToRNMessenger from '../../utils/ipc/WebViewToRNMessenger';
|
||||
import { EditorProcessApi, EditorProps, MainProcessApi } from './types';
|
||||
import readFileToBase64 from '../utils/readFileToBase64';
|
||||
|
||||
export { default as setUpLogger } from '../utils/setUpLogger';
|
||||
|
||||
export const initializeEditor = ({
|
||||
parentElementClassName,
|
||||
initialText,
|
||||
initialNoteId,
|
||||
settings,
|
||||
}: EditorProps) => {
|
||||
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('markdownEditor', null);
|
||||
|
||||
const parentElement = document.getElementsByClassName(parentElementClassName)[0] as HTMLElement;
|
||||
if (!parentElement) {
|
||||
throw new Error(`Unable to find parent element for editor (class name: ${JSON.stringify(parentElementClassName)})`);
|
||||
}
|
||||
|
||||
const control = createEditor(parentElement, {
|
||||
initialText,
|
||||
initialNoteId,
|
||||
settings,
|
||||
|
||||
onPasteFile: async (data) => {
|
||||
const base64 = await readFileToBase64(data);
|
||||
await messenger.remoteApi.onPasteFile(data.type, base64);
|
||||
},
|
||||
|
||||
onLogMessage: message => {
|
||||
void messenger.remoteApi.logMessage(message);
|
||||
},
|
||||
onEvent: (event): void => {
|
||||
void messenger.remoteApi.onEditorEvent(event);
|
||||
},
|
||||
});
|
||||
|
||||
// Works around https://github.com/laurent22/joplin/issues/10047 by handling
|
||||
// the text/uri-list MIME type when pasting, rather than sending the paste event
|
||||
// to CodeMirror.
|
||||
//
|
||||
// TODO: Remove this workaround when the issue has been fixed upstream.
|
||||
control.on('paste', (_editor, event: ClipboardEvent) => {
|
||||
const clipboardData = event.clipboardData;
|
||||
if (clipboardData.types.length === 1 && clipboardData.types[0] === 'text/uri-list') {
|
||||
event.preventDefault();
|
||||
control.insertText(clipboardData.getData('text/uri-list'));
|
||||
}
|
||||
});
|
||||
|
||||
// Note: Just adding an onclick listener seems sufficient to focus the editor when its background
|
||||
// is tapped.
|
||||
parentElement.addEventListener('click', (event) => {
|
||||
const activeElement = document.querySelector(':focus');
|
||||
if (!parentElement.contains(activeElement) && event.target === parentElement) {
|
||||
focus('initial editor focus', control);
|
||||
}
|
||||
});
|
||||
|
||||
messenger.setLocalInterface({
|
||||
editor: control,
|
||||
});
|
||||
return control;
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { EditorEvent } from '@joplin/editor/events';
|
||||
import { EditorControl, EditorSettings } from '@joplin/editor/types';
|
||||
|
||||
export interface EditorProcessApi {
|
||||
editor: EditorControl;
|
||||
}
|
||||
|
||||
export interface SelectionRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface EditorProps {
|
||||
parentElementClassName: string;
|
||||
initialText: string;
|
||||
initialNoteId: string;
|
||||
settings: EditorSettings;
|
||||
}
|
||||
|
||||
export interface MainProcessApi {
|
||||
onEditorEvent(event: EditorEvent): Promise<void>;
|
||||
logMessage(message: string): Promise<void>;
|
||||
onPasteFile(type: string, dataBase64: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { EditorProcessApi, EditorProps as EditorOptions, SelectionRange, MainProcessApi } from './types';
|
||||
import { SetUpResult } from '../types';
|
||||
import { SearchState } from '@joplin/editor/types';
|
||||
import { RefObject, useEffect, useMemo, useRef } from 'react';
|
||||
import { OnMessageEvent, WebViewControl } from '../../components/ExtendedWebView/types';
|
||||
import { EditorEvent } from '@joplin/editor/events';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
|
||||
|
||||
const logger = Logger.create('markdownEditor');
|
||||
|
||||
interface Props {
|
||||
editorOptions: EditorOptions;
|
||||
initialSelection: SelectionRange;
|
||||
noteHash: string;
|
||||
globalSearch: string;
|
||||
onEditorEvent: (event: EditorEvent)=> void;
|
||||
onAttachFile: (mime: string, base64: string)=> void;
|
||||
|
||||
webviewRef: RefObject<WebViewControl>;
|
||||
}
|
||||
|
||||
const defaultSearchState: SearchState = {
|
||||
useRegex: false,
|
||||
caseSensitive: false,
|
||||
|
||||
searchText: '',
|
||||
replaceText: '',
|
||||
dialogVisible: false,
|
||||
};
|
||||
|
||||
const useWebViewSetup = ({
|
||||
editorOptions, initialSelection, noteHash, globalSearch, webviewRef, onEditorEvent, onAttachFile,
|
||||
}: Props): SetUpResult<EditorProcessApi> => {
|
||||
const setInitialSelectionJs = initialSelection ? `
|
||||
cm.select(${initialSelection.start}, ${initialSelection.end});
|
||||
cm.execCommand('scrollSelectionIntoView');
|
||||
` : '';
|
||||
const jumpToHashJs = noteHash ? `
|
||||
cm.jumpToHash(${JSON.stringify(noteHash)});
|
||||
` : '';
|
||||
const setInitialSearchJs = globalSearch ? `
|
||||
cm.setSearchState(${JSON.stringify({
|
||||
...defaultSearchState,
|
||||
searchText: globalSearch,
|
||||
})})
|
||||
` : '';
|
||||
|
||||
const injectedJavaScript = useMemo(() => `
|
||||
if (!window.cm) {
|
||||
${shim.injectedJs('markdownEditorBundle')};
|
||||
markdownEditorBundle.setUpLogger();
|
||||
|
||||
window.cm = markdownEditorBundle.initializeEditor(
|
||||
${JSON.stringify(editorOptions)}
|
||||
);
|
||||
|
||||
${jumpToHashJs}
|
||||
// Set the initial selection after jumping to the header -- the initial selection,
|
||||
// if specified, should take precedence.
|
||||
${setInitialSelectionJs}
|
||||
${setInitialSearchJs}
|
||||
|
||||
window.onresize = () => {
|
||||
cm.execCommand('scrollSelectionIntoView');
|
||||
};
|
||||
}
|
||||
`, [jumpToHashJs, setInitialSearchJs, setInitialSelectionJs, editorOptions]);
|
||||
|
||||
// Scroll to the new hash, if it changes.
|
||||
const isFirstScrollRef = useRef(true);
|
||||
useEffect(() => {
|
||||
// The first "jump to header" is handled during editor setup and shouldn't
|
||||
// be handled a second time:
|
||||
if (isFirstScrollRef.current) {
|
||||
isFirstScrollRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (jumpToHashJs && webviewRef.current) {
|
||||
webviewRef.current.injectJS(jumpToHashJs);
|
||||
}
|
||||
}, [jumpToHashJs, webviewRef]);
|
||||
|
||||
const onEditorEventRef = useRef(onEditorEvent);
|
||||
onEditorEventRef.current = onEditorEvent;
|
||||
|
||||
const onAttachRef = useRef(onAttachFile);
|
||||
onAttachRef.current = onAttachFile;
|
||||
|
||||
const editorMessenger = useMemo(() => {
|
||||
const localApi: MainProcessApi = {
|
||||
async onEditorEvent(event) {
|
||||
onEditorEventRef.current(event);
|
||||
},
|
||||
async logMessage(message) {
|
||||
logger.debug('CodeMirror:', message);
|
||||
},
|
||||
async onPasteFile(type, data) {
|
||||
onAttachRef.current(type, data);
|
||||
},
|
||||
};
|
||||
const messenger = new RNToWebViewMessenger<MainProcessApi, EditorProcessApi>(
|
||||
'markdownEditor', webviewRef, localApi,
|
||||
);
|
||||
return messenger;
|
||||
}, [webviewRef]);
|
||||
|
||||
const webViewEventHandlers = useMemo(() => {
|
||||
return {
|
||||
onLoadEnd: () => {
|
||||
editorMessenger.onWebViewLoaded();
|
||||
},
|
||||
onMessage: (event: OnMessageEvent) => {
|
||||
editorMessenger.onWebViewMessage(event);
|
||||
},
|
||||
};
|
||||
}, [editorMessenger]);
|
||||
|
||||
const api = useMemo(() => {
|
||||
return editorMessenger.remoteApi;
|
||||
}, [editorMessenger]);
|
||||
|
||||
const editorSettings = editorOptions.settings;
|
||||
useEffect(() => {
|
||||
api.editor.updateSettings(editorSettings);
|
||||
}, [api, editorSettings]);
|
||||
|
||||
return useMemo(() => ({
|
||||
pageSetup: {
|
||||
js: injectedJavaScript,
|
||||
css: '',
|
||||
},
|
||||
api,
|
||||
webViewEventHandlers,
|
||||
}), [injectedJavaScript, api, webViewEventHandlers]);
|
||||
};
|
||||
|
||||
export default useWebViewSetup;
|
||||
@@ -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;
|
||||
73
packages/app-mobile/contentScripts/rendererBundle/types.ts
Normal file
73
packages/app-mobile/contentScripts/rendererBundle/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1,131 @@
|
||||
import '../utils/polyfills';
|
||||
import { createEditor } from '@joplin/editor/ProseMirror';
|
||||
import { EditorProcessApi, EditorProps, MainProcessApi } from './types';
|
||||
import WebViewToRNMessenger from '../../utils/ipc/WebViewToRNMessenger';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import '@joplin/editor/ProseMirror/styles';
|
||||
import HtmlToMd from '@joplin/lib/HtmlToMd';
|
||||
import readFileToBase64 from '../utils/readFileToBase64';
|
||||
import { EditorLanguageType } from '@joplin/editor/types';
|
||||
|
||||
const postprocessHtml = (html: HTMLElement) => {
|
||||
// Fix resource URLs
|
||||
const resources = html.querySelectorAll<HTMLImageElement>('img[data-resource-id]');
|
||||
for (const resource of resources) {
|
||||
const resourceId = resource.getAttribute('data-resource-id');
|
||||
resource.src = `:/${resourceId}`;
|
||||
}
|
||||
|
||||
// Re-add newlines to data-joplin-source-* that were removed
|
||||
// by ProseMirror.
|
||||
// TODO: Try to find a better solution
|
||||
const sourceBlocks = html.querySelectorAll<HTMLPreElement>(
|
||||
'pre[data-joplin-source-open][data-joplin-source-close].joplin-source',
|
||||
);
|
||||
for (const sourceBlock of sourceBlocks) {
|
||||
const isBlock = sourceBlock.parentElement.tagName !== 'SPAN';
|
||||
if (isBlock) {
|
||||
const originalOpen = sourceBlock.getAttribute('data-joplin-source-open');
|
||||
const originalClose = sourceBlock.getAttribute('data-joplin-source-close');
|
||||
sourceBlock.setAttribute('data-joplin-source-open', `${originalOpen}\n`);
|
||||
sourceBlock.setAttribute('data-joplin-source-close', `\n${originalClose}`);
|
||||
}
|
||||
}
|
||||
|
||||
return html;
|
||||
};
|
||||
|
||||
const wrapHtmlForMarkdownConversion = (html: HTMLElement) => {
|
||||
// Add a container element -- when converting to HTML, Turndown
|
||||
// sometimes doesn't process the toplevel element in the same way
|
||||
// as other elements (e.g. in the case of Joplin source blocks).
|
||||
const wrapper = html.ownerDocument.createElement('div');
|
||||
wrapper.appendChild(html.cloneNode(true));
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
|
||||
const htmlToMd = new HtmlToMd();
|
||||
const htmlToMarkdown = (html: HTMLElement): string => {
|
||||
html = postprocessHtml(html);
|
||||
|
||||
return htmlToMd.parse(html, { preserveColorStyles: true });
|
||||
};
|
||||
|
||||
export const initialize = async ({
|
||||
settings,
|
||||
initialText,
|
||||
initialNoteId,
|
||||
parentElementClassName,
|
||||
}: EditorProps) => {
|
||||
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('rich-text-editor', null);
|
||||
const parentElement = document.getElementsByClassName(parentElementClassName)[0];
|
||||
if (!parentElement) throw new Error('Parent element not found');
|
||||
if (!(parentElement instanceof HTMLElement)) {
|
||||
throw new Error('Parent node is not an element.');
|
||||
}
|
||||
|
||||
const assetContainer = document.createElement('div');
|
||||
assetContainer.id = 'joplin-container-pluginAssetsContainer';
|
||||
document.body.appendChild(assetContainer);
|
||||
|
||||
const editor = await createEditor(parentElement, {
|
||||
settings,
|
||||
initialText,
|
||||
initialNoteId,
|
||||
|
||||
onPasteFile: async (data) => {
|
||||
const base64 = await readFileToBase64(data);
|
||||
await messenger.remoteApi.onPasteFile(data.type, base64);
|
||||
},
|
||||
onLogMessage: (message: string) => {
|
||||
void messenger.remoteApi.logMessage(message);
|
||||
},
|
||||
onEvent: (event) => {
|
||||
void messenger.remoteApi.onEditorEvent(event);
|
||||
},
|
||||
}, {
|
||||
renderMarkupToHtml: async (markup) => {
|
||||
return await messenger.remoteApi.onRender({
|
||||
markup,
|
||||
language: settings.language === EditorLanguageType.Html ? MarkupLanguage.Html : MarkupLanguage.Markdown,
|
||||
}, {
|
||||
pluginAssetContainerSelector: `#${assetContainer.id}`,
|
||||
splitted: true,
|
||||
mapsToLine: true,
|
||||
});
|
||||
},
|
||||
renderHtmlToMarkup: (node) => {
|
||||
// By default, if `src` is specified on an image, the browser will try to load the image, even if it isn't added
|
||||
// to the DOM. (A similar problem is described here: https://stackoverflow.com/q/62019538).
|
||||
// Since :/resourceId isn't a valid image URI, this results in a large number of warnings. As a workaround,
|
||||
// move the element to a temporary document before processing:
|
||||
const dom = document.implementation.createHTMLDocument();
|
||||
node = dom.importNode(node, true);
|
||||
|
||||
let html: HTMLElement;
|
||||
if ((node instanceof HTMLElement)) {
|
||||
html = node;
|
||||
} else {
|
||||
const container = document.createElement('div');
|
||||
container.appendChild(html);
|
||||
html = container;
|
||||
}
|
||||
|
||||
if (settings.language === EditorLanguageType.Markdown) {
|
||||
return htmlToMarkdown(wrapHtmlForMarkdownConversion(html));
|
||||
} else {
|
||||
return postprocessHtml(html).outerHTML;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
messenger.setLocalInterface({
|
||||
editor,
|
||||
});
|
||||
|
||||
return editor;
|
||||
};
|
||||
|
||||
export { default as setUpLogger } from '../utils/setUpLogger';
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { EditorEvent } from '@joplin/editor/events';
|
||||
import { EditorControl, EditorSettings } from '@joplin/editor/types';
|
||||
import { MarkupRecord, RendererControl } from '../rendererBundle/types';
|
||||
import { RenderResult } from '@joplin/renderer/types';
|
||||
|
||||
export interface EditorProps {
|
||||
initialText: string;
|
||||
initialNoteId: string;
|
||||
parentElementClassName: string;
|
||||
settings: EditorSettings;
|
||||
}
|
||||
|
||||
export interface EditorProcessApi {
|
||||
editor: EditorControl;
|
||||
}
|
||||
|
||||
type RenderOptionsSlice = {
|
||||
pluginAssetContainerSelector: string;
|
||||
splitted: boolean;
|
||||
mapsToLine: true;
|
||||
};
|
||||
|
||||
export interface MainProcessApi {
|
||||
onEditorEvent(event: EditorEvent): Promise<void>;
|
||||
logMessage(message: string): Promise<void>;
|
||||
onRender(markup: MarkupRecord, options: RenderOptionsSlice): Promise<RenderResult>;
|
||||
onPasteFile(type: string, base64: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RichTextEditorControl {
|
||||
editor: EditorControl;
|
||||
renderer: RendererControl;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { RefObject, useEffect, useMemo, useRef } from 'react';
|
||||
import { WebViewControl } from '../../components/ExtendedWebView/types';
|
||||
import { SetUpResult } from '../types';
|
||||
import { EditorControl, EditorSettings } from '@joplin/editor/types';
|
||||
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
|
||||
import { EditorProcessApi, EditorProps, MainProcessApi } from './types';
|
||||
import useRendererSetup from '../rendererBundle/useWebViewSetup';
|
||||
import { EditorEvent } from '@joplin/editor/events';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import { RendererControl, RenderOptions } from '../rendererBundle/types';
|
||||
import { ResourceInfos } from '@joplin/renderer/types';
|
||||
|
||||
const logger = Logger.create('useWebViewSetup');
|
||||
|
||||
interface Props {
|
||||
initialText: string;
|
||||
noteId: string;
|
||||
settings: EditorSettings;
|
||||
parentElementClassName: string;
|
||||
themeId: number;
|
||||
pluginStates: PluginStates;
|
||||
noteResources: ResourceInfos;
|
||||
onAttachFile: (mime: string, base64: string)=> void;
|
||||
|
||||
onPostMessage: (message: string)=> void;
|
||||
onEditorEvent: (event: EditorEvent)=> void;
|
||||
webviewRef: RefObject<WebViewControl>;
|
||||
}
|
||||
|
||||
type UseMessengerProps = Props & { renderer: SetUpResult<RendererControl> };
|
||||
|
||||
const useMessenger = (props: UseMessengerProps) => {
|
||||
const onEditorEventRef = useRef(props.onEditorEvent);
|
||||
onEditorEventRef.current = props.onEditorEvent;
|
||||
const rendererRef = useRef(props.renderer);
|
||||
rendererRef.current = props.renderer;
|
||||
const onAttachRef = useRef(props.onAttachFile);
|
||||
onAttachRef.current = props.onAttachFile;
|
||||
|
||||
const markupRenderingSettings = useRef<RenderOptions>(null);
|
||||
markupRenderingSettings.current = {
|
||||
themeId: props.themeId,
|
||||
highlightedKeywords: [],
|
||||
resources: props.noteResources,
|
||||
themeOverrides: {},
|
||||
noteHash: '',
|
||||
initialScroll: 0,
|
||||
pluginAssetContainerSelector: null,
|
||||
};
|
||||
|
||||
return useMemo(() => {
|
||||
const api: MainProcessApi = {
|
||||
onEditorEvent: (event: EditorEvent) => {
|
||||
onEditorEventRef.current(event);
|
||||
return Promise.resolve();
|
||||
},
|
||||
logMessage: (message: string) => {
|
||||
logger.info(message);
|
||||
return Promise.resolve();
|
||||
},
|
||||
onRender: async (markup, options) => {
|
||||
const renderResult = await rendererRef.current.api.render(
|
||||
markup,
|
||||
{
|
||||
...markupRenderingSettings.current,
|
||||
splitted: options.splitted,
|
||||
pluginAssetContainerSelector: options.pluginAssetContainerSelector,
|
||||
mapsToLine: options.mapsToLine,
|
||||
},
|
||||
);
|
||||
return renderResult;
|
||||
},
|
||||
onPasteFile: async (type: string, base64: string) => {
|
||||
onAttachRef.current(type, base64);
|
||||
},
|
||||
};
|
||||
|
||||
const messenger = new RNToWebViewMessenger<MainProcessApi, EditorProcessApi>(
|
||||
'rich-text-editor',
|
||||
props.webviewRef,
|
||||
api,
|
||||
);
|
||||
return messenger;
|
||||
}, [props.webviewRef]);
|
||||
};
|
||||
|
||||
type UseSourceProps = Props & { renderer: SetUpResult<RendererControl> };
|
||||
|
||||
const useSource = (props: UseSourceProps) => {
|
||||
const propsRef = useRef(props);
|
||||
propsRef.current = props;
|
||||
|
||||
const rendererJs = props.renderer.pageSetup.js;
|
||||
const rendererCss = props.renderer.pageSetup.css;
|
||||
|
||||
return useMemo(() => {
|
||||
const editorOptions: EditorProps = {
|
||||
parentElementClassName: propsRef.current.parentElementClassName,
|
||||
initialText: propsRef.current.initialText,
|
||||
initialNoteId: propsRef.current.noteId,
|
||||
settings: propsRef.current.settings,
|
||||
};
|
||||
|
||||
return {
|
||||
css: `
|
||||
${shim.injectedCss('richTextEditorBundle')}
|
||||
${rendererCss}
|
||||
|
||||
/* Increase the size of the editor to make it easier to focus the editor. */
|
||||
.prosemirror-editor {
|
||||
min-height: 75vh;
|
||||
}
|
||||
`,
|
||||
js: `
|
||||
${rendererJs}
|
||||
|
||||
if (!window.richTextEditorCreated) {
|
||||
window.richTextEditorCreated = true;
|
||||
${shim.injectedJs('richTextEditorBundle')}
|
||||
richTextEditorBundle.setUpLogger();
|
||||
richTextEditorBundle.initialize(${JSON.stringify(editorOptions)}).then(function(editor) {
|
||||
/* For testing */
|
||||
window.joplinRichTextEditor_ = editor;
|
||||
});
|
||||
}
|
||||
`,
|
||||
};
|
||||
}, [rendererJs, rendererCss]);
|
||||
};
|
||||
|
||||
const useWebViewSetup = (props: Props): SetUpResult<EditorControl> => {
|
||||
const renderer = useRendererSetup({
|
||||
webviewRef: props.webviewRef,
|
||||
onBodyScroll: null,
|
||||
onPostMessage: props.onPostMessage,
|
||||
pluginStates: props.pluginStates,
|
||||
themeId: props.themeId,
|
||||
});
|
||||
const messenger = useMessenger({ ...props, renderer });
|
||||
const pageSetup = useSource({ ...props, renderer });
|
||||
|
||||
useEffect(() => {
|
||||
void messenger.remoteApi.editor.updateSettings(props.settings);
|
||||
}, [props.settings, messenger]);
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
api: messenger.remoteApi.editor,
|
||||
pageSetup: pageSetup,
|
||||
webViewEventHandlers: {
|
||||
onLoadEnd: () => {
|
||||
messenger.onWebViewLoaded();
|
||||
renderer.webViewEventHandlers.onLoadEnd();
|
||||
},
|
||||
onMessage: (event) => {
|
||||
messenger.onWebViewMessage(event);
|
||||
renderer.webViewEventHandlers.onMessage(event);
|
||||
},
|
||||
},
|
||||
};
|
||||
}, [messenger, pageSetup, renderer.webViewEventHandlers]);
|
||||
};
|
||||
|
||||
export default useWebViewSetup;
|
||||
17
packages/app-mobile/contentScripts/types.ts
Normal file
17
packages/app-mobile/contentScripts/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { OnMessageEvent } from '../components/ExtendedWebView/types';
|
||||
|
||||
interface WebViewEventHandlers {
|
||||
onLoadEnd: ()=> void;
|
||||
onMessage: (event: OnMessageEvent)=> void;
|
||||
}
|
||||
|
||||
export interface PageSetupSources {
|
||||
css: string;
|
||||
js: string;
|
||||
}
|
||||
|
||||
export interface SetUpResult<Api> {
|
||||
api: Api;
|
||||
pageSetup: PageSetupSources;
|
||||
webViewEventHandlers: WebViewEventHandlers;
|
||||
}
|
||||
27
packages/app-mobile/contentScripts/utils/polyfills.ts
Normal file
27
packages/app-mobile/contentScripts/utils/polyfills.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// .replaceChildren is not supported in Chromium 83, which is the default for Android 11
|
||||
// (unless auto-updated from the Google Play store).
|
||||
HTMLElement.prototype.replaceChildren ??= function(this: HTMLElement, ...nodes: Node[]) {
|
||||
while (this.children.length) {
|
||||
this.children[0].remove();
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
this.appendChild(node);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Array.prototype.flat ??= function<A, D extends number = 1>(this: A, depthParam?: D): FlatArray<A, D>[] {
|
||||
if (!Array.isArray(this)) throw new Error('Not an array');
|
||||
const depth = depthParam ?? 1;
|
||||
|
||||
const result = [] as FlatArray<A, D>[];
|
||||
for (let i = 0; i < this.length; i++) {
|
||||
if (Array.isArray(this[i]) && depth > 0) {
|
||||
result.push(...this[i].flat(depth - 1));
|
||||
} else {
|
||||
result.push(this[i]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
15
packages/app-mobile/contentScripts/utils/readFileToBase64.ts
Normal file
15
packages/app-mobile/contentScripts/utils/readFileToBase64.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
const readFileToBase64 = (file: Blob) => {
|
||||
const reader = new FileReader();
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
reader.onload = async () => {
|
||||
const dataUrl = reader.result as string;
|
||||
const base64 = dataUrl.replace(/^data:.*;base64,/, '');
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to load file.'));
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
export default readFileToBase64;
|
||||
14
packages/app-mobile/contentScripts/utils/setUpLogger.ts
Normal file
14
packages/app-mobile/contentScripts/utils/setUpLogger.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import Logger, { TargetType } from '@joplin/utils/Logger';
|
||||
|
||||
let loggerCreated = false;
|
||||
const setUpLogger = () => {
|
||||
if (!loggerCreated) {
|
||||
const logger = new Logger();
|
||||
logger.addTarget(TargetType.Console);
|
||||
logger.setLevel(Logger.LEVEL_WARN);
|
||||
Logger.initializeGlobalLogger(logger);
|
||||
loggerCreated = true;
|
||||
}
|
||||
};
|
||||
|
||||
export default setUpLogger;
|
||||
Reference in New Issue
Block a user