diff --git a/.eslintignore b/.eslintignore
index e64e5ecea15..f21103bc3d9 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -948,6 +948,12 @@ packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js.map
packages/app-mobile/components/NoteEditor/EditLinkDialog.d.ts
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
packages/app-mobile/components/NoteEditor/EditLinkDialog.js.map
+packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.d.ts
+packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.js
+packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.js.map
+packages/app-mobile/components/NoteEditor/ImageEditor/createJsDrawEditor.d.ts
+packages/app-mobile/components/NoteEditor/ImageEditor/createJsDrawEditor.js
+packages/app-mobile/components/NoteEditor/ImageEditor/createJsDrawEditor.js.map
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.d.ts
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js.map
diff --git a/.gitignore b/.gitignore
index 1d1b9de1ac5..c89ba89f8bb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -936,6 +936,12 @@ packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js.map
packages/app-mobile/components/NoteEditor/EditLinkDialog.d.ts
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
packages/app-mobile/components/NoteEditor/EditLinkDialog.js.map
+packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.d.ts
+packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.js
+packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.js.map
+packages/app-mobile/components/NoteEditor/ImageEditor/createJsDrawEditor.d.ts
+packages/app-mobile/components/NoteEditor/ImageEditor/createJsDrawEditor.js
+packages/app-mobile/components/NoteEditor/ImageEditor/createJsDrawEditor.js.map
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.d.ts
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js.map
diff --git a/packages/app-mobile/.gitignore b/packages/app-mobile/.gitignore
index 4482323fa83..37470b02973 100644
--- a/packages/app-mobile/.gitignore
+++ b/packages/app-mobile/.gitignore
@@ -64,7 +64,7 @@ buck-out/
lib/csstojs/
lib/rnInjectedJs/
dist/
-components/NoteEditor/CodeMirror/CodeMirror.bundle.js
-components/NoteEditor/CodeMirror/CodeMirror.bundle.min.js
+components/NoteEditor/**/*.bundle.js
+components/NoteEditor/**/*.bundle.min.js
utils/fs-driver-android.js
diff --git a/packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.tsx b/packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.tsx
new file mode 100644
index 00000000000..4125de83f7c
--- /dev/null
+++ b/packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.tsx
@@ -0,0 +1,190 @@
+const React = require('react');
+import { _ } from '@joplin/lib/locale';
+import Setting from '@joplin/lib/models/Setting';
+import shim from '@joplin/lib/shim';
+import { themeStyle } from '@joplin/lib/theme';
+import { Theme } from '@joplin/lib/themes/type';
+import { useCallback, useEffect, useMemo, useRef } from 'react';
+import { Alert, BackHandler } from 'react-native';
+import { WebViewMessageEvent } from 'react-native-webview';
+import ExtendedWebView from '../../ExtendedWebView';
+
+type OnSaveCallback = (svgData: string)=> void;
+type OnCancelCallback = ()=> void;
+
+interface Props {
+ themeId: number;
+ initialSVGData: string;
+ onSave: OnSaveCallback;
+ onCancel: OnCancelCallback;
+}
+
+const useCss = (editorTheme: Theme) => {
+ return useMemo(() => {
+ return `
+ :root .imageEditorContainer {
+ --primary-background-color: ${editorTheme.backgroundColor};
+ --primary-background-color-transparent: ${editorTheme.backgroundColorTransparent};
+ --secondary-background-color: ${editorTheme.selectedColor2};
+ --primary-foreground-color: ${editorTheme.color};
+ --secondary-foreground-color: ${editorTheme.color2};
+ --primary-shadow-color: ${editorTheme.colorFaded};
+
+ width: 100vw;
+ height: 100vh;
+ box-sizing: border-box;
+ }
+
+ body, html {
+ padding: 0;
+ margin: 0;
+ }
+ `;
+ }, [editorTheme]);
+};
+
+const ImageEditor = (props: Props) => {
+ const editorTheme: Theme = themeStyle(props.themeId);
+ const webviewRef = useRef(null);
+
+ const onBackPress = useCallback(() => {
+ Alert.alert(
+ _('Save changes?'), _('This drawing may have unsaved changes.'), [
+ {
+ text: _('Discard changes'),
+ onPress: () => props.onCancel(),
+ style: 'destructive',
+ },
+ {
+ text: _('Save changes'),
+ onPress: () => {
+ // saveDrawing calls props.onSave(...) which may close the
+ // editor.
+ webviewRef.current.injectJS('saveDrawing();');
+ },
+ },
+ ]
+ );
+ return true;
+ }, [webviewRef, props.onCancel]);
+
+ useEffect(() => {
+ BackHandler.addEventListener('hardwareBackPress', onBackPress);
+
+ return () => {
+ BackHandler.removeEventListener('hardwareBackPress', onBackPress);
+ };
+ }, [onBackPress]);
+
+ const css = useCss(editorTheme);
+ const html = useMemo(() => `
+
+
+
+
+
+
+
+
+
+
+ `, [css]);
+
+ const injectedJavaScript = useMemo(() => `
+ window.onerror = (message, source, lineno) => {
+ window.ReactNativeWebView.postMessage(
+ "error: " + message + " in file://" + source + ", line " + lineno
+ );
+ };
+
+ const saveDrawing = (isAutosave) => {
+ const img = window.editor.toSVG();
+ window.ReactNativeWebView.postMessage(
+ JSON.stringify({
+ action: isAutosave ? 'autosave' : 'save',
+ data: img.outerHTML,
+ }),
+ );
+ };
+ window.saveDrawing = saveDrawing;
+
+ try {
+ if (window.editor === undefined) {
+ ${shim.injectedJs('svgEditorBundle')}
+
+ window.editor = svgEditorBundle.createJsDrawEditor();
+
+ window.initialSVGData = ${JSON.stringify(props.initialSVGData)};
+ if (initialSVGData && initialSVGData.length > 0) {
+ editor.loadFromSVG(initialSVGData);
+ }
+
+ const toolbar = editor.addToolbar();
+ toolbar.addActionButton({
+ label: ${JSON.stringify(_('Done'))},
+ icon: editor.icons.makeSaveIcon(),
+ }, () => {
+ saveDrawing(false);
+ });
+
+ svgEditorBundle.restoreToolbarState(
+ toolbar,
+ ${JSON.stringify(Setting.value('imageeditor.jsdrawToolbar'))}
+ );
+ svgEditorBundle.listenToolbarState(editor, toolbar);
+
+ // Auto-save every four minutes.
+ const autoSaveInterval = 4 * 60 * 1000;
+ setInterval(() => {
+ saveDrawing(true);
+ }, autoSaveInterval);
+ }
+ } catch(e) {
+ window.ReactNativeWebView.postMessage(
+ 'error: ' + e.message + ': ' + JSON.stringify(e)
+ );
+ }
+ true;
+ `, [props.initialSVGData]);
+
+ const onMessage = useCallback(async (event: WebViewMessageEvent) => {
+ const data = event.nativeEvent.data;
+ if (data.startsWith('error:')) {
+ console.error('ImageEditor:', data);
+ return;
+ }
+
+ const json = JSON.parse(data);
+ if (json.action === 'save') {
+ props.onSave(json.data);
+ } else if (json.action === 'autosave') {
+ const filePath = `${Setting.value('resourceDir')}/autosaved-drawing.joplin.svg`;
+ await shim.fsDriver().writeFile(filePath, json.data, 'utf8');
+ console.info('Auto-saved to %s', filePath);
+ } else if (json.action === 'save-toolbar') {
+ Setting.setValue('imageeditor.jsdrawToolbar', json.data);
+ } else {
+ console.error('Unknown action,', json.action);
+ }
+ }, [props.onSave]);
+
+ const onError = useCallback((event: any) => {
+ console.error('ImageEditor: WebView error: ', event);
+ }, []);
+
+ return (
+
+ );
+};
+
+export default ImageEditor;
diff --git a/packages/app-mobile/components/NoteEditor/ImageEditor/createJsDrawEditor.ts b/packages/app-mobile/components/NoteEditor/ImageEditor/createJsDrawEditor.ts
new file mode 100644
index 00000000000..334cc29701d
--- /dev/null
+++ b/packages/app-mobile/components/NoteEditor/ImageEditor/createJsDrawEditor.ts
@@ -0,0 +1,39 @@
+
+import Editor, { EditorEventType, HTMLToolbar } from 'js-draw';
+import 'js-draw/bundle';
+
+declare namespace ReactNativeWebView {
+ const postMessage: (data: any)=> void;
+}
+
+export const createJsDrawEditor = (): Editor => {
+ const parentElement = document.body;
+ const editor = new Editor(parentElement);
+
+ return editor;
+};
+
+export const restoreToolbarState = (toolbar: HTMLToolbar, state: string) => {
+ if (state) {
+ // deserializeState throws on invalid argument.
+ try {
+ toolbar.deserializeState(state);
+ } catch (e) {
+ console.warn('Error deserializing toolbar state: ', e);
+ }
+ }
+};
+
+export const listenToolbarState = (editor: Editor, toolbar: HTMLToolbar) => {
+ editor.notifier.on(EditorEventType.ToolUpdated, () => {
+ const state = toolbar.serializeState();
+ ReactNativeWebView.postMessage(
+ JSON.stringify({
+ action: 'save-toolbar',
+ data: state,
+ })
+ );
+ });
+};
+
+export default createJsDrawEditor;
diff --git a/packages/app-mobile/components/screens/Note.tsx b/packages/app-mobile/components/screens/Note.tsx
index ec93d6d220c..10ce77961db 100644
--- a/packages/app-mobile/components/screens/Note.tsx
+++ b/packages/app-mobile/components/screens/Note.tsx
@@ -44,6 +44,7 @@ import ShareExtension from '../../utils/ShareExtension.js';
import CameraView from '../CameraView';
import { NoteEntity } from '@joplin/lib/services/database/types';
import Logger from '@joplin/lib/Logger';
+import ImageEditor from '../NoteEditor/ImageEditor/ImageEditor';
const urlUtils = require('@joplin/lib/urlUtils');
const emptyArray: any[] = [];
@@ -69,6 +70,9 @@ class NoteScreenComponent extends BaseScreenComponent {
noteTagDialogShown: false,
fromShare: false,
showCamera: false,
+ showImageEditor: false,
+ imageEditorData: '',
+ imageEditorResource: null,
noteResources: {},
// HACK: For reasons I can't explain, when the WebView is present, the TextInput initially does not display (It's just a white rectangle with
@@ -184,10 +188,14 @@ class NoteScreenComponent extends BaseScreenComponent {
}, 5);
} else if (item.type_ === BaseModel.TYPE_RESOURCE) {
if (!(await Resource.isReady(item))) throw new Error(_('This attachment is not downloaded or not decrypted yet.'));
- const resourcePath = Resource.fullPath(item);
- logger.info(`Opening resource: ${resourcePath}`);
- await FileViewer.open(resourcePath);
+ const resourcePath = Resource.fullPath(item);
+ if (item.mime === 'image/svg+xml') {
+ void this.editDrawing(resourcePath, item);
+ } else {
+ logger.info(`Opening resource: ${resourcePath}`);
+ await FileViewer.open(resourcePath);
+ }
} else {
throw new Error(_('The Joplin mobile app does not currently support this type of link: %s', BaseModel.modelTypeToName(item.type_)));
}
@@ -655,7 +663,7 @@ class NoteScreenComponent extends BaseScreenComponent {
const done = await this.resizeImage(localFilePath, targetPath, mimeType);
if (!done) return;
} else {
- if (fileType === 'image') {
+ if (fileType === 'image' && mimeType !== 'image/svg+xml') {
dialogs.error(this, _('Unsupported image type: %s', mimeType));
return;
} else {
@@ -748,6 +756,56 @@ class NoteScreenComponent extends BaseScreenComponent {
this.setState({ showCamera: false });
}
+ private drawPicture_onPress = () => {
+ this.setState({
+ showImageEditor: true,
+ imageEditorData: '',
+ imageEditorResource: null,
+ });
+ };
+
+ private async attachDrawing(svgData: string) {
+ this.setState({ showImageEditor: false });
+
+ let resource = this.state.imageEditorResource;
+ const resourcePath = resource ? Resource.fullPath(resource) : null;
+
+ const filePath = resourcePath ?? `${Setting.value('resourceDir')}/saved-drawing.joplin.svg`;
+ await shim.fsDriver().writeFile(filePath, svgData, 'utf8');
+ console.info('Saved drawing to', filePath);
+
+ if (resource) {
+ resource = await Resource.save(resource, { isNew: false });
+ await this.refreshResource(resource);
+ this.setState({
+ imageEditorResource: null,
+ });
+ } else {
+ // Otherwise, we're creating a new file
+ await this.attachFile({
+ uri: filePath,
+ name: _('Joplin Drawing'),
+ }, 'image');
+ }
+ }
+
+ private onSaveDrawing = async (svgData: string) => {
+ await this.attachDrawing(svgData);
+ };
+
+ private onCancelDrawing = () => {
+ this.setState({ showImageEditor: false });
+ };
+
+ private async editDrawing(filePath: string, item: BaseItem) {
+ const svgData = await shim.fsDriver().readFile(filePath);
+ this.setState({
+ showImageEditor: true,
+ imageEditorData: svgData,
+ imageEditorResource: item,
+ });
+ }
+
private async attachFile_onPress() {
const response = await this.pickDocuments();
for (const asset of response) {
@@ -868,12 +926,14 @@ class NoteScreenComponent extends BaseScreenComponent {
// because that's only way to browse photos from the camera roll.
if (Platform.OS === 'ios') buttons.push({ text: _('Attach photo'), id: 'attachPhoto' });
buttons.push({ text: _('Take photo'), id: 'takePhoto' });
+ buttons.push({ text: _('Draw picture'), id: 'drawPicture' });
const buttonId = await dialogs.pop(this, _('Choose an option'), buttons);
if (buttonId === 'takePhoto') this.takePhoto_onPress();
if (buttonId === 'attachFile') void this.attachFile_onPress();
if (buttonId === 'attachPhoto') void this.attachPhoto_onPress();
+ if (buttonId === 'drawPicture') void this.drawPicture_onPress();
}
menuOptions() {
@@ -1053,6 +1113,13 @@ class NoteScreenComponent extends BaseScreenComponent {
if (this.state.showCamera) {
return ;
+ } else if (this.state.showImageEditor) {
+ return ;
}
// Currently keyword highlighting is supported only when FTS is available.
diff --git a/packages/app-mobile/package.json b/packages/app-mobile/package.json
index 8a27303dc65..130d86e6c85 100644
--- a/packages/app-mobile/package.json
+++ b/packages/app-mobile/package.json
@@ -99,6 +99,7 @@
"jest": "29.3.1",
"jest-environment-jsdom": "29.3.1",
"jetifier": "2.0.0",
+ "js-draw": "0.10.2",
"jsdom": "20.0.0",
"metro-react-native-babel-preset": "0.67.0",
"nodemon": "2.0.20",
diff --git a/packages/app-mobile/tools/buildInjectedJs.ts b/packages/app-mobile/tools/buildInjectedJs.ts
index 300303e45d3..e3d17c9bd44 100644
--- a/packages/app-mobile/tools/buildInjectedJs.ts
+++ b/packages/app-mobile/tools/buildInjectedJs.ts
@@ -186,6 +186,10 @@ const bundledFiles: BundledFile[] = [
'codeMirrorBundle',
`${mobileDir}/components/NoteEditor/CodeMirror/CodeMirror.ts`
),
+ new BundledFile(
+ 'svgEditorBundle',
+ `${mobileDir}/components/NoteEditor/ImageEditor/createJsDrawEditor.ts`
+ ),
];
export async function buildInjectedJS() {
diff --git a/packages/app-mobile/utils/shim-init-react.js b/packages/app-mobile/utils/shim-init-react.js
index ec01c041fc5..b1697865e43 100644
--- a/packages/app-mobile/utils/shim-init-react.js
+++ b/packages/app-mobile/utils/shim-init-react.js
@@ -14,6 +14,7 @@ const Resource = require('@joplin/lib/models/Resource').default;
const injectedJs = {
webviewLib: require('@joplin/lib/rnInjectedJs/webviewLib'),
codeMirrorBundle: require('../lib/rnInjectedJs/CodeMirror.bundle'),
+ svgEditorBundle: require('../lib/rnInjectedJs/createJsDrawEditor.bundle'),
};
function shimInit() {
diff --git a/packages/lib/models/Setting.ts b/packages/lib/models/Setting.ts
index 28f074efc3e..9bd535d56c7 100644
--- a/packages/lib/models/Setting.ts
+++ b/packages/lib/models/Setting.ts
@@ -1400,6 +1400,15 @@ class Setting extends BaseModel {
isGlobal: true,
},
+ 'imageeditor.jsdrawToolbar': {
+ value: '',
+ type: SettingItemType.String,
+ public: false,
+ appTypes: [AppType.Mobile],
+ label: () => '',
+ storage: SettingStorage.File,
+ },
+
'net.customCertificates': {
value: '',
type: SettingItemType.String,
diff --git a/yarn.lock b/yarn.lock
index 248ca66c8b1..5dc24b4e54e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4710,6 +4710,7 @@ __metadata:
jest-environment-jsdom: 29.3.1
jetifier: 2.0.0
joplin-rn-alarm-notification: 1.0.7
+ js-draw: 0.10.2
jsc-android: 241213.1.0
jsdom: 20.0.0
md5: 2.3.0
@@ -6175,6 +6176,13 @@ __metadata:
languageName: node
linkType: hard
+"@melloware/coloris@npm:^0.16.1":
+ version: 0.16.1
+ resolution: "@melloware/coloris@npm:0.16.1"
+ checksum: 32b468ae92bba2b470896a3c3d8db71106e555f81e996dcb54a3d55557c95b1788c0f58d4cfd963b0ead8af85469459f3fcc6d3bb104dd689bdf5203715fd06d
+ languageName: node
+ linkType: hard
+
"@mrmlnc/readdir-enhanced@npm:^2.2.1":
version: 2.2.1
resolution: "@mrmlnc/readdir-enhanced@npm:2.2.1"
@@ -10295,6 +10303,13 @@ __metadata:
languageName: node
linkType: hard
+"bezier-js@npm:^6.1.0":
+ version: 6.1.0
+ resolution: "bezier-js@npm:6.1.0"
+ checksum: 46b4133c821df152cf12f392313a94cd4a1a4649277367990970a2aefb8275999a594d35cff3bada92947f5e6fee423a54ba89857a3954fdcd8f04f0d475e488
+ languageName: node
+ linkType: hard
+
"big-integer@npm:1.6.x":
version: 1.6.51
resolution: "big-integer@npm:1.6.51"
@@ -21001,6 +21016,16 @@ __metadata:
languageName: node
linkType: hard
+"js-draw@npm:0.10.2":
+ version: 0.10.2
+ resolution: "js-draw@npm:0.10.2"
+ dependencies:
+ "@melloware/coloris": ^0.16.1
+ bezier-js: ^6.1.0
+ checksum: ac03fdcd78882845020a33a65d425e8d77fef234e633d60f56dba6a480cb263823a22b43a61ac5461534e071a5fff6af738fa6aad8b05d7aef301e6eed553c05
+ languageName: node
+ linkType: hard
+
"js-sdsl@npm:^4.1.4":
version: 4.1.5
resolution: "js-sdsl@npm:4.1.5"