1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-01-20 18:48:28 +02:00

Mobile: Add support for drawing pictures (#7588)

This commit is contained in:
Henry Heino 2023-10-02 07:15:51 -07:00 committed by GitHub
parent 487112fd4d
commit 849427d1bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1419 additions and 46 deletions

View File

@ -47,7 +47,7 @@ packages/app-desktop/packageInfo.js
packages/app-desktop/services/electron-context-menu.js
packages/app-desktop/vendor/lib/
packages/app-mobile/android
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.bundle.js
packages/app-mobile/components/NoteEditor/**/*.bundle.js
packages/app-mobile/ios
packages/app-mobile/lib/rnInjectedJs/
packages/app-mobile/locales
@ -413,12 +413,24 @@ packages/app-mobile/components/ExtendedWebView.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Modal.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.test.js
packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.js
packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.js
packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js
packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/autosave.js
packages/app-mobile/components/NoteEditor/ImageEditor/isEditableResource.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/applyTemplateToEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.test.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/startAutosaveLoop.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/types.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/watchEditorForTemplateChanges.js
packages/app-mobile/components/NoteEditor/ImageEditor/promptRestoreAutosave.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.js

12
.gitignore vendored
View File

@ -399,12 +399,24 @@ packages/app-mobile/components/ExtendedWebView.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Modal.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.test.js
packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.js
packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.js
packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js
packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/autosave.js
packages/app-mobile/components/NoteEditor/ImageEditor/isEditableResource.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/applyTemplateToEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.test.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/startAutosaveLoop.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/types.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/watchEditorForTemplateChanges.js
packages/app-mobile/components/NoteEditor/ImageEditor/promptRestoreAutosave.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.js

View File

@ -66,9 +66,10 @@ yarn-error.log
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.js.md5
components/NoteEditor/**/*.bundle.min.js
components/NoteEditor/**/*.bundle.js.LICENSE.txt
utils/fs-driver-android.js
android/app/build-*

View File

@ -1,7 +1,7 @@
import { useRef, useCallback } from 'react';
import useSource from './hooks/useSource';
import useOnMessage from './hooks/useOnMessage';
import useOnMessage, { HandleMessageCallback, OnMarkForDownloadCallback } from './hooks/useOnMessage';
import useOnResourceLongPress from './hooks/useOnResourceLongPress';
const React = require('react');
@ -19,14 +19,11 @@ interface Props {
noteResources: any;
paddingBottom: number;
noteHash: string;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onJoplinLinkClick: Function;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onCheckboxChange?: Function;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onMarkForDownload?: Function;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onLoadEnd?: Function;
onJoplinLinkClick: HandleMessageCallback;
onCheckboxChange?: HandleMessageCallback;
onRequestEditResource?: HandleMessageCallback;
onMarkForDownload?: OnMarkForDownloadCallback;
onLoadEnd?: ()=> void;
}
const webViewStyle = {
@ -47,16 +44,22 @@ export default function NoteBodyViewer(props: Props) {
);
const onResourceLongPress = useOnResourceLongPress(
props.onJoplinLinkClick,
{
onJoplinLinkClick: props.onJoplinLinkClick,
onRequestEditResource: props.onRequestEditResource,
},
dialogBoxRef,
);
const onMessage = useOnMessage(
props.onCheckboxChange,
props.noteBody,
props.onMarkForDownload,
props.onJoplinLinkClick,
onResourceLongPress,
{
onCheckboxChange: props.onCheckboxChange,
onMarkForDownload: props.onMarkForDownload,
onJoplinLinkClick: props.onJoplinLinkClick,
onRequestEditResource: props.onRequestEditResource,
onResourceLongPress,
},
);
const onLoadEnd = useCallback(() => {

View File

@ -0,0 +1,86 @@
/**
* @jest-environment jsdom
*/
import { writeFileSync } from 'fs-extra';
import { join } from 'path';
import Setting from '@joplin/lib/models/Setting';
// Mock react-native-vector-icons -- it uses ESM imports, which, by default, are not
// supported by jest.
jest.doMock('react-native-vector-icons/Ionicons', () => {
return {
default: {
getImageSourceSync: () => {
// Create an empty file that can be read/used as an image resource.
const iconPath = join(Setting.value('cacheDir'), 'test-icon.png');
writeFileSync(iconPath, '', 'utf-8');
return { uri: iconPath };
},
},
};
});
import lightTheme from '@joplin/lib/themes/light';
import { editPopupClass, getEditPopupSource } from './useEditPopup';
import { describe, it, expect, beforeAll, jest } from '@jest/globals';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
const createEditPopup = (target: HTMLElement) => {
const { createEditPopupSyntax } = getEditPopupSource(lightTheme);
eval(`(${createEditPopupSyntax})`)(target, 'someresourceid', '() => {}');
};
const destroyEditPopup = () => {
const { destroyEditPopupSyntax } = getEditPopupSource(lightTheme);
eval(`(${destroyEditPopupSyntax})`)();
};
describe('useEditPopup', () => {
beforeAll(async () => {
// useEditPopup relies on the resourceDir setting, which is set by
// switchClient.
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
});
it('should attach an edit popup to an image', () => {
const container = document.createElement('div');
const targetImage = document.createElement('img');
container.appendChild(targetImage);
createEditPopup(targetImage);
// Popup should be present in the document
expect(container.querySelector(`.${editPopupClass}`)).not.toBeNull();
// Destroy the edit popup
jest.useFakeTimers();
destroyEditPopup();
// Give time for the popup's fade out animation to run.
jest.advanceTimersByTime(1000 * 10);
// Popup should be destroyed.
expect(container.querySelector(`.${editPopupClass}`)).toBeNull();
targetImage.remove();
});
it('should auto-remove the edit popup after a delay', () => {
jest.useFakeTimers();
const container = document.createElement('div');
const targetImage = document.createElement('img');
container.appendChild(targetImage);
jest.useFakeTimers();
createEditPopup(targetImage);
expect(container.querySelector(`.${editPopupClass}`)).not.toBeNull();
jest.advanceTimersByTime(1000 * 20); // ms
expect(container.querySelector(`.${editPopupClass}`)).toBeNull();
});
});

View File

@ -0,0 +1,145 @@
import { _ } from '@joplin/lib/locale';
import Setting from '@joplin/lib/models/Setting';
import { themeStyle } from '@joplin/lib/theme';
import { Theme } from '@joplin/lib/themes/type';
import { useMemo } from 'react';
import { extname } from 'path';
import shim from '@joplin/lib/shim';
const Icon = require('react-native-vector-icons/Ionicons').default;
export const editPopupClass = 'joplin-editPopup';
const getEditIconSrc = (theme: Theme) => {
const iconUri = Icon.getImageSourceSync('pencil', 20, theme.color2).uri;
// Copy to a location that can be read within a WebView
// (necessary on iOS)
const destPath = `${Setting.value('resourceDir')}/edit-icon${extname(iconUri)}`;
// Copy in the background -- the edit icon popover script doesn't need the
// icon immediately.
void (async () => {
await shim.fsDriver().copy(iconUri, destPath);
})();
return destPath;
};
// Creates JavaScript/CSS that can be used to create an "Edit" button.
// Exported to facilitate testing.
export const getEditPopupSource = (theme: Theme) => {
const fadeOutDelay = 400;
const editPopupDestroyDelay = 5000;
const editPopupCss = `
@keyframes fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes fade-out {
0% { opacity: 1; }
100% { opacity: 0; }
}
.${editPopupClass} {
display: inline-block;
position: relative;
/* Don't take up any space in the line, overlay the button */
width: 0;
height: 0;
overflow: visible;
--edit-popup-width: 40px;
--edit-popup-padding: 10px;
/* Shift the popup such that it overlaps with the previous element. */
left: calc(0px - var(--edit-popup-width));
/* Match the top of the image */
vertical-align: top;
}
.${editPopupClass} > button {
padding: var(--edit-popup-padding);
width: var(--edit-popup-width);
animation: fade-in 0.4s ease;
background-color: ${theme.backgroundColor2};
color: ${theme.color2};
border: none;
}
.${editPopupClass} img {
/* Make the image take up as much space as possible (minus padding) */
width: calc(var(--edit-popup-width) - var(--edit-popup-padding));
}
.${editPopupClass}.fadeOut {
animation: fade-out ${fadeOutDelay}ms ease;
}
`;
const destroyEditPopupSyntax = `() => {
if (!window.editPopup) {
return;
}
const popup = editPopup;
popup.classList.add('fadeOut');
window.editPopup = null;
setTimeout(() => {
popup.remove();
}, ${fadeOutDelay});
}`;
const createEditPopupSyntax = `(parent, resourceId, onclick) => {
if (window.editPopupTimeout) {
clearTimeout(window.editPopupTimeout);
window.editPopupTimeout = undefined;
}
window.editPopupTimeout = setTimeout(${destroyEditPopupSyntax}, ${editPopupDestroyDelay});
if (window.lastEditPopupTarget !== parent) {
(${destroyEditPopupSyntax})();
} else if (window.editPopup) {
return;
}
window.editPopup = document.createElement('div');
const popupButton = document.createElement('button');
const popupIcon = new Image();
popupIcon.alt = ${JSON.stringify(_('Edit'))};
popupIcon.title = popupIcon.alt;
popupIcon.src = ${JSON.stringify(getEditIconSrc(theme))};
popupButton.appendChild(popupIcon);
popupButton.onclick = onclick;
editPopup.appendChild(popupButton);
editPopup.classList.add(${JSON.stringify(editPopupClass)});
parent.insertAdjacentElement('afterEnd', editPopup);
// Ensure that the edit popup is focused immediately by screen
// readers.
editPopup.focus();
window.lastEditPopupTarget = parent;
}`;
return { createEditPopupSyntax, destroyEditPopupSyntax, editPopupCss };
};
const useEditPopup = (themeId: number) => {
return useMemo(() => {
return getEditPopupSource(themeStyle(themeId));
}, [themeId]);
};
export default useEditPopup;

View File

@ -1,8 +1,31 @@
import { useCallback } from 'react';
import shared from '@joplin/lib/components/shared/note-screen-shared';
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
export default function useOnMessage(onCheckboxChange: Function, noteBody: string, onMarkForDownload: Function, onJoplinLinkClick: Function, onResourceLongPress: Function) {
export type HandleMessageCallback = (message: string)=> void;
export type OnMarkForDownloadCallback = (resource: { resourceId: string })=> void;
interface MessageCallbacks {
onMarkForDownload?: OnMarkForDownloadCallback;
onJoplinLinkClick: HandleMessageCallback;
onResourceLongPress: HandleMessageCallback;
onRequestEditResource?: HandleMessageCallback;
onCheckboxChange: HandleMessageCallback;
}
export default function useOnMessage(
noteBody: string,
callbacks: MessageCallbacks,
) {
// Dectructure callbacks. Because we have that ({ a: 1 }) !== ({ a: 1 }),
// we can expect the `callbacks` variable from the last time useOnMessage was called to
// not equal the current` callbacks` variable, even if the callbacks themselves are the
// same.
//
// Thus, useCallback should depend on each callback individually.
const {
onMarkForDownload, onResourceLongPress, onCheckboxChange, onRequestEditResource, onJoplinLinkClick,
} = callbacks;
return useCallback((event: any) => {
// 2021-05-19: Historically this was unescaped twice as it was
// apparently needed after an upgrade to RN 58 (or 59). However this is
@ -17,13 +40,15 @@ export default function useOnMessage(onCheckboxChange: Function, noteBody: strin
if (msg.indexOf('checkboxclick:') === 0) {
const newBody = shared.toggleCheckbox(msg, noteBody);
if (onCheckboxChange) onCheckboxChange(newBody);
onCheckboxChange?.(newBody);
} else if (msg.indexOf('markForDownload:') === 0) {
const splittedMsg = msg.split(':');
const resourceId = splittedMsg[1];
if (onMarkForDownload) onMarkForDownload({ resourceId: resourceId });
onMarkForDownload?.({ resourceId: resourceId });
} else if (msg.startsWith('longclick:')) {
onResourceLongPress(msg);
} else if (msg.startsWith('edit:')) {
onRequestEditResource?.(msg);
} else if (msg.startsWith('joplin:')) {
onJoplinLinkClick(msg);
} else if (msg.startsWith('error:')) {
@ -31,5 +56,12 @@ export default function useOnMessage(onCheckboxChange: Function, noteBody: strin
} else {
onJoplinLinkClick(msg);
}
}, [onCheckboxChange, noteBody, onMarkForDownload, onJoplinLinkClick, onResourceLongPress]);
}, [
noteBody,
onCheckboxChange,
onMarkForDownload,
onJoplinLinkClick,
onResourceLongPress,
onRequestEditResource,
]);
}

View File

@ -6,20 +6,40 @@ import { reg } from '@joplin/lib/registry';
const { dialogs } = require('../../../utils/dialogs.js');
import Resource from '@joplin/lib/models/Resource';
import { copyToCache } from '../../../utils/ShareUtils';
import isEditableResource from '../../NoteEditor/ImageEditor/isEditableResource';
const Share = require('react-native-share').default;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
export default function useOnResourceLongPress(onJoplinLinkClick: Function, dialogBoxRef: any) {
interface Callbacks {
onJoplinLinkClick: (link: string)=> void;
onRequestEditResource: (message: string)=> void;
}
export default function useOnResourceLongPress(callbacks: Callbacks, dialogBoxRef: any) {
const { onJoplinLinkClick, onRequestEditResource } = callbacks;
return useCallback(async (msg: string) => {
try {
const resourceId = msg.split(':')[1];
const resource = await Resource.load(resourceId);
const name = resource.title ? resource.title : resource.file_name;
const action = await dialogs.pop({ dialogbox: dialogBoxRef.current }, name, [
{ text: _('Open'), id: 'open' },
{ text: _('Share'), id: 'share' },
]);
// Handle the case where it's a long press on a link with no resource
if (!resource) {
reg.logger().warn(`Long-press: Resource with ID ${resourceId} does not exist (may be a note).`);
return;
}
const name = resource.title ? resource.title : resource.file_name;
const mime: string|undefined = resource.mime;
const actions = [];
actions.push({ text: _('Open'), id: 'open' });
if (mime && isEditableResource(mime)) {
actions.push({ text: _('Edit'), id: 'edit' });
}
actions.push({ text: _('Share'), id: 'share' });
const action = await dialogs.pop({ dialogbox: dialogBoxRef.current }, name, actions);
if (action === 'open') {
onJoplinLinkClick(`joplin://${resourceId}`);
@ -32,11 +52,12 @@ export default function useOnResourceLongPress(onJoplinLinkClick: Function, dial
url: `file://${fileToShare}`,
failOnCancel: false,
});
} else if (action === 'edit') {
onRequestEditResource(`edit:${resourceId}`);
}
} catch (e) {
reg.logger().error('Could not handle link long press', e);
ToastAndroid.show('An error occurred, check log for details', ToastAndroid.SHORT);
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [onJoplinLinkClick]);
}, [onJoplinLinkClick, onRequestEditResource, dialogBoxRef]);
}

View File

@ -3,6 +3,7 @@ import shim from '@joplin/lib/shim';
import Setting from '@joplin/lib/models/Setting';
const { themeStyle } = require('../../global-style.js');
import markupLanguageUtils from '@joplin/lib/markupLanguageUtils';
import useEditPopup from './useEditPopup';
import Logger from '@joplin/utils/Logger';
const { assetsToHeaders } = require('@joplin/renderer');
@ -90,6 +91,8 @@ export default function useSource(noteBody: string, noteMarkupLanguage: number,
const onlyNoteBodyHasChanged = Object.keys(changedDeps).length === 1 && changedDeps[0];
const onlyCheckboxesHaveChanged = previousDeps[0] && changedDeps[0] && onlyCheckboxHasChangedHack(previousDeps[0], noteBody);
const { createEditPopupSyntax, destroyEditPopupSyntax, editPopupCss } = useEditPopup(themeId);
useEffect(() => {
if (onlyNoteBodyHasChanged && onlyCheckboxesHaveChanged) {
logger.info('Only a checkbox has changed - not updating HTML');
@ -112,6 +115,11 @@ export default function useSource(noteBody: string, noteMarkupLanguage: number,
codeTheme: theme.codeThemeCss,
postMessageSyntax: 'window.joplinPostMessage_',
enableLongPress: true,
// Show an 'edit' popup over SVG images
editPopupFiletypes: ['image/svg+xml'],
createEditPopupSyntax,
destroyEditPopupSyntax,
};
// Whenever a resource state changes, for example when it goes from "not downloaded" to "downloaded", the "noteResources"
@ -209,7 +217,9 @@ export default function useSource(noteBody: string, noteMarkupLanguage: number,
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
${shim.mobilePlatform() === 'ios' ? `${iOSSpecificCss}\n${defaultCss}` : defaultCss}
${defaultCss}
${shim.mobilePlatform() === 'ios' ? iOSSpecificCss : ''}
${editPopupCss}
</style>
${assetsToHeaders(result.pluginAssets, { asHtml: true })}
</head>

View File

@ -0,0 +1,302 @@
const React = require('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 { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, BackHandler } from 'react-native';
import { WebViewMessageEvent } from 'react-native-webview';
import ExtendedWebView, { WebViewControl } from '../../ExtendedWebView';
import { clearAutosave, writeAutosave } from './autosave';
import { LocalizedStrings } from './js-draw/types';
const logger = Logger.create('ImageEditor');
type OnSaveCallback = (svgData: string)=> void;
type OnCancelCallback = ()=> void;
type LoadInitialSVGCallback = ()=> Promise<string>;
interface Props {
themeId: number;
loadInitialSVGData: LoadInitialSVGCallback|null;
onSave: OnSaveCallback;
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;
}
/* 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 beause 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: MutableRefObject<WebViewControl>|null = useRef(null);
const [imageChanged, setImageChanged] = useState(false);
const onRequestCloseEditor = useCallback((promptIfUnsaved: boolean) => {
const discardChangesAndClose = async () => {
await clearAutosave();
props.onExit();
};
if (!imageChanged || !promptIfUnsaved) {
void discardChangesAndClose();
return true;
}
Alert.alert(
_('Save changes?'), _('This drawing may have unsaved changes.'), [
{
text: _('Discard changes'),
onPress: discardChangesAndClose,
style: 'destructive',
},
{
text: _('Save changes'),
onPress: () => {
// saveDrawing calls props.onSave(...) which may close the
// editor.
webviewRef.current.injectJS('window.editorControl.saveThenExit()');
},
},
],
);
return true;
}, [webviewRef, props.onExit, imageChanged]);
useEffect(() => {
const hardwareBackPressListener = () => {
onRequestCloseEditor(true);
return true;
};
BackHandler.addEventListener('hardwareBackPress', hardwareBackPressListener);
return () => {
BackHandler.removeEventListener('hardwareBackPress', hardwareBackPressListener);
};
}, [onRequestCloseEditor]);
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]);
// 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 injectedJavaScript = useMemo(() => `
window.onerror = (message, source, lineno) => {
window.ReactNativeWebView.postMessage(
"error: " + message + " in file://" + source + ", line " + lineno
);
};
const setImageHasChanges = (hasChanges) => {
window.ReactNativeWebView.postMessage(
JSON.stringify({
action: 'set-image-has-changes',
data: hasChanges,
}),
);
};
window.updateEditorTemplate = (templateData) => {
window.ReactNativeWebView.postMessage(
JSON.stringify({
action: 'set-image-template-data',
data: templateData,
}),
);
};
const notifyReadyToLoadSVG = () => {
window.ReactNativeWebView.postMessage(
JSON.stringify({
action: 'ready-to-load-data',
})
);
};
const saveDrawing = async (drawing, isAutosave) => {
window.ReactNativeWebView.postMessage(
JSON.stringify({
action: isAutosave ? 'autosave' : 'save',
data: drawing.outerHTML,
}),
);
};
const closeEditor = (promptIfUnsaved) => {
window.ReactNativeWebView.postMessage(JSON.stringify({
action: 'close',
promptIfUnsaved,
}));
};
try {
if (window.editorControl === undefined) {
${shim.injectedJs('svgEditorBundle')}
window.editorControl = svgEditorBundle.createJsDrawEditor(
{
saveDrawing,
closeEditor,
updateEditorTemplate,
setImageHasChanges,
},
${JSON.stringify(Setting.value('imageeditor.jsdrawToolbar'))},
${JSON.stringify(Setting.value('locale'))},
${JSON.stringify(localizedStrings)},
);
// Start loading the SVG file (if present) after loading the editor.
// This shows the user that progress is being made (loading large SVGs
// from disk into memory can take several seconds).
notifyReadyToLoadSVG();
}
} catch(e) {
window.ReactNativeWebView.postMessage(
'error: ' + e.message + ': ' + JSON.stringify(e)
);
}
true;
`, [localizedStrings]);
useEffect(() => {
webviewRef.current?.injectJS(`
if (window.editorControl) {
window.editorControl.onThemeUpdate();
}
`);
}, [editorTheme]);
const onReadyToLoadData = useCallback(async () => {
const initialSVGData = await props.loadInitialSVGData?.() ?? '';
// 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 initialSVGData = ${JSON.stringify(initialSVGData)};
const initialTemplateData = ${JSON.stringify(Setting.value('imageeditor.imageTemplate'))};
editorControl.loadImageOrTemplate(initialSVGData, initialTemplateData);
}
})();`);
}, [webviewRef, props.loadInitialSVGData]);
const onMessage = useCallback(async (event: WebViewMessageEvent) => {
const data = event.nativeEvent.data;
if (data.startsWith('error:')) {
logger.error('ImageEditor:', data);
return;
}
const json = JSON.parse(data);
if (json.action === 'save') {
await clearAutosave();
props.onSave(json.data);
} else if (json.action === 'autosave') {
await writeAutosave(json.data);
} else if (json.action === 'save-toolbar') {
Setting.setValue('imageeditor.jsdrawToolbar', json.data);
} else if (json.action === 'close') {
onRequestCloseEditor(json.promptIfUnsaved);
} else if (json.action === 'ready-to-load-data') {
void onReadyToLoadData();
} else if (json.action === 'set-image-has-changes') {
setImageChanged(json.data);
} else if (json.action === 'set-image-template-data') {
Setting.setValue('imageeditor.imageTemplate', json.data);
} else {
logger.error('Unknown action,', json.action);
}
}, [props.onSave, onRequestCloseEditor, onReadyToLoadData]);
const onError = useCallback((event: any) => {
logger.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;

View File

@ -0,0 +1,26 @@
import Logger from '@joplin/utils/Logger';
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
export const autosaveFilename = 'autosaved-drawing.joplin.svg';
const logger = Logger.create('ImageEditor/autosave');
export const getAutosaveFilepath = () => {
return `${Setting.value('resourceDir')}/${autosaveFilename}`;
};
export const writeAutosave = async (data: string) => {
const filePath = getAutosaveFilepath();
logger.info(`Auto-saving drawing to ${JSON.stringify(filePath)}`);
await shim.fsDriver().writeFile(filePath, data, 'utf8');
};
export const readAutosave = async (): Promise<string|null> => {
return await shim.fsDriver().readFile(getAutosaveFilepath());
};
export const clearAutosave = async () => {
await shim.fsDriver().remove(getAutosaveFilepath());
};

View File

@ -0,0 +1,6 @@
const isEditableResource = (resourceMime: string) => {
return resourceMime === 'image/svg+xml';
};
export default isEditableResource;

View File

@ -0,0 +1,67 @@
import { AbstractComponent, Editor, BackgroundComponentBackgroundType, Erase, Vec2, Rect2 } from 'js-draw';
const applyTemplateToEditor = async (editor: Editor, templateData: string) => {
let backgroundComponent: AbstractComponent|null = null;
let imageSize = editor.getImportExportRect().size;
let autoresize = true;
try {
const templateJSON = JSON.parse(templateData);
const isEmptyTemplate =
!('imageSize' in templateJSON) && !('backgroundData' in templateJSON);
// If the template is empty, add a default background
if (isEmptyTemplate) {
templateJSON.backgroundData = {
'name': 'image-background',
'zIndex': 0,
'data': {
'mainColor': '#ffffff',
'backgroundType': BackgroundComponentBackgroundType.SolidColor,
},
};
}
if ('backgroundData' in templateJSON) {
backgroundComponent = AbstractComponent.deserialize(
templateJSON['backgroundData'],
);
}
if ('imageSize' in templateJSON) {
imageSize = Vec2.ofXY(templateJSON.imageSize);
}
if ('autoresize' in templateJSON) {
autoresize = !!templateJSON.autoresize;
}
} catch (e) {
console.error('Warning: Invalid image template data: ', e);
}
if (backgroundComponent) {
// Remove the old background (if any)
const previousBackground = editor.image.getBackgroundComponents();
if (previousBackground.length > 0) {
const removeBackgroundCommand = new Erase(previousBackground);
await editor.dispatchNoAnnounce(removeBackgroundCommand, false);
}
// Add the new background
const addBackgroundCommand = editor.image.addElement(backgroundComponent);
await editor.dispatchNoAnnounce(addBackgroundCommand, false);
}
// Set the image size
const imageSizeCommand = editor.setImportExportRect(new Rect2(0, 0, imageSize.x, imageSize.y));
await editor.dispatchNoAnnounce(imageSizeCommand, false);
// Enable/disable autoresize
await editor.dispatchNoAnnounce(editor.image.setAutoresizeEnabled(autoresize), false);
// And zoom to the template (false = don't make undoable)
await editor.dispatchNoAnnounce(editor.viewport.zoomTo(editor.getImportExportRect()), false);
};
export default applyTemplateToEditor;

View File

@ -0,0 +1,109 @@
/** @jest-environment jsdom */
// Hide warnings from js-draw.
// jsdom doesn't support ResizeObserver and HTMLCanvasElement.getContext.
HTMLCanvasElement.prototype.getContext = () => null;
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 = {
saveDrawing: () => {},
closeEditor: ()=> {},
setImageHasChanges: ()=> {},
updateEditorTemplate: ()=> {},
...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({
saveDrawing: (_drawing: SVGElement, 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);
});
});

View File

@ -0,0 +1,168 @@
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, LocalizedStrings } from './types';
import startAutosaveLoop from './startAutosaveLoop';
declare namespace ReactNativeWebView {
const postMessage: (data: any)=> void;
}
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);
}
}
};
const listenToolbarState = (editor: Editor, toolbar: AbstractToolbar) => {
editor.notifier.on(EditorEventType.ToolUpdated, () => {
const state = toolbar.serializeState();
ReactNativeWebView.postMessage(
JSON.stringify({
action: 'save-toolbar',
data: state,
}),
);
});
};
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(),
...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 saveNow = () => {
callbacks.saveDrawing(editor.toSVG({
// Grow small images to this minimum size
minDimension: 50,
}), false);
// The image is now up-to-date with the resource
setImageHasChanges(false);
};
saveButton = toolbar.addSaveButton(saveNow);
// Load and save toolbar-realated state (e.g. pen sizes/colors).
restoreToolbarState(toolbar, initialToolbarState);
listenToolbarState(editor, toolbar);
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 editorControl = {
editor,
loadImageOrTemplate: async (svgData: string|undefined, templateData: string) => {
// loadFromSVG shows its own loading message. Hide the original.
editor.hideLoadingWarning();
if (svgData && svgData.length > 0) {
await editor.loadFromSVG(svgData);
} else {
await applyTemplateToEditor(editor, templateData);
// The editor expects to be saved initially (without
// unsaved changes). Save now.
saveNow();
}
// We can now edit and save safely (without data loss).
editor.setReadOnly(false);
void startAutosaveLoop(editor, callbacks.saveDrawing);
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 () => {
saveNow();
// Don't show a confirmation dialog -- it's possible that
// the code outside of the WebView still thinks changes haven't
// been saved:
const showConfirmation = false;
callbacks.closeEditor(showConfirmation);
},
};
editorControl.onThemeUpdate();
return editorControl;
};
export default createJsDrawEditor;

View File

@ -0,0 +1,26 @@
import { Editor } from 'js-draw';
import { SaveDrawingCallback } from './types';
const startAutosaveLoop = async (
editor: Editor,
saveDrawing: SaveDrawingCallback,
) => {
// Autosave every two minutes.
const delayTime = 1000 * 60 * 2; // ms
const createAutosave = async () => {
const savedSVG = await editor.toSVGAsync();
saveDrawing(savedSVG, true);
};
while (true) {
await (new Promise<void>(resolve => {
setTimeout(() => resolve(), delayTime);
}));
await createAutosave();
}
};
export default startAutosaveLoop;

View File

@ -0,0 +1,20 @@
export type SaveDrawingCallback = (svgElement: SVGElement, isAutosave: boolean)=> void;
export type UpdateEditorTemplateCallback = (newTemplate: string)=> void;
export interface ImageEditorCallbacks {
saveDrawing: SaveDrawingCallback;
updateEditorTemplate: UpdateEditorTemplateCallback;
closeEditor: (promptIfUnsaved: boolean)=> void;
setImageHasChanges: (hasChanges: boolean)=> void;
}
// Overrides translations in js-draw -- as of the time of this writing,
// Joplin has many common strings localized better than js-draw.
export interface LocalizedStrings {
save: string;
close: string;
undo: string;
redo: string;
}

View File

@ -0,0 +1,61 @@
import { Editor, BackgroundComponent, EditorEventType, Vec2 } from 'js-draw';
const watchEditorForTemplateChanges = (
editor: Editor, initialTemplate: string, updateTemplate: (templateData: string)=> void,
) => {
const computeTemplate = (): string => {
let backgroundSize: Vec2|null = null;
// Only store the background size if the size isn't determined
// by the editor content. In this case, the background always
// appears to be full screen.
if (!editor.image.getAutoresizeEnabled()) {
backgroundSize = editor.getImportExportRect().size;
// Constrain the size: Don't allow an extremely small or extremely large tempalte.
// 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;

View File

@ -0,0 +1,37 @@
import { _ } from '@joplin/lib/locale';
import shim from '@joplin/lib/shim';
import { Alert } from 'react-native';
import { clearAutosave, getAutosaveFilepath, readAutosave } from './autosave';
export type RestoreAutosaveCallback = (data: string)=> void;
const promptRestoreAutosave = async (onRestoreAutosave: RestoreAutosaveCallback) => {
const autosavePath = getAutosaveFilepath();
if (await shim.fsDriver().exists(autosavePath)) {
const title: string|null = null;
const message = _(
'An autosaved drawing was found. Attach a copy of it to the note?',
);
Alert.alert(title, message, [
{
text: _('Discard'),
onPress: async () => {
await clearAutosave();
},
},
{
text: _('Attach'),
onPress: async () => {
const autosaveData = await readAutosave();
await clearAutosave();
onRestoreAutosave(autosaveData);
},
},
]);
}
};
export default promptRestoreAutosave;

View File

@ -42,8 +42,11 @@ import { ImagePickerResponse, launchImageLibrary } from 'react-native-image-pick
import SelectDateTimeDialog from '../SelectDateTimeDialog';
import ShareExtension from '../../utils/ShareExtension.js';
import CameraView from '../CameraView';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
import Logger from '@joplin/utils/Logger';
import ImageEditor from '../NoteEditor/ImageEditor/ImageEditor';
import promptRestoreAutosave from '../NoteEditor/ImageEditor/promptRestoreAutosave';
import isEditableResource from '../NoteEditor/ImageEditor/isEditableResource';
import VoiceTypingDialog from '../voiceTyping/VoiceTypingDialog';
import { voskEnabled } from '../../services/voiceTyping/vosk';
import { isSupportedLanguage } from '../../services/voiceTyping/vosk.android';
@ -76,6 +79,9 @@ class NoteScreenComponent extends BaseScreenComponent {
noteTagDialogShown: false,
fromShare: false,
showCamera: false,
showImageEditor: false,
loadImageEditorData: null,
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
@ -195,8 +201,8 @@ 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);
const resourcePath = Resource.fullPath(item);
logger.info(`Opening resource: ${resourcePath}`);
await FileViewer.open(resourcePath);
} else {
@ -451,7 +457,7 @@ class NoteScreenComponent extends BaseScreenComponent {
void ResourceFetcher.instance().markForDownload(event.resourceId);
}
public componentDidUpdate(prevProps: any) {
public componentDidUpdate(prevProps: any, prevState: any) {
if (this.doFocusUpdate_) {
this.doFocusUpdate_ = false;
this.focusUpdate();
@ -463,6 +469,22 @@ class NoteScreenComponent extends BaseScreenComponent {
options: this.sideMenuOptions(),
});
}
if (prevState.isLoading !== this.state.isLoading && !this.state.isLoading) {
// If there's autosave data, prompt the user to restore it.
void promptRestoreAutosave((drawingData: string) => {
void this.attachNewDrawing(drawingData);
});
}
// Disable opening/closing the side menu with touch gestures
// when the image editor is open.
if (prevState.showImageEditor !== this.state.showImageEditor) {
this.props.dispatch({
type: 'SET_SIDE_MENU_TOUCH_GESTURES_DISABLED',
disableSideMenuGestures: this.state.showImageEditor,
});
}
}
public componentWillUnmount() {
@ -632,10 +654,10 @@ class NoteScreenComponent extends BaseScreenComponent {
return await saveOriginalImage();
}
public async attachFile(pickerResponse: any, fileType: string) {
public async attachFile(pickerResponse: any, fileType: string): Promise<ResourceEntity|null> {
if (!pickerResponse) {
// User has cancelled
return;
return null;
}
const localFilePath = Platform.select({
@ -662,7 +684,7 @@ class NoteScreenComponent extends BaseScreenComponent {
reg.logger().info(`Got file: ${localFilePath}`);
reg.logger().info(`Got type: ${mimeType}`);
let resource = Resource.new();
let resource: ResourceEntity = Resource.new();
resource.id = uuid.create();
resource.mime = mimeType;
resource.title = pickerResponse.name ? pickerResponse.name : '';
@ -675,11 +697,11 @@ class NoteScreenComponent extends BaseScreenComponent {
try {
if (mimeType === 'image/jpeg' || mimeType === 'image/jpg' || mimeType === 'image/png') {
const done = await this.resizeImage(localFilePath, targetPath, mimeType);
if (!done) return;
if (!done) return null;
} else {
if (fileType === 'image') {
if (fileType === 'image' && mimeType !== 'image/svg+xml') {
dialogs.error(this, _('Unsupported image type: %s', mimeType));
return;
return null;
} else {
await shim.fsDriver().copy(localFilePath, targetPath);
const stat = await shim.fsDriver().stat(targetPath);
@ -693,7 +715,7 @@ class NoteScreenComponent extends BaseScreenComponent {
} catch (error) {
reg.logger().warn('Could not attach file:', error);
await dialogs.error(this, error.message);
return;
return null;
}
const itDoes = await shim.fsDriver().waitTillExists(targetPath);
@ -735,6 +757,8 @@ class NoteScreenComponent extends BaseScreenComponent {
this.refreshResource(resource, newNote.body);
this.scheduleSave();
return resource;
}
private async attachPhoto_onPress() {
@ -776,6 +800,82 @@ class NoteScreenComponent extends BaseScreenComponent {
this.setState({ showCamera: false });
}
private async attachNewDrawing(svgData: string) {
const filePath = `${Setting.value('resourceDir')}/saved-drawing.joplin.svg`;
await shim.fsDriver().writeFile(filePath, svgData, 'utf8');
logger.info('Saved new drawing to', filePath);
return await this.attachFile({
uri: filePath,
name: _('Drawing'),
}, 'image');
}
private drawPicture_onPress = async () => {
// Create a new empty drawing and attach it now.
const resource = await this.attachNewDrawing('');
this.setState({
showImageEditor: true,
loadImageEditorData: null,
imageEditorResource: resource,
});
};
private async updateDrawing(svgData: string) {
let resource: ResourceEntity|null = this.state.imageEditorResource;
if (!resource) {
throw new Error('No resource is loaded in the editor');
}
const resourcePath = Resource.fullPath(resource);
const filePath = resourcePath;
await shim.fsDriver().writeFile(filePath, svgData, 'utf8');
logger.info('Saved drawing to', filePath);
resource = await Resource.save(resource, { isNew: false });
await this.refreshResource(resource);
}
private onSaveDrawing = async (svgData: string) => {
await this.updateDrawing(svgData);
};
private onCloseDrawing = () => {
this.setState({ showImageEditor: false });
};
private async editDrawing(item: BaseItem) {
const filePath = Resource.fullPath(item);
this.setState({
showImageEditor: true,
loadImageEditorData: async () => {
return await shim.fsDriver().readFile(filePath);
},
imageEditorResource: item,
});
}
private onEditResource = async (message: string) => {
const messageData = /^edit:(.*)$/.exec(message);
if (!messageData) {
throw new Error('onEditResource: Error: Invalid message');
}
const resourceId = messageData[1];
const resource = await BaseItem.loadItemById(resourceId);
await Resource.requireIsReady(resource);
if (isEditableResource(resource.mime)) {
await this.editDrawing(resource);
} else {
throw new Error(_('Unable to edit resource of type %s', resource.mime));
}
};
private async attachFile_onPress() {
const response = await this.pickDocuments();
for (const asset of response) {
@ -1007,6 +1107,12 @@ class NoteScreenComponent extends BaseScreenComponent {
});
}
output.push({
title: _('Draw picture'),
onPress: () => this.drawPicture_onPress(),
disabled: readOnly,
});
if (isTodo) {
output.push({
title: _('Set alarm'),
@ -1194,6 +1300,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
loadInitialSVGData={this.state.loadImageEditorData}
themeId={this.props.themeId}
onSave={this.onSaveDrawing}
onExit={this.onCloseDrawing}
/>;
}
// Currently keyword highlighting is supported only when FTS is available.
@ -1219,6 +1332,7 @@ class NoteScreenComponent extends BaseScreenComponent {
noteHash={this.props.noteHash}
onCheckboxChange={this.onBodyViewerCheckboxChange}
onMarkForDownload={this.onMarkForDownload}
onRequestEditResource={this.onEditResource}
onLoadEnd={this.onBodyViewerLoadEnd}
/>
);

View File

@ -20,7 +20,7 @@ module.exports = {
// Do transform most packages in node_modules (transformations correct unrecognized
// import syntax)
transformIgnorePatterns: ['<rootDir>/node_modules/jest'],
transformIgnorePatterns: ['<rootDir>/node_modules/jest', '<rootDir>/node_modules/js-draw'],
slowTestThreshold: 40,
};

View File

@ -88,6 +88,7 @@
"@babel/preset-env": "7.20.2",
"@babel/runtime": "7.20.0",
"@joplin/tools": "~2.13",
"@js-draw/material-icons": "1.5.0",
"@lezer/highlight": "1.1.4",
"@testing-library/jest-native": "5.4.3",
"@testing-library/react-native": "12.2.2",
@ -106,6 +107,7 @@
"jest": "29.6.4",
"jest-environment-jsdom": "29.6.4",
"jetifier": "2.0.0",
"js-draw": "1.5.0",
"jsdom": "22.1.0",
"md5-file": "5.0.0",
"metro-react-native-babel-preset": "0.73.9",

View File

@ -241,7 +241,9 @@ const appDefaultState: AppState = { ...defaultState, sideMenuOpenPercent: 0,
route: DEFAULT_ROUTE,
noteSelectionEnabled: false,
noteSideMenuOptions: null,
isOnMobileData: false };
isOnMobileData: false,
disableSideMenuGestures: false,
};
const appReducer = (state = appDefaultState, action: any) => {
let newState = state;
@ -402,6 +404,11 @@ const appReducer = (state = appDefaultState, action: any) => {
newState.noteSideMenuOptions = action.options;
break;
case 'SET_SIDE_MENU_TOUCH_GESTURES_DISABLED':
newState = { ...state };
newState.disableSideMenuGestures = action.disableSideMenuGestures;
break;
case 'MOBILE_DATA_WARNING_UPDATE':
newState = { ...state };
@ -1060,6 +1067,7 @@ class AppComponent extends React.Component {
openMenuOffset={this.state.sideMenuWidth}
menuPosition={menuPosition}
onChange={(isOpen: boolean) => this.sideMenu_change(isOpen)}
disableGestures={this.props.disableSideMenuGestures}
onSliding={(percent: number) => {
this.props.dispatch({
type: 'SIDE_MENU_OPEN_PERCENT',
@ -1120,6 +1128,7 @@ const mapStateToProps = (state: any) => {
routeName: state.route.routeName,
themeId: state.settings.theme,
noteSideMenuOptions: state.noteSideMenuOptions,
disableSideMenuGestures: state.disableSideMenuGestures,
biometricsDone: state.biometricsDone,
biometricsEnabled: state.settings['security.biometricsEnabled'],
};

View File

@ -202,6 +202,10 @@ const bundledFiles: BundledFile[] = [
'codeMirrorBundle',
`${mobileDir}/components/NoteEditor/CodeMirror/CodeMirror.ts`,
),
new BundledFile(
'svgEditorBundle',
`${mobileDir}/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.ts`,
),
];
export async function buildInjectedJS() {

View File

@ -16,6 +16,7 @@ const { setLocale, defaultLocale, closestSupportedLocale } = require('@joplin/li
const injectedJs = {
webviewLib: require('@joplin/lib/rnInjectedJs/webviewLib'),
codeMirrorBundle: require('../lib/rnInjectedJs/CodeMirror.bundle'),
svgEditorBundle: require('../lib/rnInjectedJs/createJsDrawEditor.bundle'),
};
function shimInit() {

View File

@ -6,5 +6,6 @@ export interface AppState extends State {
route: any;
smartFilterId: string;
noteSideMenuOptions: any;
disableSideMenuGestures: boolean;
themeId: number;
}

View File

@ -24,7 +24,7 @@ interface Shared {
initState?: (comp: any)=> void;
toggleIsTodo_onPress?: (comp: any)=> void;
toggleCheckboxRange?: (ipcMessage: string, noteBody: string)=> any;
toggleCheckbox?: (ipcMessage: string, noteBody: string)=> void;
toggleCheckbox?: (ipcMessage: string, noteBody: string)=> string;
installResourceHandling?: (refreshResourceHandler: any)=> void;
uninstallResourceHandling?: (refreshResourceHandler: any)=> void;
}

View File

@ -1459,6 +1459,24 @@ class Setting extends BaseModel {
isGlobal: true,
},
'imageeditor.jsdrawToolbar': {
value: '',
type: SettingItemType.String,
public: false,
appTypes: [AppType.Mobile],
label: () => '',
storage: SettingStorage.File,
},
'imageeditor.imageTemplate': {
value: '{ }',
type: SettingItemType.String,
public: false,
appTypes: [AppType.Mobile],
label: () => 'Template for the image editor',
storage: SettingStorage.File,
},
// 2023-09-07: This setting is now used to track the desktop beta editor. It
// was used to track the mobile beta editor previously.
'editor.beta': {

View File

@ -172,6 +172,15 @@ export interface RuleOptions {
// linkRenderingType = 2 gives a plain link with no JS. Caller needs to handle clicking on the link.
linkRenderingType?: number;
// A list of MIME types for which an edit button appears on tap/hover.
// Used by the image editor in the mobile app.
editPopupFiletypes?: string[];
// Shoould be the string representation a function that accepts two arguments:
// the target element to have the popup shown for and the id of the resource to edit.
createEditPopupSyntax?: string;
destroyEditPopupSyntax?: string;
audioPlayerEnabled: boolean;
videoPlayerEnabled: boolean;
pdfViewerEnabled: boolean;

View File

@ -21,10 +21,12 @@ describe('createEventHandlingAttrs', () => {
const options: Options = {
enableLongPress: false,
postMessageSyntax: 'postMessageFn',
enableEditPopup: false,
};
const listeners = createEventHandlingListeners('someresourceid', options, 'postMessage("click")');
// Should not add touchstart/mouseenter/leave listeners when not long-pressing.
// Should not add touchstart/mouseenter/leave listeners when not creating
// an edit popup or long-pressing.
expect(listeners.onmouseenter).toBe('');
expect(listeners.onmouseleave).toBe('');
expect(listeners.ontouchstart).toBe('');
@ -37,6 +39,7 @@ describe('createEventHandlingAttrs', () => {
const options: Options = {
enableLongPress: false,
postMessageSyntax: 'postMessageFn',
enableEditPopup: false,
};
const clickAction = 'postMessageFn("click")';
const listeners = createEventHandlingListeners('someresourceid', options, clickAction);
@ -51,6 +54,7 @@ describe('createEventHandlingAttrs', () => {
const options: Options = {
enableLongPress: true,
postMessageSyntax: 'postMessageFn',
enableEditPopup: false,
};
const clickAction: null|string = null;
const listeners = createEventHandlingListeners('resourceidhere', options, clickAction);
@ -71,6 +75,7 @@ describe('createEventHandlingAttrs', () => {
const options: Options = {
enableLongPress: true,
postMessageSyntax: 'postMessageFn',
enableEditPopup: false,
};
const listeners = createEventHandlingListeners('id', options, null);

View File

@ -6,6 +6,10 @@ import utils from '../utils';
export interface Options {
enableLongPress: boolean;
postMessageSyntax: string;
enableEditPopup: boolean;
createEditPopupSyntax?: string;
destroyEditPopupSyntax?: string;
}
// longPressTouchStart and clearLongPressTimeout are turned into strings before being called.
@ -58,6 +62,14 @@ export const createEventHandlingListeners = (resourceId: string, options: Option
eventHandlers.ontouchend += touchEnd;
}
if (options.enableEditPopup) {
const editPopupClick = `(() => ${options.postMessageSyntax}('edit:${resourceId}'))`;
const createEditPopup = ` (${options.createEditPopupSyntax})(this, ${JSON.stringify(resourceId)}, ${editPopupClick}); `;
eventHandlers.ontouchstart += createEditPopup;
eventHandlers.onclick += createEditPopup;
}
if (onClickAction) {
eventHandlers.onclick += onClickAction;
}

View File

@ -97,6 +97,7 @@ export default function(href: string, options: Options = null): LinkReplacementR
js = createEventHandlingAttrs(resourceId, {
enableLongPress: options.enableLongPress ?? false,
postMessageSyntax: options.postMessageSyntax ?? 'void',
enableEditPopup: false,
}, onClick);
} else {
js = `onclick='${htmlentities(js)}'`;

View File

@ -20,9 +20,17 @@ function plugin(markdownIt: any, ruleOptions: RuleOptions) {
if (r) {
const id = r['data-resource-id'];
// Show the edit popup if any MIME type matches that in editPopupFiletypes
const mimeType = ruleOptions.resources[id]?.item?.mime?.toLowerCase();
const enableEditPopup = ruleOptions.editPopupFiletypes?.some(showForMime => mimeType === showForMime);
const js = createEventHandlingAttrs(id, {
enableLongPress: ruleOptions.enableLongPress ?? false,
postMessageSyntax: ruleOptions.postMessageSyntax ?? 'void',
enableEditPopup,
createEditPopupSyntax: ruleOptions.createEditPopupSyntax,
destroyEditPopupSyntax: ruleOptions.destroyEditPopupSyntax,
}, null);
return `<img data-from-md ${attributesHtml({ ...r, title: title, alt: token.content })} ${js}/>`;

View File

@ -6,5 +6,6 @@
],
"exclude": [
"**/node_modules",
"**/*.test.ts",
],
}

View File

@ -4723,6 +4723,7 @@ __metadata:
"@joplin/renderer": ~2.13
"@joplin/tools": ~2.13
"@joplin/utils": ~2.13
"@js-draw/material-icons": 1.5.0
"@lezer/highlight": 1.1.4
"@react-native-community/clipboard": 1.5.1
"@react-native-community/datetimepicker": 7.4.2
@ -4753,6 +4754,7 @@ __metadata:
jest: 29.6.4
jest-environment-jsdom: 29.6.4
jetifier: 2.0.0
js-draw: 1.5.0
jsc-android: 241213.1.0
jsdom: 22.1.0
lodash: 4.17.21
@ -5391,6 +5393,24 @@ __metadata:
languageName: node
linkType: hard
"@js-draw/material-icons@npm:1.5.0":
version: 1.5.0
resolution: "@js-draw/material-icons@npm:1.5.0"
peerDependencies:
js-draw: ^1.0.1
checksum: 7aafd02b2c0bf6a0765698279271564fd5f8b1bce6987ac14a0c81e8f3d88b123a3b7da46367c28a043aa8af717c48f825b5f89a4d085976d7e0e8339737bb50
languageName: node
linkType: hard
"@js-draw/math@npm:^1.4.0":
version: 1.4.0
resolution: "@js-draw/math@npm:1.4.0"
dependencies:
bezier-js: 6.1.3
checksum: b56ee2e3b4fd415a7f27a9d175a37db2a24b59cbd6aeb296fe4bac6f40db8eaf34c4674a21958233bdde2739309a8af644ee7feb107a62304314394080851b4b
languageName: node
linkType: hard
"@koa/cors@npm:3.4.3":
version: 3.4.3
resolution: "@koa/cors@npm:3.4.3"
@ -6367,6 +6387,13 @@ __metadata:
languageName: node
linkType: hard
"@melloware/coloris@npm:0.21.0":
version: 0.21.0
resolution: "@melloware/coloris@npm:0.21.0"
checksum: d8f23ba23fbc557c6fb82fb5f27eda9f163d440f0bc11b6993d89d9db289982c809627184105a1e2e5fbae55f4daa122e549835608d5135905c5e700d89a6388
languageName: node
linkType: hard
"@mrmlnc/readdir-enhanced@npm:^2.2.1":
version: 2.2.1
resolution: "@mrmlnc/readdir-enhanced@npm:2.2.1"
@ -10785,6 +10812,13 @@ __metadata:
languageName: node
linkType: hard
"bezier-js@npm:6.1.3":
version: 6.1.3
resolution: "bezier-js@npm:6.1.3"
checksum: 5ca3317b00c844c15b4b179f1b54638bc943d393d17dd1551777c7178017663f3d61e469e56c2c2cc5b28459380706ccc12e46d80677b9375970ca55f48b5338
languageName: node
linkType: hard
"big-integer@npm:^1.6.44":
version: 1.6.51
resolution: "big-integer@npm:1.6.51"
@ -22177,6 +22211,16 @@ __metadata:
languageName: node
linkType: hard
"js-draw@npm:1.5.0":
version: 1.5.0
resolution: "js-draw@npm:1.5.0"
dependencies:
"@js-draw/math": ^1.4.0
"@melloware/coloris": 0.21.0
checksum: 9491fac7ecf904e4a0568cdf37d5a5291afd66928063f20f942e50c7f0f803784f3722aa870c697f2368719e255d719dbfda7bbc1072f164a3e7b9e2a1a243fc
languageName: node
linkType: hard
"js-sha512@npm:0.8.0":
version: 0.8.0
resolution: "js-sha512@npm:0.8.0"