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"