You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-11-29 22:48:10 +02:00
Mobile: Add a Rich Text Editor (#12748)
This commit is contained in:
@@ -1,91 +0,0 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
// This contains the CodeMirror instance, which needs to be built into a bundle
|
||||
// using `yarn buildInjectedJs`. This bundle is then loaded from
|
||||
// NoteEditor.tsx into the webview.
|
||||
//
|
||||
// In general, since this file is harder to debug due to the intermediate built
|
||||
// step, it's better to keep it as light as possible - it should just be a light
|
||||
// wrapper to access CodeMirror functionalities. Anything else should be done
|
||||
// from NoteEditor.tsx.
|
||||
|
||||
import { EditorSettings } from '@joplin/editor/types';
|
||||
import createEditor from '@joplin/editor/CodeMirror/createEditor';
|
||||
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
|
||||
import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger';
|
||||
import { WebViewToEditorApi } from '../types';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import Logger, { TargetType } from '@joplin/utils/Logger';
|
||||
|
||||
let loggerCreated = false;
|
||||
export const setUpLogger = () => {
|
||||
if (!loggerCreated) {
|
||||
const logger = new Logger();
|
||||
logger.addTarget(TargetType.Console);
|
||||
logger.setLevel(Logger.LEVEL_WARN);
|
||||
Logger.initializeGlobalLogger(logger);
|
||||
loggerCreated = true;
|
||||
}
|
||||
};
|
||||
|
||||
export const initCodeMirror = (
|
||||
parentElement: HTMLElement,
|
||||
initialText: string,
|
||||
initialNoteId: string,
|
||||
settings: EditorSettings,
|
||||
): CodeMirrorControl => {
|
||||
const messenger = new WebViewToRNMessenger<CodeMirrorControl, WebViewToEditorApi>('editor', null);
|
||||
|
||||
const control = createEditor(parentElement, {
|
||||
initialText,
|
||||
initialNoteId,
|
||||
settings,
|
||||
|
||||
onPasteFile: async (data) => {
|
||||
const reader = new FileReader();
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
reader.onload = async () => {
|
||||
const dataUrl = reader.result as string;
|
||||
const base64 = dataUrl.replace(/^data:.*;base64,/, '');
|
||||
await messenger.remoteApi.onPasteFile(data.type, base64);
|
||||
resolve();
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to load file.'));
|
||||
|
||||
reader.readAsDataURL(data);
|
||||
});
|
||||
},
|
||||
|
||||
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(control);
|
||||
return control;
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
<!--
|
||||
Open this file in a web browser to more easily debug the CodeMirror editor.
|
||||
Messages will show up in the console when posted.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||
<meta charset="utf-8"/>
|
||||
<title>CodeMirror test</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="CodeMirror"></div>
|
||||
<script>
|
||||
// Override the default postMessage — codeMirrorBundle expects
|
||||
// this to be present.
|
||||
window.ReactNativeWebView = {
|
||||
postMessage: message => {
|
||||
console.log('postMessage:', message);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<script src="./CodeMirror.bundle.js"></script>
|
||||
<script>
|
||||
const parent = document.querySelector('.CodeMirror');
|
||||
const initialText = 'Testing...';
|
||||
|
||||
const settings = {
|
||||
themeData: {
|
||||
fontSize: 12, // px
|
||||
fontFamily: 'serif',
|
||||
backgroundColor: 'black',
|
||||
color: 'white',
|
||||
backgroundColor2: '#330',
|
||||
color2: '#ff0',
|
||||
backgroundColor3: '#404',
|
||||
color3: '#f0f',
|
||||
backgroundColor4: '#555',
|
||||
color4: '#0ff',
|
||||
appearance: 'dark',
|
||||
},
|
||||
themeId: 0,
|
||||
spellcheckEnabled: true,
|
||||
language: 'markdown',
|
||||
katexEnabled: true,
|
||||
useExternalSearch: false,
|
||||
readOnly: false,
|
||||
|
||||
keymap: 'default',
|
||||
|
||||
automatchBraces: false,
|
||||
ignoreModifiers: false,
|
||||
|
||||
indentWithTabs: false,
|
||||
};
|
||||
|
||||
window.cm = codeMirrorBundle.initCodeMirror(parent, initialText, settings);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,20 +1,13 @@
|
||||
import * as React from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
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, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import ExtendedWebView from '../../ExtendedWebView';
|
||||
import { OnMessageEvent, WebViewControl } from '../../ExtendedWebView/types';
|
||||
import { clearAutosave } from './autosave';
|
||||
import { LocalizedStrings } from './js-draw/types';
|
||||
import { clearAutosave, writeAutosave } from './autosave';
|
||||
import { DialogContext } from '../../DialogManager';
|
||||
import useEditorMessenger from './utils/useEditorMessenger';
|
||||
import BackButtonService from '../../../services/BackButtonService';
|
||||
|
||||
import useWebViewSetup, { ImageEditorControl } from '../../../contentScripts/imageEditorBundle/useWebViewSetup';
|
||||
|
||||
const logger = Logger.create('ImageEditor');
|
||||
|
||||
@@ -28,69 +21,15 @@ interface Props {
|
||||
onExit: OnCancelCallback;
|
||||
}
|
||||
|
||||
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 ImageEditor = (props: Props) => {
|
||||
const editorTheme: Theme = themeStyle(props.themeId);
|
||||
const webviewRef = useRef<WebViewControl|null>(null);
|
||||
const webViewRef = useRef<WebViewControl|null>(null);
|
||||
const [imageChanged, setImageChanged] = useState(false);
|
||||
|
||||
const editorControlRef = useRef<ImageEditorControl|null>(null);
|
||||
const dialogs = useContext(DialogContext);
|
||||
|
||||
const onRequestCloseEditor = useCallback((promptIfUnsaved: boolean) => {
|
||||
const onRequestCloseEditor = useCallback((promptIfUnsaved = true) => {
|
||||
const discardChangesAndClose = async () => {
|
||||
await clearAutosave();
|
||||
props.onExit();
|
||||
@@ -98,7 +37,7 @@ const ImageEditor = (props: Props) => {
|
||||
|
||||
if (!imageChanged || !promptIfUnsaved) {
|
||||
void discardChangesAndClose();
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
dialogs.prompt(
|
||||
@@ -113,13 +52,12 @@ const ImageEditor = (props: Props) => {
|
||||
onPress: () => {
|
||||
// saveDrawing calls props.onSave(...) which may close the
|
||||
// editor.
|
||||
webviewRef.current.injectJS('window.editorControl.saveThenExit()');
|
||||
void editorControlRef.current.saveThenExit();
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
return true;
|
||||
}, [webviewRef, dialogs, props.onExit, imageChanged]);
|
||||
}, [dialogs, props.onExit, imageChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
const hardwareBackPressListener = () => {
|
||||
@@ -133,9 +71,18 @@ const ImageEditor = (props: Props) => {
|
||||
};
|
||||
}, [onRequestCloseEditor]);
|
||||
|
||||
const css = useCss(editorTheme);
|
||||
const [html, setHtml] = useState('');
|
||||
const { pageSetup, api: editorControl, webViewEventHandlers } = useWebViewSetup({
|
||||
webViewRef,
|
||||
themeId: props.themeId,
|
||||
onSetImageChanged: setImageChanged,
|
||||
onAutoSave: writeAutosave,
|
||||
onSave: props.onSave,
|
||||
onRequestCloseEditor,
|
||||
resourceFilename: props.resourceFilename,
|
||||
});
|
||||
editorControlRef.current = editorControl;
|
||||
|
||||
const [html, setHtml] = useState('');
|
||||
useEffect(() => {
|
||||
setHtml(`
|
||||
<!DOCTYPE html>
|
||||
@@ -144,8 +91,8 @@ const ImageEditor = (props: Props) => {
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"/>
|
||||
|
||||
<style id='main-style'>
|
||||
${css}
|
||||
<style>
|
||||
${pageSetup.css}
|
||||
</style>
|
||||
</head>
|
||||
<body></body>
|
||||
@@ -160,112 +107,12 @@ const ImageEditor = (props: Props) => {
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 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(() => `
|
||||
window.onerror = (message, source, lineno) => {
|
||||
window.ReactNativeWebView.postMessage(
|
||||
"error: " + message + " in file://" + source + ", line " + lineno,
|
||||
);
|
||||
};
|
||||
|
||||
window.onunhandledrejection = (error) => {
|
||||
window.ReactNativeWebView.postMessage(
|
||||
"error: " + error.reason,
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
if (window.editorControl === undefined) {
|
||||
${shim.injectedJs('svgEditorBundle')}
|
||||
|
||||
window.editorControl = svgEditorBundle.createJsDrawEditor(
|
||||
svgEditorBundle.createMessenger().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,
|
||||
} : {}),
|
||||
})},
|
||||
);
|
||||
}
|
||||
} catch(e) {
|
||||
window.ReactNativeWebView.postMessage(
|
||||
'error: ' + e.message + ': ' + JSON.stringify(e)
|
||||
);
|
||||
}
|
||||
true;
|
||||
`, [localizedStrings, appInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
webviewRef.current?.injectJS(`
|
||||
document.querySelector('#main-style').textContent = ${JSON.stringify(css)};
|
||||
|
||||
if (window.editorControl) {
|
||||
window.editorControl.onThemeUpdate();
|
||||
}
|
||||
`);
|
||||
}, [css]);
|
||||
|
||||
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 (!props.resourceFilename) {
|
||||
return '';
|
||||
}
|
||||
return await shim.fsDriver().readFile(props.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.editorControl) {
|
||||
const initialSVGPath = ${JSON.stringify(props.resourceFilename)};
|
||||
const initialTemplateData = ${JSON.stringify(Setting.value('imageeditor.imageTemplate'))};
|
||||
const initialData = ${JSON.stringify(await getInitialInjectedData())};
|
||||
|
||||
editorControl.loadImageOrTemplate(initialSVGPath, initialTemplateData, initialData);
|
||||
}
|
||||
})();`);
|
||||
}, [webviewRef, props.resourceFilename]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const onError = useCallback((event: any) => {
|
||||
logger.error('ImageEditor: WebView error: ', event);
|
||||
}, []);
|
||||
|
||||
const messenger = useEditorMessenger({
|
||||
webviewRef,
|
||||
setImageChanged,
|
||||
onReadyToLoadData,
|
||||
onSave: props.onSave,
|
||||
onRequestCloseEditor,
|
||||
});
|
||||
|
||||
const onWebViewMessage = webViewEventHandlers.onMessage;
|
||||
const onMessage = useCallback((event: OnMessageEvent) => {
|
||||
const data = event.nativeEvent.data;
|
||||
if (typeof data === 'string' && data.startsWith('error:')) {
|
||||
@@ -273,18 +120,18 @@ const ImageEditor = (props: Props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
messenger.onWebViewMessage(event);
|
||||
}, [messenger]);
|
||||
onWebViewMessage(event);
|
||||
}, [onWebViewMessage]);
|
||||
|
||||
return (
|
||||
<ExtendedWebView
|
||||
html={html}
|
||||
injectedJavaScript={injectedJavaScript}
|
||||
injectedJavaScript={pageSetup.js}
|
||||
allowFileAccessFromJs={true}
|
||||
onMessage={onMessage}
|
||||
onLoadEnd={messenger.onWebViewLoaded}
|
||||
onLoadEnd={webViewEventHandlers.onLoadEnd}
|
||||
onError={onError}
|
||||
ref={webviewRef}
|
||||
ref={webViewRef}
|
||||
webviewInstanceId={'image-editor-js-draw'}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
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;
|
||||
@@ -1,115 +0,0 @@
|
||||
/** @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 './createJsDrawEditor';
|
||||
import { BackgroundComponent } from 'js-draw';
|
||||
import { BackgroundComponentBackgroundType } from 'js-draw';
|
||||
import { ImageEditorCallbacks } from './types';
|
||||
import applyTemplateToEditor from './applyTemplateToEditor';
|
||||
|
||||
|
||||
const createEditorWithCallbacks = (callbacks: Partial<ImageEditorCallbacks>) => {
|
||||
const toolbarState = '';
|
||||
const locale = 'en';
|
||||
|
||||
const allCallbacks: ImageEditorCallbacks = {
|
||||
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('createJsDrawEditor', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,222 +0,0 @@
|
||||
|
||||
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 { ImageEditorCallbacks, ImageEditorControl, LocalizedStrings } from './types';
|
||||
import startAutosaveLoop from './startAutosaveLoop';
|
||||
import WebViewToRNMessenger from '../../../../utils/ipc/WebViewToRNMessenger';
|
||||
import './polyfills';
|
||||
|
||||
|
||||
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 createMessenger = () => {
|
||||
const messenger = new WebViewToRNMessenger<ImageEditorControl, ImageEditorCallbacks>(
|
||||
'image-editor', {},
|
||||
);
|
||||
return messenger;
|
||||
};
|
||||
|
||||
export const createJsDrawEditor = (
|
||||
callbacks: ImageEditorCallbacks,
|
||||
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 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: () => {
|
||||
// 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();
|
||||
|
||||
callbacks.onLoadedEditor();
|
||||
|
||||
return editorControl;
|
||||
};
|
||||
|
||||
|
||||
export default createJsDrawEditor;
|
||||
@@ -1,11 +0,0 @@
|
||||
// .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);
|
||||
}
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
|
||||
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;
|
||||
@@ -1,30 +0,0 @@
|
||||
|
||||
export type SaveDrawingCallback = (svgData: string, isAutosave: boolean)=> void;
|
||||
export type UpdateEditorTemplateCallback = (newTemplate: string)=> void;
|
||||
export type UpdateToolbarCallback = (toolbarData: string)=> void;
|
||||
|
||||
export interface ImageEditorCallbacks {
|
||||
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 ImageEditorControl {}
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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;
|
||||
@@ -1,63 +0,0 @@
|
||||
import { RefObject, useMemo } from 'react';
|
||||
import { WebViewControl } from '../../../ExtendedWebView/types';
|
||||
import { ImageEditorCallbacks, ImageEditorControl } from '../js-draw/types';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import RNToWebViewMessenger from '../../../../utils/ipc/RNToWebViewMessenger';
|
||||
import { writeAutosave } from '../autosave';
|
||||
import Clipboard from '@react-native-clipboard/clipboard';
|
||||
|
||||
interface Props {
|
||||
webviewRef: RefObject<WebViewControl>;
|
||||
setImageChanged(changed: boolean): void;
|
||||
|
||||
onReadyToLoadData(): void;
|
||||
onSave(data: string): void;
|
||||
onRequestCloseEditor(promptIfUnsaved: boolean): void;
|
||||
}
|
||||
|
||||
const useEditorMessenger = ({
|
||||
webviewRef, setImageChanged, onReadyToLoadData, onRequestCloseEditor, onSave,
|
||||
}: Props) => {
|
||||
return useMemo(() => {
|
||||
const localApi: ImageEditorCallbacks = {
|
||||
updateEditorTemplate: newTemplate => {
|
||||
Setting.setValue('imageeditor.imageTemplate', newTemplate);
|
||||
},
|
||||
updateToolbarState: newData => {
|
||||
Setting.setValue('imageeditor.jsdrawToolbar', newData);
|
||||
},
|
||||
setImageHasChanges: hasChanges => {
|
||||
setImageChanged(hasChanges);
|
||||
},
|
||||
onLoadedEditor: () => {
|
||||
onReadyToLoadData();
|
||||
},
|
||||
saveThenClose: svgData => {
|
||||
onSave(svgData);
|
||||
onRequestCloseEditor(false);
|
||||
},
|
||||
save: (svgData, isAutosave) => {
|
||||
if (isAutosave) {
|
||||
return writeAutosave(svgData);
|
||||
} else {
|
||||
return onSave(svgData);
|
||||
}
|
||||
},
|
||||
closeEditor: promptIfUnsaved => {
|
||||
onRequestCloseEditor(promptIfUnsaved);
|
||||
},
|
||||
writeClipboardText: async text => {
|
||||
Clipboard.setString(text);
|
||||
},
|
||||
readClipboardText: async () => {
|
||||
return Clipboard.getString();
|
||||
},
|
||||
};
|
||||
const messenger = new RNToWebViewMessenger<ImageEditorCallbacks, ImageEditorControl>(
|
||||
'image-editor', webviewRef, localApi,
|
||||
);
|
||||
return messenger;
|
||||
}, [webviewRef, setImageChanged, onReadyToLoadData, onRequestCloseEditor, onSave]);
|
||||
};
|
||||
|
||||
export default useEditorMessenger;
|
||||
193
packages/app-mobile/components/NoteEditor/MarkdownEditor.tsx
Normal file
193
packages/app-mobile/components/NoteEditor/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import themeToCss from '@joplin/lib/services/style/themeToCss';
|
||||
import ExtendedWebView from '../ExtendedWebView';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { NativeSyntheticEvent } from 'react-native';
|
||||
|
||||
import { EditorProps } from './types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import useCodeMirrorPlugins from './hooks/useCodeMirrorPlugins';
|
||||
import { WebViewErrorEvent } from 'react-native-webview/lib/RNCWebViewNativeComponent';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { OnMessageEvent } from '../ExtendedWebView/types';
|
||||
import useWebViewSetup from '../../contentScripts/markdownEditorBundle/useWebViewSetup';
|
||||
|
||||
const logger = Logger.create('MarkdownEditor');
|
||||
|
||||
function useCss(themeId: number): string {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
const themeVariableCss = themeToCss(theme);
|
||||
return `
|
||||
${themeVariableCss}
|
||||
|
||||
:root {
|
||||
background-color: ${theme.backgroundColor};
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
/* Prefer 100% -- 100vw shows an unnecessary horizontal scrollbar in Google Chrome (desktop). */
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
padding-left: 1px;
|
||||
padding-right: 1px;
|
||||
padding-bottom: 1px;
|
||||
padding-top: 10px;
|
||||
|
||||
font-size: 13pt;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(100, 100, 100, 0.7) rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@supports selector(::-webkit-scrollbar) {
|
||||
*::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: none;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
border: none;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: rgba(100, 100, 100, 0.3);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(100, 100, 100, 0.7);
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: unset;
|
||||
scrollbar-color: unset;
|
||||
}
|
||||
}
|
||||
`;
|
||||
}, [themeId]);
|
||||
}
|
||||
|
||||
function useHtml(): string {
|
||||
return useMemo(() => `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<title>${_('Note editor')}</title>
|
||||
<style>
|
||||
/* For better scrolling on iOS (working scrollbar) we use external, rather than internal,
|
||||
scrolling. */
|
||||
.cm-scroller {
|
||||
overflow: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="CodeMirror" style="height:100%;" autocapitalize="on"></div>
|
||||
</body>
|
||||
</html>
|
||||
`, []);
|
||||
}
|
||||
|
||||
const MarkdownEditor: React.FC<EditorProps> = props => {
|
||||
const webviewRef = props.webviewRef;
|
||||
|
||||
const editorWebViewSetup = useWebViewSetup({
|
||||
initialSelection: props.initialSelection,
|
||||
noteHash: props.noteHash,
|
||||
globalSearch: props.globalSearch,
|
||||
onEditorEvent: props.onEditorEvent,
|
||||
onAttachFile: props.onAttach,
|
||||
editorOptions: {
|
||||
parentElementClassName: 'CodeMirror',
|
||||
initialText: props.initialText,
|
||||
initialNoteId: props.noteId,
|
||||
settings: props.editorSettings,
|
||||
},
|
||||
webviewRef,
|
||||
});
|
||||
|
||||
props.editorRef.current = editorWebViewSetup.api.editor;
|
||||
|
||||
const injectedJavaScript = `
|
||||
window.onerror = (message, source, lineno) => {
|
||||
console.error(message);
|
||||
window.ReactNativeWebView.postMessage(
|
||||
"error: " + message + " in file://" + source + ", line " + lineno
|
||||
);
|
||||
};
|
||||
window.onunhandledrejection = (event) => {
|
||||
window.ReactNativeWebView.postMessage(
|
||||
"error: Unhandled promise rejection: " + event
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
${editorWebViewSetup.pageSetup.js}
|
||||
} catch (e) {
|
||||
console.error('Setup error: ', e);
|
||||
window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
|
||||
}
|
||||
|
||||
true;
|
||||
`;
|
||||
|
||||
const css = useCss(props.themeId);
|
||||
const html = useHtml();
|
||||
|
||||
const codeMirrorPlugins = useCodeMirrorPlugins(props.plugins);
|
||||
useEffect(() => {
|
||||
void editorWebViewSetup.api.editor.setContentScripts(codeMirrorPlugins);
|
||||
}, [codeMirrorPlugins, editorWebViewSetup]);
|
||||
|
||||
const onMessage = useCallback((event: OnMessageEvent) => {
|
||||
const data = event.nativeEvent.data;
|
||||
|
||||
if (typeof data === 'string' && data.indexOf('error:') === 0) {
|
||||
logger.error('CodeMirror error', data);
|
||||
return;
|
||||
}
|
||||
|
||||
editorWebViewSetup.webViewEventHandlers.onMessage(event);
|
||||
}, [editorWebViewSetup]);
|
||||
|
||||
const onError = useCallback((event: NativeSyntheticEvent<WebViewErrorEvent>) => {
|
||||
logger.error(`Load error: Code ${event.nativeEvent.code}: ${event.nativeEvent.description}`);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ExtendedWebView
|
||||
ref={webviewRef}
|
||||
webviewInstanceId='MarkdownEditor'
|
||||
testID='MarkdownEditor'
|
||||
scrollEnabled={true}
|
||||
html={html}
|
||||
injectedJavaScript={injectedJavaScript}
|
||||
css={css}
|
||||
hasPluginScripts={codeMirrorPlugins.length > 0}
|
||||
onMessage={onMessage}
|
||||
onLoadEnd={editorWebViewSetup.webViewEventHandlers.onLoadEnd}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownEditor;
|
||||
@@ -15,10 +15,31 @@ import mockCommandRuntimes from '../EditorToolbar/testing/mockCommandRuntimes';
|
||||
import setupGlobalStore from '../../utils/testing/setupGlobalStore';
|
||||
import { Store } from 'redux';
|
||||
import { AppState } from '../../utils/types';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import { EditorType } from './types';
|
||||
|
||||
let store: Store<AppState>;
|
||||
let registeredRuntime: RegisteredRuntime;
|
||||
|
||||
const defaultEditorProps = {
|
||||
themeId: Setting.THEME_ARITIM_DARK,
|
||||
markupLanguage: MarkupLanguage.Markdown,
|
||||
initialText: 'Testing...',
|
||||
globalSearch: '',
|
||||
noteId: '',
|
||||
noteHash: '',
|
||||
style: {},
|
||||
toolbarEnabled: true,
|
||||
readOnly: false,
|
||||
onChange: ()=>{},
|
||||
onSelectionChange: ()=>{},
|
||||
onUndoRedoDepthChange: ()=>{},
|
||||
onAttach: async ()=>{},
|
||||
noteResources: {},
|
||||
plugins: {},
|
||||
mode: EditorType.Markdown,
|
||||
};
|
||||
|
||||
describe('NoteEditor', () => {
|
||||
beforeAll(() => {
|
||||
// This allows the NoteEditor test to register editor commands without errors.
|
||||
@@ -45,19 +66,8 @@ describe('NoteEditor', () => {
|
||||
const wrappedNoteEditor = render(
|
||||
<TestProviderStack store={store}>
|
||||
<NoteEditor
|
||||
themeId={Setting.THEME_ARITIM_DARK}
|
||||
initialText='Testing...'
|
||||
globalSearch=''
|
||||
noteId=''
|
||||
noteHash=''
|
||||
style={{}}
|
||||
toolbarEnabled={true}
|
||||
readOnly={false}
|
||||
onChange={()=>{}}
|
||||
onSelectionChange={()=>{}}
|
||||
onUndoRedoDepthChange={()=>{}}
|
||||
onAttach={async ()=>{}}
|
||||
plugins={{}}
|
||||
ref={undefined}
|
||||
{...defaultEditorProps}
|
||||
/>
|
||||
</TestProviderStack>,
|
||||
);
|
||||
@@ -99,4 +109,27 @@ describe('NoteEditor', () => {
|
||||
|
||||
wrappedNoteEditor.unmount();
|
||||
});
|
||||
|
||||
it('should show a warning banner the first time the Rich Text Editor is used', () => {
|
||||
const wrappedNoteEditor = render(
|
||||
<TestProviderStack store={store}>
|
||||
<NoteEditor
|
||||
ref={undefined}
|
||||
{...defaultEditorProps}
|
||||
mode={EditorType.RichText}
|
||||
/>
|
||||
</TestProviderStack>,
|
||||
);
|
||||
|
||||
const warningBannerQuery = /This Rich Text editor has a number of limitations.*/;
|
||||
const warning = screen.getByText(warningBannerQuery);
|
||||
expect(warning).toBeVisible();
|
||||
|
||||
// Pressing dismiss should dismiss the warning
|
||||
const dismissButton = screen.getByHintText('Hides warning');
|
||||
fireEvent.press(dismissButton);
|
||||
expect(screen.queryByText(warningBannerQuery)).toBeNull();
|
||||
|
||||
wrappedNoteEditor.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,46 +1,49 @@
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import themeToCss from '@joplin/lib/services/style/themeToCss';
|
||||
import EditLinkDialog from './EditLinkDialog';
|
||||
import { defaultSearchState, SearchPanel } from './SearchPanel';
|
||||
import ExtendedWebView from '../ExtendedWebView';
|
||||
import { WebViewControl } from '../ExtendedWebView/types';
|
||||
|
||||
import * as React from 'react';
|
||||
import { forwardRef, RefObject, useEffect, useImperativeHandle } from 'react';
|
||||
import { Ref, RefObject, useEffect, useImperativeHandle } from 'react';
|
||||
import { useMemo, useState, useCallback, useRef } from 'react';
|
||||
import { LayoutChangeEvent, NativeSyntheticEvent, View, ViewStyle } from 'react-native';
|
||||
import { LayoutChangeEvent, View, ViewStyle } from 'react-native';
|
||||
import { editorFont } from '../global-style';
|
||||
|
||||
import { EditorControl as EditorBodyControl, ContentScriptData } from '@joplin/editor/types';
|
||||
import { EditorControl, EditorSettings, SelectionRange, WebViewToEditorApi } from './types';
|
||||
import { EditorControl, EditorSettings, EditorType } from './types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { ChangeEvent, EditorEvent, EditorEventType, SelectionRangeChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events';
|
||||
import { EditorCommandType, EditorKeymap, EditorLanguageType, SearchState } from '@joplin/editor/types';
|
||||
import SelectionFormatting, { defaultSelectionFormatting } from '@joplin/editor/SelectionFormatting';
|
||||
import useCodeMirrorPlugins from './hooks/useCodeMirrorPlugins';
|
||||
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
|
||||
import { WebViewErrorEvent } from 'react-native-webview/lib/RNCWebViewNativeComponent';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import useEditorCommandHandler from './hooks/useEditorCommandHandler';
|
||||
import { OnMessageEvent } from '../ExtendedWebView/types';
|
||||
import { join, dirname } from 'path';
|
||||
import * as mimeUtils from '@joplin/lib/mime-utils';
|
||||
import uuid from '@joplin/lib/uuid';
|
||||
import EditorToolbar from '../EditorToolbar/EditorToolbar';
|
||||
import { SelectionRange } from '../../contentScripts/markdownEditorBundle/types';
|
||||
import MarkdownEditor from './MarkdownEditor';
|
||||
import RichTextEditor from './RichTextEditor';
|
||||
import { ResourceInfos } from '@joplin/renderer/types';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import { join } from 'path';
|
||||
import uuid from '@joplin/lib/uuid';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { dirname } from '@joplin/utils/path';
|
||||
import { toFileExtension } from '@joplin/lib/mime-utils';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import WarningBanner from './WarningBanner';
|
||||
|
||||
type ChangeEventHandler = (event: ChangeEvent)=> void;
|
||||
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
|
||||
type SelectionChangeEventHandler = (event: SelectionRangeChangeEvent)=> void;
|
||||
type OnAttachCallback = (filePath?: string)=> Promise<void>;
|
||||
|
||||
const logger = Logger.create('NoteEditor');
|
||||
|
||||
interface Props {
|
||||
ref: Ref<EditorControl>;
|
||||
themeId: number;
|
||||
initialText: string;
|
||||
mode: EditorType;
|
||||
markupLanguage: MarkupLanguage;
|
||||
noteId: string;
|
||||
noteHash: string;
|
||||
globalSearch: string;
|
||||
@@ -49,6 +52,7 @@ interface Props {
|
||||
toolbarEnabled: boolean;
|
||||
readOnly: boolean;
|
||||
plugins: PluginStates;
|
||||
noteResources: ResourceInfos;
|
||||
|
||||
onChange: ChangeEventHandler;
|
||||
onSelectionChange: SelectionChangeEventHandler;
|
||||
@@ -61,103 +65,6 @@ function fontFamilyFromSettings() {
|
||||
return font ? `${font}, sans-serif` : 'sans-serif';
|
||||
}
|
||||
|
||||
function useCss(themeId: number): string {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
const themeVariableCss = themeToCss(theme);
|
||||
return `
|
||||
${themeVariableCss}
|
||||
|
||||
:root {
|
||||
background-color: ${theme.backgroundColor};
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
/* Prefer 100% -- 100vw shows an unnecessary horizontal scrollbar in Google Chrome (desktop). */
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
padding-left: 1px;
|
||||
padding-right: 1px;
|
||||
padding-bottom: 1px;
|
||||
padding-top: 10px;
|
||||
|
||||
font-size: 13pt;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(100, 100, 100, 0.7) rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@supports selector(::-webkit-scrollbar) {
|
||||
*::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: none;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
border: none;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: rgba(100, 100, 100, 0.3);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(100, 100, 100, 0.7);
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: unset;
|
||||
scrollbar-color: unset;
|
||||
}
|
||||
}
|
||||
`;
|
||||
}, [themeId]);
|
||||
}
|
||||
|
||||
const themeStyleSheetClassName = 'note-editor-styles';
|
||||
function useHtml(initialCss: string): string {
|
||||
const cssRef = useRef(initialCss);
|
||||
cssRef.current = initialCss;
|
||||
|
||||
return useMemo(() => `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<title>${_('Note editor')}</title>
|
||||
<style>
|
||||
/* For better scrolling on iOS (working scrollbar) we use external, rather than internal,
|
||||
scrolling. */
|
||||
.cm-scroller {
|
||||
overflow: none;
|
||||
}
|
||||
</style>
|
||||
<style class=${JSON.stringify(themeStyleSheetClassName)}>
|
||||
${cssRef.current}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="CodeMirror" style="height:100%;" autocapitalize="on"></div>
|
||||
</body>
|
||||
</html>
|
||||
`, []);
|
||||
}
|
||||
|
||||
function editorTheme(themeId: number) {
|
||||
const fontSizeInPx = Setting.value('style.editor.fontSize');
|
||||
|
||||
@@ -167,6 +74,7 @@ function editorTheme(themeId: number) {
|
||||
const estimatedFontSizeInEm = fontSizeInPx / 16;
|
||||
|
||||
return {
|
||||
themeId,
|
||||
...themeStyle(themeId),
|
||||
|
||||
// To allow accessibility font scaling, we also need to set the
|
||||
@@ -181,54 +89,54 @@ function editorTheme(themeId: number) {
|
||||
type OnSetVisibleCallback = (visible: boolean)=> void;
|
||||
type OnSearchStateChangeCallback = (state: SearchState)=> void;
|
||||
const useEditorControl = (
|
||||
bodyControl: EditorBodyControl,
|
||||
editorRef: RefObject<EditorBodyControl>,
|
||||
webviewRef: RefObject<WebViewControl>,
|
||||
setLinkDialogVisible: OnSetVisibleCallback,
|
||||
setSearchState: OnSearchStateChangeCallback,
|
||||
): EditorControl => {
|
||||
return useMemo(() => {
|
||||
const execEditorCommand = (command: EditorCommandType) => {
|
||||
void bodyControl.execCommand(command);
|
||||
void editorRef.current.execCommand(command);
|
||||
};
|
||||
|
||||
const setSearchStateCallback = (state: SearchState) => {
|
||||
bodyControl.setSearchState(state);
|
||||
editorRef.current.setSearchState(state);
|
||||
setSearchState(state);
|
||||
};
|
||||
|
||||
const control: EditorControl = {
|
||||
supportsCommand(command: EditorCommandType) {
|
||||
return bodyControl.supportsCommand(command);
|
||||
return editorRef.current.supportsCommand(command);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
execCommand(command, ...args: any[]) {
|
||||
return bodyControl.execCommand(command, ...args);
|
||||
return editorRef.current.execCommand(command, ...args);
|
||||
},
|
||||
|
||||
focus() {
|
||||
void bodyControl.execCommand(EditorCommandType.Focus);
|
||||
void editorRef.current.execCommand(EditorCommandType.Focus);
|
||||
},
|
||||
|
||||
undo() {
|
||||
bodyControl.undo();
|
||||
editorRef.current.undo();
|
||||
},
|
||||
redo() {
|
||||
bodyControl.redo();
|
||||
editorRef.current.redo();
|
||||
},
|
||||
select(anchor: number, head: number) {
|
||||
bodyControl.select(anchor, head);
|
||||
editorRef.current.select(anchor, head);
|
||||
},
|
||||
setScrollPercent(fraction: number) {
|
||||
bodyControl.setScrollPercent(fraction);
|
||||
editorRef.current.setScrollPercent(fraction);
|
||||
},
|
||||
insertText(text: string) {
|
||||
bodyControl.insertText(text);
|
||||
editorRef.current.insertText(text);
|
||||
},
|
||||
updateBody(newBody: string) {
|
||||
bodyControl.updateBody(newBody);
|
||||
editorRef.current.updateBody(newBody);
|
||||
},
|
||||
updateSettings(newSettings: EditorSettings) {
|
||||
bodyControl.updateSettings(newSettings);
|
||||
editorRef.current.updateSettings(newSettings);
|
||||
},
|
||||
|
||||
toggleBolded() {
|
||||
@@ -276,7 +184,7 @@ const useEditorControl = (
|
||||
execEditorCommand(EditorCommandType.IndentLess);
|
||||
},
|
||||
updateLink(label: string, url: string) {
|
||||
bodyControl.updateLink(label, url);
|
||||
editorRef.current.updateLink(label, url);
|
||||
},
|
||||
scrollSelectionIntoView() {
|
||||
execEditorCommand(EditorCommandType.ScrollSelectionIntoView);
|
||||
@@ -292,7 +200,7 @@ const useEditorControl = (
|
||||
},
|
||||
|
||||
setContentScripts: async (plugins: ContentScriptData[]) => {
|
||||
return bodyControl.setContentScripts(plugins);
|
||||
return editorRef.current.setContentScripts(plugins);
|
||||
},
|
||||
|
||||
setSearchState: setSearchStateCallback,
|
||||
@@ -320,37 +228,25 @@ const useEditorControl = (
|
||||
|
||||
setSearchState: setSearchStateCallback,
|
||||
},
|
||||
|
||||
onResourceDownloaded: (id: string) => {
|
||||
editorRef.current.onResourceDownloaded(id);
|
||||
},
|
||||
};
|
||||
|
||||
return control;
|
||||
}, [webviewRef, bodyControl, setLinkDialogVisible, setSearchState]);
|
||||
}, [webviewRef, editorRef, setLinkDialogVisible, setSearchState]);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
function NoteEditor(props: Props, ref: any) {
|
||||
function NoteEditor(props: Props) {
|
||||
const webviewRef = useRef<WebViewControl>(null);
|
||||
|
||||
const setInitialSelectionJs = props.initialSelection ? `
|
||||
cm.select(${props.initialSelection.start}, ${props.initialSelection.end});
|
||||
cm.execCommand('scrollSelectionIntoView');
|
||||
` : '';
|
||||
const jumpToHashJs = props.noteHash ? `
|
||||
cm.jumpToHash(${JSON.stringify(props.noteHash)});
|
||||
` : '';
|
||||
const setInitialSearchJs = props.globalSearch ? `
|
||||
cm.setSearchState(${JSON.stringify({
|
||||
...defaultSearchState,
|
||||
searchText: props.globalSearch,
|
||||
})})
|
||||
` : '';
|
||||
|
||||
const editorSettings: EditorSettings = useMemo(() => ({
|
||||
themeId: props.themeId,
|
||||
themeData: editorTheme(props.themeId),
|
||||
markdownMarkEnabled: Setting.value('markdown.plugin.mark'),
|
||||
katexEnabled: Setting.value('markdown.plugin.katex'),
|
||||
spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'),
|
||||
language: EditorLanguageType.Markdown,
|
||||
language: props.markupLanguage === MarkupLanguage.Html ? EditorLanguageType.Html : EditorLanguageType.Markdown,
|
||||
useExternalSearch: true,
|
||||
readOnly: props.readOnly,
|
||||
|
||||
@@ -365,208 +261,83 @@ function NoteEditor(props: Props, ref: any) {
|
||||
indentWithTabs: true,
|
||||
|
||||
editorLabel: _('Markdown editor'),
|
||||
}), [props.themeId, props.readOnly]);
|
||||
}), [props.themeId, props.readOnly, props.markupLanguage]);
|
||||
|
||||
const injectedJavaScript = `
|
||||
window.onerror = (message, source, lineno) => {
|
||||
window.ReactNativeWebView.postMessage(
|
||||
"error: " + message + " in file://" + source + ", line " + lineno
|
||||
);
|
||||
};
|
||||
window.onunhandledrejection = (event) => {
|
||||
window.ReactNativeWebView.postMessage(
|
||||
"error: Unhandled promise rejection: " + event
|
||||
);
|
||||
};
|
||||
|
||||
if (!window.cm) {
|
||||
// This variable is not used within this script
|
||||
// but is called using "injectJavaScript" from
|
||||
// the wrapper component.
|
||||
window.cm = null;
|
||||
|
||||
try {
|
||||
${shim.injectedJs('codeMirrorBundle')};
|
||||
codeMirrorBundle.setUpLogger();
|
||||
|
||||
const parentElement = document.getElementsByClassName('CodeMirror')[0];
|
||||
// On Android, injectJavaScript is run twice -- once before the parent element exists.
|
||||
// To avoid logging unnecessary errors to the console, skip setup in this case:
|
||||
if (parentElement) {
|
||||
const initialText = ${JSON.stringify(props.initialText)};
|
||||
const settings = ${JSON.stringify(editorSettings)};
|
||||
|
||||
window.cm = codeMirrorBundle.initCodeMirror(
|
||||
parentElement,
|
||||
initialText,
|
||||
${JSON.stringify(props.noteId)},
|
||||
settings
|
||||
);
|
||||
|
||||
${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');
|
||||
};
|
||||
} else {
|
||||
console.warn('No parent element for the editor found. This may mean that the editor HTML is still loading.');
|
||||
}
|
||||
} catch (e) {
|
||||
window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
|
||||
}
|
||||
}
|
||||
true;
|
||||
`;
|
||||
|
||||
const css = useCss(props.themeId);
|
||||
|
||||
useEffect(() => {
|
||||
if (webviewRef.current) {
|
||||
webviewRef.current.injectJS(`
|
||||
const styleClass = ${JSON.stringify(themeStyleSheetClassName)};
|
||||
for (const oldStyle of [...document.getElementsByClassName(styleClass)]) {
|
||||
oldStyle.remove();
|
||||
}
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.classList.add(styleClass);
|
||||
|
||||
style.appendChild(document.createTextNode(${JSON.stringify(css)}));
|
||||
document.head.appendChild(style);
|
||||
`);
|
||||
}
|
||||
}, [css]);
|
||||
|
||||
// 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]);
|
||||
|
||||
const html = useHtml(css);
|
||||
const [selectionState, setSelectionState] = useState<SelectionFormatting>(defaultSelectionFormatting);
|
||||
const [linkDialogVisible, setLinkDialogVisible] = useState(false);
|
||||
const [searchState, setSearchState] = useState(defaultSearchState);
|
||||
|
||||
const onEditorEvent = useRef((_event: EditorEvent) => {});
|
||||
const editorControlRef = useRef<EditorControl|null>(null);
|
||||
const onEditorEvent = (event: EditorEvent) => {
|
||||
let exhaustivenessCheck: never;
|
||||
switch (event.kind) {
|
||||
case EditorEventType.Change:
|
||||
props.onChange(event);
|
||||
break;
|
||||
case EditorEventType.UndoRedoDepthChange:
|
||||
props.onUndoRedoDepthChange(event);
|
||||
break;
|
||||
case EditorEventType.SelectionRangeChange:
|
||||
props.onSelectionChange(event);
|
||||
break;
|
||||
case EditorEventType.SelectionFormattingChange:
|
||||
setSelectionState(event.formatting);
|
||||
break;
|
||||
case EditorEventType.EditLink:
|
||||
editorControl.showLinkDialog();
|
||||
break;
|
||||
case EditorEventType.FollowLink:
|
||||
void CommandService.instance().execute('openItem', event.link);
|
||||
break;
|
||||
case EditorEventType.UpdateSearchDialog:
|
||||
setSearchState(event.searchState);
|
||||
|
||||
const onAttachRef = useRef(props.onAttach);
|
||||
onAttachRef.current = props.onAttach;
|
||||
|
||||
const editorMessenger = useMemo(() => {
|
||||
const localApi: WebViewToEditorApi = {
|
||||
async onEditorEvent(event) {
|
||||
onEditorEvent.current(event);
|
||||
},
|
||||
async logMessage(message) {
|
||||
logger.debug('CodeMirror:', message);
|
||||
},
|
||||
async onPasteFile(type, data) {
|
||||
const tempFilePath = join(Setting.value('tempDir'), `paste.${uuid.createNano()}.${mimeUtils.toFileExtension(type)}`);
|
||||
await shim.fsDriver().mkdir(dirname(tempFilePath));
|
||||
try {
|
||||
await shim.fsDriver().writeFile(tempFilePath, data, 'base64');
|
||||
await onAttachRef.current(tempFilePath);
|
||||
} finally {
|
||||
await shim.fsDriver().remove(tempFilePath);
|
||||
}
|
||||
},
|
||||
};
|
||||
const messenger = new RNToWebViewMessenger<WebViewToEditorApi, EditorBodyControl>(
|
||||
'editor', webviewRef, localApi,
|
||||
);
|
||||
return messenger;
|
||||
}, []);
|
||||
if (event.searchState.dialogVisible) {
|
||||
editorControl.searchControl.showSearch();
|
||||
} else {
|
||||
editorControl.searchControl.hideSearch();
|
||||
}
|
||||
break;
|
||||
case EditorEventType.Scroll:
|
||||
// Not handled
|
||||
break;
|
||||
default:
|
||||
exhaustivenessCheck = event;
|
||||
return exhaustivenessCheck;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
const editorRef = useRef<EditorBodyControl|null>(null);
|
||||
const editorControl = useEditorControl(
|
||||
editorMessenger.remoteApi, webviewRef, setLinkDialogVisible, setSearchState,
|
||||
editorRef, webviewRef, setLinkDialogVisible, setSearchState,
|
||||
);
|
||||
editorControlRef.current = editorControl;
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
editorControl.updateSettings(editorSettings);
|
||||
}, [editorSettings, editorControl]);
|
||||
|
||||
const lastNoteResources = useRef<ResourceInfos>(props.noteResources);
|
||||
useEffect(() => {
|
||||
const isDownloaded = (resourceInfos: ResourceInfos, resourceId: string) => {
|
||||
return resourceInfos[resourceId]?.localState?.fetch_status === Resource.FETCH_STATUS_DONE;
|
||||
};
|
||||
for (const key in props.noteResources) {
|
||||
const wasDownloaded = isDownloaded(lastNoteResources.current, key);
|
||||
if (!wasDownloaded && isDownloaded(props.noteResources, key)) {
|
||||
editorControl.onResourceDownloaded(key);
|
||||
}
|
||||
}
|
||||
}, [props.noteResources, editorControl]);
|
||||
|
||||
useEditorCommandHandler(editorControl);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
useImperativeHandle(props.ref, () => {
|
||||
return editorControl;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onEditorEvent.current = (event: EditorEvent) => {
|
||||
let exhaustivenessCheck: never;
|
||||
switch (event.kind) {
|
||||
case EditorEventType.Change:
|
||||
props.onChange(event);
|
||||
break;
|
||||
case EditorEventType.UndoRedoDepthChange:
|
||||
props.onUndoRedoDepthChange(event);
|
||||
break;
|
||||
case EditorEventType.SelectionRangeChange:
|
||||
props.onSelectionChange(event);
|
||||
break;
|
||||
case EditorEventType.SelectionFormattingChange:
|
||||
setSelectionState(event.formatting);
|
||||
break;
|
||||
case EditorEventType.EditLink:
|
||||
editorControl.showLinkDialog();
|
||||
break;
|
||||
case EditorEventType.UpdateSearchDialog:
|
||||
setSearchState(event.searchState);
|
||||
|
||||
if (event.searchState.dialogVisible) {
|
||||
editorControl.searchControl.showSearch();
|
||||
} else {
|
||||
editorControl.searchControl.hideSearch();
|
||||
}
|
||||
break;
|
||||
case EditorEventType.Scroll:
|
||||
// Not handled
|
||||
break;
|
||||
default:
|
||||
exhaustivenessCheck = event;
|
||||
return exhaustivenessCheck;
|
||||
}
|
||||
return;
|
||||
};
|
||||
}, [props.onChange, props.onUndoRedoDepthChange, props.onSelectionChange, editorControl]);
|
||||
|
||||
const codeMirrorPlugins = useCodeMirrorPlugins(props.plugins);
|
||||
useEffect(() => {
|
||||
void editorControl.setContentScripts(codeMirrorPlugins);
|
||||
}, [codeMirrorPlugins, editorControl]);
|
||||
|
||||
const onLoadEnd = useCallback(() => {
|
||||
editorMessenger.onWebViewLoaded();
|
||||
}, [editorMessenger]);
|
||||
|
||||
const onMessage = useCallback((event: OnMessageEvent) => {
|
||||
const data = event.nativeEvent.data;
|
||||
|
||||
if (typeof data === 'string' && data.indexOf('error:') === 0) {
|
||||
logger.error('CodeMirror error', data);
|
||||
return;
|
||||
}
|
||||
|
||||
editorMessenger.onWebViewMessage(event);
|
||||
}, [editorMessenger]);
|
||||
|
||||
const onError = useCallback((event: NativeSyntheticEvent<WebViewErrorEvent>) => {
|
||||
logger.error(`Load error: Code ${event.nativeEvent.code}: ${event.nativeEvent.description}`);
|
||||
}, []);
|
||||
|
||||
const [hasSpaceForToolbar, setHasSpaceForToolbar] = useState(true);
|
||||
const toolbarEnabled = props.toolbarEnabled && hasSpaceForToolbar;
|
||||
|
||||
@@ -580,12 +351,24 @@ function NoteEditor(props: Props, ref: any) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onAttach = useCallback(async (type: string, base64: string) => {
|
||||
const tempFilePath = join(Setting.value('tempDir'), `paste.${uuid.createNano()}.${toFileExtension(type)}`);
|
||||
await shim.fsDriver().mkdir(dirname(tempFilePath));
|
||||
try {
|
||||
await shim.fsDriver().writeFile(tempFilePath, base64, 'base64');
|
||||
await props.onAttach(tempFilePath);
|
||||
} finally {
|
||||
await shim.fsDriver().remove(tempFilePath);
|
||||
}
|
||||
}, [props.onAttach]);
|
||||
|
||||
const toolbarEditorState = useMemo(() => ({
|
||||
selectionState,
|
||||
searchVisible: searchState.dialogVisible,
|
||||
}), [selectionState, searchState.dialogVisible]);
|
||||
|
||||
const toolbar = <EditorToolbar editorState={toolbarEditorState} />;
|
||||
const EditorComponent = props.mode === EditorType.Markdown ? MarkdownEditor : RichTextEditor;
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -607,20 +390,25 @@ function NoteEditor(props: Props, ref: any) {
|
||||
flexShrink: 0,
|
||||
minHeight: '30%',
|
||||
}}>
|
||||
<ExtendedWebView
|
||||
webviewInstanceId='NoteEditor'
|
||||
testID='NoteEditor'
|
||||
scrollEnabled={true}
|
||||
ref={webviewRef}
|
||||
html={html}
|
||||
injectedJavaScript={injectedJavaScript}
|
||||
hasPluginScripts={codeMirrorPlugins.length > 0}
|
||||
onMessage={onMessage}
|
||||
onLoadEnd={onLoadEnd}
|
||||
onError={onError}
|
||||
<EditorComponent
|
||||
editorRef={editorRef}
|
||||
webviewRef={webviewRef}
|
||||
themeId={props.themeId}
|
||||
noteId={props.noteId}
|
||||
noteHash={props.noteHash}
|
||||
initialText={props.initialText}
|
||||
initialSelection={props.initialSelection}
|
||||
editorSettings={editorSettings}
|
||||
globalSearch={props.globalSearch}
|
||||
onEditorEvent={onEditorEvent}
|
||||
noteResources={props.noteResources}
|
||||
plugins={props.plugins}
|
||||
onAttach={onAttach}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<WarningBanner editorType={props.mode}/>
|
||||
|
||||
<SearchPanel
|
||||
editorSettings={editorSettings}
|
||||
searchControl={editorControl.searchControl}
|
||||
@@ -632,4 +420,4 @@ function NoteEditor(props: Props, ref: any) {
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(NoteEditor);
|
||||
export default NoteEditor;
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { describe, it, beforeEach } from '@jest/globals';
|
||||
import { render, waitFor } from '../../utils/testing/testingLibrary';
|
||||
|
||||
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { createNoteAndResource, resourceFetcher, setupDatabaseAndSynchronizer, supportDir, switchClient, synchronizerStart } from '@joplin/lib/testing/test-utils';
|
||||
import getWebViewWindowById from '../../utils/testing/getWebViewWindowById';
|
||||
import TestProviderStack from '../testing/TestProviderStack';
|
||||
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
|
||||
import RichTextEditor from './RichTextEditor';
|
||||
import createTestEditorProps from './testing/createTestEditorProps';
|
||||
import { EditorEvent, EditorEventType } from '@joplin/editor/events';
|
||||
import { RefObject, useCallback, useMemo } from 'react';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import { ResourceInfos } from '@joplin/renderer/types';
|
||||
import { EditorControl, EditorLanguageType } from '@joplin/editor/types';
|
||||
import attachedResources from '@joplin/lib/utils/attachedResources';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import { EditorSettings } from './types';
|
||||
import { pregQuote } from '@joplin/lib/string-utils';
|
||||
|
||||
|
||||
interface WrapperProps {
|
||||
ref?: RefObject<EditorControl>;
|
||||
noteResources?: ResourceInfos;
|
||||
onBodyChange: (newBody: string)=> void;
|
||||
onLinkClick?: (link: string)=> void;
|
||||
note?: NoteEntity;
|
||||
noteBody: string;
|
||||
}
|
||||
|
||||
const defaultEditorProps = createTestEditorProps();
|
||||
const testStore = createMockReduxStore();
|
||||
const WrappedEditor: React.FC<WrapperProps> = (
|
||||
{
|
||||
noteBody,
|
||||
note,
|
||||
onBodyChange,
|
||||
onLinkClick,
|
||||
noteResources,
|
||||
ref,
|
||||
}: WrapperProps,
|
||||
) => {
|
||||
const onEvent = useCallback((event: EditorEvent) => {
|
||||
if (event.kind === EditorEventType.Change) {
|
||||
onBodyChange(event.value);
|
||||
} else if (event.kind === EditorEventType.FollowLink) {
|
||||
if (!onLinkClick) {
|
||||
throw new Error('No mock function for onLinkClick registered.');
|
||||
}
|
||||
|
||||
onLinkClick(event.link);
|
||||
}
|
||||
}, [onBodyChange, onLinkClick]);
|
||||
|
||||
const editorSettings = useMemo((): EditorSettings => {
|
||||
const isHtml = note?.markup_language === MarkupLanguage.Html;
|
||||
return {
|
||||
...defaultEditorProps.editorSettings,
|
||||
language: isHtml ? EditorLanguageType.Html : EditorLanguageType.Markdown,
|
||||
};
|
||||
}, [note]);
|
||||
|
||||
return <TestProviderStack store={testStore}>
|
||||
<RichTextEditor
|
||||
{...defaultEditorProps}
|
||||
editorSettings={editorSettings}
|
||||
onEditorEvent={onEvent}
|
||||
initialText={noteBody}
|
||||
noteId={note?.id ?? defaultEditorProps.noteId}
|
||||
noteResources={noteResources ?? defaultEditorProps.noteResources}
|
||||
editorRef={ref ?? defaultEditorProps.editorRef}
|
||||
/>
|
||||
</TestProviderStack>;
|
||||
};
|
||||
|
||||
const getEditorWindow = async () => {
|
||||
return await getWebViewWindowById('RichTextEditor');
|
||||
};
|
||||
|
||||
type EditorWindow = Window&typeof globalThis;
|
||||
const getEditorControl = (window: EditorWindow) => {
|
||||
if ('joplinRichTextEditor_' in window) {
|
||||
return window.joplinRichTextEditor_ as EditorControl;
|
||||
}
|
||||
throw new Error('No editor control found. Is the editor loaded?');
|
||||
};
|
||||
|
||||
const mockTyping = (window: EditorWindow, text: string) => {
|
||||
const document = window.document;
|
||||
const editor = document.querySelector('div[contenteditable]');
|
||||
|
||||
for (const character of text.split('')) {
|
||||
editor.dispatchEvent(new window.KeyboardEvent('keydown', { key: character }));
|
||||
const paragraphs = editor.querySelectorAll('p');
|
||||
(paragraphs[paragraphs.length - 1] ?? editor).appendChild(document.createTextNode(character));
|
||||
editor.dispatchEvent(new window.KeyboardEvent('keyup', { key: character }));
|
||||
}
|
||||
};
|
||||
|
||||
const mockSelectionMovement = (window: EditorWindow, position: number) => {
|
||||
getEditorControl(window).select(position, position);
|
||||
};
|
||||
|
||||
const findElement = async function<ElementType extends Element = Element>(selector: string) {
|
||||
const window = await getEditorWindow();
|
||||
return await waitFor(() => {
|
||||
const element = window.document.querySelector<ElementType>(selector);
|
||||
expect(element).toBeTruthy();
|
||||
return element;
|
||||
}, {
|
||||
onTimeout: (error) => {
|
||||
return new Error(`Failed to find element from selector ${selector}. DOM: ${window?.document?.body?.innerHTML}. \n\nFull error: ${error}`);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createRemoteResourceAndNote = async (remoteClientId: number) => {
|
||||
await setupDatabaseAndSynchronizer(remoteClientId);
|
||||
await switchClient(remoteClientId);
|
||||
|
||||
let note = await Note.save({ title: 'Note 1', parent_id: '' });
|
||||
note = await shim.attachFileToNote(note, `${supportDir}/photo.jpg`);
|
||||
|
||||
const allResources = await Resource.all();
|
||||
expect(allResources.length).toBe(1);
|
||||
const resourceId = allResources[0].id;
|
||||
|
||||
await synchronizerStart();
|
||||
await switchClient(0);
|
||||
await synchronizerStart();
|
||||
|
||||
|
||||
return { noteId: note.id, resourceId };
|
||||
};
|
||||
|
||||
describe('RichTextEditor', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(0);
|
||||
await switchClient(0);
|
||||
Setting.setValue('editor.codeView', false);
|
||||
});
|
||||
|
||||
it('should render basic markdown', async () => {
|
||||
render(<WrappedEditor
|
||||
noteBody={'### Test\n\nParagraph `test`'}
|
||||
onBodyChange={jest.fn()}
|
||||
/>);
|
||||
|
||||
const dom = (await getEditorWindow()).document;
|
||||
expect((await findElement('h3')).textContent).toBe('Test');
|
||||
expect(dom.querySelector('p').textContent).toBe('Paragraph test');
|
||||
expect(dom.querySelector('p code').textContent).toBe('test');
|
||||
});
|
||||
|
||||
it('should dispatch events when the editor content changes', async () => {
|
||||
let body = '**bold** normal';
|
||||
render(<WrappedEditor
|
||||
noteBody={body}
|
||||
onBodyChange={newBody => { body = newBody; }}
|
||||
/>);
|
||||
|
||||
const window = await getEditorWindow();
|
||||
mockTyping(window, ' test');
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(body.trim()).toBe('**bold** normal test');
|
||||
});
|
||||
});
|
||||
|
||||
it('should render clickable checkboxes', async () => {
|
||||
let body = '- [ ] Test\n- [x] Another test';
|
||||
render(<WrappedEditor
|
||||
noteBody={body}
|
||||
onBodyChange={newBody => { body = newBody; }}
|
||||
/>);
|
||||
|
||||
const firstCheckbox = await findElement<HTMLInputElement>('input[type=checkbox]');
|
||||
const dom = (await getEditorWindow()).document;
|
||||
const getCheckboxLabel = (checkbox: HTMLElement) => {
|
||||
const labelledByAttr = checkbox.getAttribute('aria-labelledby');
|
||||
const label = dom.getElementById(labelledByAttr);
|
||||
return label;
|
||||
};
|
||||
|
||||
// Should have the correct labels
|
||||
expect(firstCheckbox.getAttribute('aria-labelledby')).toBeTruthy();
|
||||
expect(getCheckboxLabel(firstCheckbox).textContent).toBe('Test');
|
||||
|
||||
// Should be correctly checked/unchecked
|
||||
expect(firstCheckbox.checked).toBe(false);
|
||||
|
||||
// Clicking a checkbox should toggle it
|
||||
firstCheckbox.click();
|
||||
|
||||
await waitFor(async () => {
|
||||
// At present, lists are saved as non-tight lists:
|
||||
expect(body.trim()).toBe('- [x] Test\n \n- [x] Another test');
|
||||
});
|
||||
});
|
||||
|
||||
it('should reload resource placeholders when the corresponding item downloads', async () => {
|
||||
Setting.setValue('sync.resourceDownloadMode', 'manual');
|
||||
const { noteId, resourceId } = await createRemoteResourceAndNote(1);
|
||||
|
||||
const note = await Note.load(noteId);
|
||||
const localResource = await Resource.load(resourceId);
|
||||
let localState = await Resource.localState(localResource);
|
||||
expect(localState.fetch_status).toBe(Resource.FETCH_STATUS_IDLE);
|
||||
|
||||
const editorRef = React.createRef<EditorControl>();
|
||||
const component = render(
|
||||
<WrappedEditor
|
||||
noteBody={note.body}
|
||||
noteResources={{ [localResource.id]: { localState, item: localResource } }}
|
||||
onBodyChange={jest.fn()}
|
||||
ref={editorRef}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The resource placeholder should have rendered
|
||||
const placeholder = await findElement(`span[data-resource-id=${JSON.stringify(localResource.id)}]`);
|
||||
expect([...placeholder.classList]).toContain('not-loaded-resource');
|
||||
|
||||
await resourceFetcher().markForDownload([localResource.id]);
|
||||
|
||||
await waitFor(async () => {
|
||||
localState = await Resource.localState(localResource.id);
|
||||
expect(localState).toMatchObject({ fetch_status: Resource.FETCH_STATUS_DONE });
|
||||
});
|
||||
|
||||
component.rerender(
|
||||
<WrappedEditor
|
||||
noteBody={note.body}
|
||||
noteResources={{ [localResource.id]: { localState, item: localResource } }}
|
||||
onBodyChange={jest.fn()}
|
||||
ref={editorRef}
|
||||
/>,
|
||||
);
|
||||
editorRef.current.onResourceDownloaded(localResource.id);
|
||||
|
||||
expect(
|
||||
await findElement(`img[data-resource-id=${JSON.stringify(localResource.id)}]`),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render clickable internal note links', async () => {
|
||||
const linkTarget = await Note.save({ title: 'test' });
|
||||
const body = `[link](:/${linkTarget.id})`;
|
||||
const onLinkClick = jest.fn();
|
||||
render(<WrappedEditor
|
||||
noteBody={body}
|
||||
onBodyChange={jest.fn()}
|
||||
onLinkClick={onLinkClick}
|
||||
/>);
|
||||
|
||||
const window = await getEditorWindow();
|
||||
|
||||
const link = await findElement<HTMLAnchorElement>('a[href]');
|
||||
expect(link.href).toBe(`:/${linkTarget.id}`);
|
||||
mockSelectionMovement(window, 2);
|
||||
|
||||
const tooltipButton = await findElement<HTMLButtonElement>('.link-tooltip:not(.-hidden) > button');
|
||||
tooltipButton.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onLinkClick).toHaveBeenCalledWith(`:/${linkTarget.id}`);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
MarkupLanguage.Markdown, MarkupLanguage.Html,
|
||||
])('should preserve image attachments on edit (case %#)', async (markupLanguage) => {
|
||||
const { note, resource } = await createNoteAndResource({ markupLanguage });
|
||||
let body = note.body;
|
||||
|
||||
const resources = await attachedResources(body);
|
||||
render(<WrappedEditor
|
||||
noteBody={note.body}
|
||||
note={note}
|
||||
onBodyChange={newBody => { body = newBody; }}
|
||||
noteResources={resources}
|
||||
/>);
|
||||
|
||||
const renderedImage = await findElement<HTMLImageElement>(`img[data-resource-id=${JSON.stringify(resource.id)}]`);
|
||||
expect(renderedImage).toBeTruthy();
|
||||
|
||||
const window = await getEditorWindow();
|
||||
mockTyping(window, ' test');
|
||||
|
||||
// The rendered image should still have the correct ALT and source
|
||||
await waitFor(async () => {
|
||||
const editorContent = body.trim();
|
||||
if (markupLanguage === MarkupLanguage.Html) {
|
||||
expect(editorContent).toMatch(
|
||||
new RegExp(`^<p><img src=":/${pregQuote(resource.id)}" alt="${pregQuote(renderedImage.alt)}"[^>]*> test</p>$`),
|
||||
);
|
||||
} else {
|
||||
expect(editorContent).toBe(` test`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ useValidSyntax: false },
|
||||
{ useValidSyntax: true },
|
||||
])('should preserve inline math on edit (%j)', async ({ useValidSyntax }) => {
|
||||
const macros = '\\def\\<{\\langle} \\def\\>{\\rangle}';
|
||||
let inlineMath = '| \\< u, v \\> |^2 \\leq \\< u, u \\>\\< v, v \\>';
|
||||
// The \\< escapes are invalid without the above custom macro definitions.
|
||||
// It should be possible for the editor to preserve invalid math syntax.
|
||||
if (useValidSyntax) {
|
||||
inlineMath = macros + inlineMath;
|
||||
}
|
||||
|
||||
let body = `Inline math: $${inlineMath}$...`;
|
||||
|
||||
render(<WrappedEditor
|
||||
noteBody={body}
|
||||
onBodyChange={newBody => { body = newBody; }}
|
||||
/>);
|
||||
|
||||
const renderedInlineMath = await findElement<HTMLElement>('span.joplin-editable');
|
||||
expect(renderedInlineMath).toBeTruthy();
|
||||
|
||||
const window = await getEditorWindow();
|
||||
mockTyping(window, ' testing');
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(body.trim()).toBe(`Inline math: $${inlineMath}$... testing`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve block math on edit', async () => {
|
||||
let body = 'Test:\n\n$$3^2 + 4^2 = \\sqrt{625}$$\n\nTest.';
|
||||
|
||||
render(<WrappedEditor
|
||||
noteBody={body}
|
||||
onBodyChange={newBody => { body = newBody; }}
|
||||
/>);
|
||||
|
||||
const renderedInlineMath = await findElement<HTMLElement>('div.joplin-editable');
|
||||
expect(renderedInlineMath).toBeTruthy();
|
||||
|
||||
const window = await getEditorWindow();
|
||||
mockTyping(window, ' testing');
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(body.trim()).toBe('Test:\n\n$$\n3^2 + 4^2 = \\sqrt{625}\n$$\n\nTest. testing');
|
||||
});
|
||||
});
|
||||
});
|
||||
155
packages/app-mobile/components/NoteEditor/RichTextEditor.tsx
Normal file
155
packages/app-mobile/components/NoteEditor/RichTextEditor.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import themeToCss from '@joplin/lib/services/style/themeToCss';
|
||||
import ExtendedWebView from '../ExtendedWebView';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useMemo, useCallback, useRef } from 'react';
|
||||
import { NativeSyntheticEvent } from 'react-native';
|
||||
|
||||
import { EditorProps } from './types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { WebViewErrorEvent } from 'react-native-webview/lib/RNCWebViewNativeComponent';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { OnMessageEvent } from '../ExtendedWebView/types';
|
||||
import useWebViewSetup from '../../contentScripts/richTextEditorBundle/useWebViewSetup';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import shim from '@joplin/lib/shim';
|
||||
|
||||
const logger = Logger.create('RichTextEditor');
|
||||
|
||||
function useCss(themeId: number, editorCss: string): string {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
const themeVariableCss = themeToCss(theme);
|
||||
return `
|
||||
${themeVariableCss}
|
||||
${editorCss}
|
||||
|
||||
:root {
|
||||
background-color: ${theme.backgroundColor};
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
/* Prefer 100% -- 100vw shows an unnecessary horizontal scrollbar in Google Chrome (desktop). */
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding-bottom: 1px;
|
||||
padding-top: 10px;
|
||||
|
||||
font-size: 13pt;
|
||||
font-family: ${JSON.stringify(theme.fontFamily)}, sans-serif;
|
||||
}
|
||||
`;
|
||||
}, [themeId, editorCss]);
|
||||
}
|
||||
|
||||
function useHtml(initialCss: string): string {
|
||||
const cssRef = useRef(initialCss);
|
||||
cssRef.current = initialCss;
|
||||
|
||||
return useMemo(() => `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<title>${_('Note editor')}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="RichTextEditor" style="height:100%;" autocapitalize="on"></div>
|
||||
</body>
|
||||
</html>
|
||||
`, []);
|
||||
}
|
||||
|
||||
const onPostMessage = async (message: string) => {
|
||||
try {
|
||||
await CommandService.instance().execute('openItem', message);
|
||||
} catch (error) {
|
||||
void shim.showErrorDialog(`postMessage failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const RichTextEditor: React.FC<EditorProps> = props => {
|
||||
const webviewRef = props.webviewRef;
|
||||
|
||||
const editorWebViewSetup = useWebViewSetup({
|
||||
parentElementClassName: 'RichTextEditor',
|
||||
onEditorEvent: props.onEditorEvent,
|
||||
initialText: props.initialText,
|
||||
noteId: props.noteId,
|
||||
settings: props.editorSettings,
|
||||
webviewRef,
|
||||
themeId: props.themeId,
|
||||
pluginStates: props.plugins,
|
||||
noteResources: props.noteResources,
|
||||
onPostMessage: onPostMessage,
|
||||
onAttachFile: props.onAttach,
|
||||
});
|
||||
|
||||
props.editorRef.current = editorWebViewSetup.api;
|
||||
|
||||
const injectedJavaScript = `
|
||||
window.onerror = (message, source, lineno) => {
|
||||
console.error(message);
|
||||
window.ReactNativeWebView.postMessage(
|
||||
"error: " + message + " in file://" + source + ", line " + lineno
|
||||
);
|
||||
};
|
||||
window.onunhandledrejection = (event) => {
|
||||
window.ReactNativeWebView.postMessage(
|
||||
"error: Unhandled promise rejection: " + event
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
${editorWebViewSetup.pageSetup.js}
|
||||
} catch (e) {
|
||||
console.error('Setup error: ', e);
|
||||
window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
|
||||
}
|
||||
|
||||
true;
|
||||
`;
|
||||
|
||||
const css = useCss(props.themeId, editorWebViewSetup.pageSetup.css);
|
||||
const html = useHtml(css);
|
||||
|
||||
const onMessage = useCallback((event: OnMessageEvent) => {
|
||||
const data = event.nativeEvent.data;
|
||||
|
||||
if (typeof data === 'string' && data.indexOf('error:') === 0) {
|
||||
logger.error('Rich Text Editor error', data);
|
||||
return;
|
||||
}
|
||||
|
||||
editorWebViewSetup.webViewEventHandlers.onMessage(event);
|
||||
}, [editorWebViewSetup]);
|
||||
|
||||
const onError = useCallback((event: NativeSyntheticEvent<WebViewErrorEvent>) => {
|
||||
logger.error(`Load error: Code ${event.nativeEvent.code}: ${event.nativeEvent.description}`);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ExtendedWebView
|
||||
ref={webviewRef}
|
||||
webviewInstanceId='RichTextEditor'
|
||||
testID='RichTextEditor'
|
||||
scrollEnabled={true}
|
||||
html={html}
|
||||
injectedJavaScript={injectedJavaScript}
|
||||
css={css}
|
||||
hasPluginScripts={false}
|
||||
onMessage={onMessage}
|
||||
onLoadEnd={editorWebViewSetup.webViewEventHandlers.onLoadEnd}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RichTextEditor;
|
||||
@@ -192,7 +192,7 @@ export const SearchPanel = (props: SearchPanelProps) => {
|
||||
}, [state.dialogVisible, control]);
|
||||
|
||||
|
||||
const themeId = props.editorSettings.themeId;
|
||||
const themeId = props.editorSettings.themeData.themeId;
|
||||
const closeButton = (
|
||||
<ActionButton
|
||||
themeId={themeId}
|
||||
|
||||
47
packages/app-mobile/components/NoteEditor/WarningBanner.tsx
Normal file
47
packages/app-mobile/components/NoteEditor/WarningBanner.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import onRichTextReadMoreLinkClick from '@joplin/lib/components/shared/NoteEditor/WarningBanner/onRichTextReadMoreLinkClick';
|
||||
import onRichTextDismissLinkClick from '@joplin/lib/components/shared/NoteEditor/WarningBanner/onRichTextDismissLinkClick';
|
||||
import { AppState } from '../../utils/types';
|
||||
import { EditorType } from './types';
|
||||
import { Banner } from 'react-native-paper';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface Props {
|
||||
editorType: EditorType;
|
||||
richTextBannerDismissed: boolean;
|
||||
}
|
||||
|
||||
const WarningBanner: React.FC<Props> = props => {
|
||||
const actions = useMemo(() => [
|
||||
{
|
||||
label: _('Read more'),
|
||||
onPress: onRichTextReadMoreLinkClick,
|
||||
},
|
||||
{
|
||||
label: _('Dismiss'),
|
||||
accessibilityHint: _('Hides warning'),
|
||||
onPress: onRichTextDismissLinkClick,
|
||||
},
|
||||
], []);
|
||||
|
||||
if (props.editorType !== EditorType.RichText || props.richTextBannerDismissed) return null;
|
||||
return (
|
||||
<Banner
|
||||
icon='alert-outline'
|
||||
actions={actions}
|
||||
// Avoid hiding with react-native-paper's "visible" prop to avoid potential accessibility issues
|
||||
// related to how react-native-paper hides the banner.
|
||||
visible={true}
|
||||
>
|
||||
{_('This Rich Text editor has a number of limitations and it is recommended to be aware of them before using it.')}
|
||||
</Banner>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect((state: AppState) => {
|
||||
return {
|
||||
richTextBannerDismissed: state.settings.richTextBannerDismissed,
|
||||
};
|
||||
})(WarningBanner);
|
||||
@@ -2,11 +2,22 @@ import { EditorCommandType } from '@joplin/editor/types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { CommandDeclaration } from '@joplin/lib/services/CommandService';
|
||||
|
||||
export const enabledCondition = (_commandName: string) => {
|
||||
const markdownEditorOnlyCommands = [
|
||||
EditorCommandType.DuplicateLine,
|
||||
EditorCommandType.SortSelectedLines,
|
||||
EditorCommandType.SwapLineUp,
|
||||
EditorCommandType.SwapLineDown,
|
||||
].map(command => `editor.${command}`);
|
||||
|
||||
export const enabledCondition = (commandName: string) => {
|
||||
const output = [
|
||||
'!noteIsReadOnly',
|
||||
];
|
||||
|
||||
if (markdownEditorOnlyCommands.includes(commandName)) {
|
||||
output.push('!richTextEditorVisible');
|
||||
}
|
||||
|
||||
return output.filter(c => !!c).join(' && ');
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import createEditorSettings from '@joplin/editor/testing/createEditorSettings';
|
||||
import { EditorProps } from '../types';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
const defaultEditorSettings = { ...createEditorSettings(Setting.THEME_LIGHT), themeId: Setting.THEME_LIGHT };
|
||||
const defaultWrapperProps: EditorProps = {
|
||||
noteResources: {},
|
||||
webviewRef: React.createRef(),
|
||||
editorRef: React.createRef(),
|
||||
themeId: Setting.THEME_LIGHT,
|
||||
noteHash: '',
|
||||
noteId: '',
|
||||
initialText: '',
|
||||
editorSettings: defaultEditorSettings,
|
||||
initialSelection: { start: 0, end: 0 },
|
||||
globalSearch: '',
|
||||
plugins: {},
|
||||
onAttach: () => Promise.resolve(),
|
||||
onEditorEvent: () => {},
|
||||
};
|
||||
|
||||
const createTestEditorProps = () => ({ ...defaultWrapperProps });
|
||||
export default createTestEditorProps;
|
||||
@@ -1,7 +1,12 @@
|
||||
// Types related to the NoteEditor
|
||||
|
||||
import { EditorEvent } from '@joplin/editor/events';
|
||||
import { EditorControl as EditorBodyControl, EditorSettings as EditorBodySettings, SearchState } from '@joplin/editor/types';
|
||||
import { RefObject } from 'react';
|
||||
import { WebViewControl } from '../ExtendedWebView/types';
|
||||
import { SelectionRange } from '../../contentScripts/markdownEditorBundle/types';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import { EditorEvent } from '@joplin/editor/events';
|
||||
import { ResourceInfos } from '@joplin/renderer/types';
|
||||
|
||||
export interface SearchControl {
|
||||
findNext(): void;
|
||||
@@ -45,17 +50,27 @@ export interface EditorControl extends EditorBodyControl {
|
||||
searchControl: SearchControl;
|
||||
}
|
||||
|
||||
export interface EditorSettings extends EditorBodySettings {
|
||||
export type EditorSettings = EditorBodySettings;
|
||||
|
||||
type OnAttachCallback = (mime: string, base64: string)=> Promise<void>;
|
||||
export interface EditorProps {
|
||||
noteResources: ResourceInfos;
|
||||
editorRef: RefObject<EditorBodyControl>;
|
||||
webviewRef: RefObject<WebViewControl>;
|
||||
themeId: number;
|
||||
noteId: string;
|
||||
noteHash: string;
|
||||
initialText: string;
|
||||
initialSelection: SelectionRange;
|
||||
editorSettings: EditorSettings;
|
||||
globalSearch: string;
|
||||
plugins: PluginStates;
|
||||
|
||||
onAttach: OnAttachCallback;
|
||||
onEditorEvent: (event: EditorEvent)=> void;
|
||||
}
|
||||
|
||||
export interface SelectionRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface WebViewToEditorApi {
|
||||
onEditorEvent(event: EditorEvent): Promise<void>;
|
||||
logMessage(message: string): Promise<void>;
|
||||
onPasteFile(type: string, dataBase64: string): Promise<void>;
|
||||
export enum EditorType {
|
||||
Markdown = 'markdown',
|
||||
RichText = 'rich-text',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user