Skip to content

Commit

Permalink
js-draw image editor
Browse files Browse the repository at this point in the history
  • Loading branch information
personalizedrefrigerator committed Jan 4, 2023
1 parent b2ba0ee commit 3c5c0ac
Show file tree
Hide file tree
Showing 11 changed files with 354 additions and 6 deletions.
6 changes: 6 additions & 0 deletions .eslintignore
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/app-mobile/.gitignore
Expand Up @@ -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
190 changes: 190 additions & 0 deletions 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(() => `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"/>
<style>
${css}
</style>
</head>
<body></body>
</html>
`, [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 (
<ExtendedWebView
themeId={props.themeId}
html={html}
injectedJavaScript={injectedJavaScript}
onMessage={onMessage}
onError={onError}
ref={webviewRef}
webviewInstanceId={'image-editor-js-draw'}
/>
);
};

export default ImageEditor;
@@ -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;
75 changes: 71 additions & 4 deletions packages/app-mobile/components/screens/Note.tsx
Expand Up @@ -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[] = [];
Expand All @@ -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
Expand Down Expand Up @@ -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_)));
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -1053,6 +1113,13 @@ class NoteScreenComponent extends BaseScreenComponent {

if (this.state.showCamera) {
return <CameraView themeId={this.props.themeId} style={{ flex: 1 }} onPhoto={this.cameraView_onPhoto} onCancel={this.cameraView_onCancel} />;
} else if (this.state.showImageEditor) {
return <ImageEditor
initialSVGData={this.state.imageEditorData}
themeId={this.props.themeId}
onSave={this.onSaveDrawing}
onCancel={this.onCancelDrawing}
/>;
}

// Currently keyword highlighting is supported only when FTS is available.
Expand Down

0 comments on commit 3c5c0ac

Please sign in to comment.