diff --git a/.eslintignore b/.eslintignore
index 811541fc4..9d378907f 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -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
diff --git a/.gitignore b/.gitignore
index 40c2c66ae..b46767877 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/packages/app-mobile/.gitignore b/packages/app-mobile/.gitignore
index 725e577b2..b8e81d463 100644
--- a/packages/app-mobile/.gitignore
+++ b/packages/app-mobile/.gitignore
@@ -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-*
diff --git a/packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.tsx b/packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.tsx
index 04d94c34d..12dcc681e 100644
--- a/packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.tsx
+++ b/packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.tsx
@@ -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(() => {
diff --git a/packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.test.ts b/packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.test.ts
new file mode 100644
index 000000000..bf545a2ce
--- /dev/null
+++ b/packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.test.ts
@@ -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();
+ });
+});
diff --git a/packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.ts b/packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.ts
new file mode 100644
index 000000000..80680bebb
--- /dev/null
+++ b/packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.ts
@@ -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;
diff --git a/packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.ts b/packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.ts
index 88df0732f..82a8c6e01 100644
--- a/packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.ts
+++ b/packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.ts
@@ -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,
+ ]);
}
diff --git a/packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.ts b/packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.ts
index 18117a49f..5f7d96e1e 100644
--- a/packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.ts
+++ b/packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.ts
@@ -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]);
}
diff --git a/packages/app-mobile/components/NoteBodyViewer/hooks/useSource.ts b/packages/app-mobile/components/NoteBodyViewer/hooks/useSource.ts
index 53101c367..a78adcb2a 100644
--- a/packages/app-mobile/components/NoteBodyViewer/hooks/useSource.ts
+++ b/packages/app-mobile/components/NoteBodyViewer/hooks/useSource.ts
@@ -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,
${assetsToHeaders(result.pluginAssets, { asHtml: true })}
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 000000000..b337e3dc5
--- /dev/null
+++ b/packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.tsx
@@ -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;
+
+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|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(() => `
+
+
+
+
+
+
+
+
+
+
+ `, [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 (
+
+ );
+};
+
+export default ImageEditor;
diff --git a/packages/app-mobile/components/NoteEditor/ImageEditor/autosave.ts b/packages/app-mobile/components/NoteEditor/ImageEditor/autosave.ts
new file mode 100644
index 000000000..0dae756e9
--- /dev/null
+++ b/packages/app-mobile/components/NoteEditor/ImageEditor/autosave.ts
@@ -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 => {
+ return await shim.fsDriver().readFile(getAutosaveFilepath());
+};
+
+export const clearAutosave = async () => {
+ await shim.fsDriver().remove(getAutosaveFilepath());
+};
diff --git a/packages/app-mobile/components/NoteEditor/ImageEditor/isEditableResource.ts b/packages/app-mobile/components/NoteEditor/ImageEditor/isEditableResource.ts
new file mode 100644
index 000000000..e5ab3c9bd
--- /dev/null
+++ b/packages/app-mobile/components/NoteEditor/ImageEditor/isEditableResource.ts
@@ -0,0 +1,6 @@
+
+const isEditableResource = (resourceMime: string) => {
+ return resourceMime === 'image/svg+xml';
+};
+
+export default isEditableResource;
diff --git a/packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/applyTemplateToEditor.ts b/packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/applyTemplateToEditor.ts
new file mode 100644
index 000000000..79f9096de
--- /dev/null
+++ b/packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/applyTemplateToEditor.ts
@@ -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;
diff --git a/packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.test.ts b/packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.test.ts
new file mode 100644
index 000000000..59ecce237
--- /dev/null
+++ b/packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.test.ts
@@ -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) => {
+ const toolbarState = '';
+ const locale = 'en';
+
+ const allCallbacks: ImageEditorCallbacks = {
+ saveDrawing: () => {},
+ closeEditor: ()=> {},
+ setImageHasChanges: ()=> {},
+ updateEditorTemplate: ()=> {},
+
+ ...callbacks,
+ };
+
+ const editorOptions: Partial = {
+ // 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);
+ });
+});
diff --git a/packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.ts b/packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.ts
new file mode 100644
index 000000000..f5283b53e
--- /dev/null
+++ b/packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.ts
@@ -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 = {},
+) => {
+ 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;
diff --git a/packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/startAutosaveLoop.ts b/packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/startAutosaveLoop.ts
new file mode 100644
index 000000000..9c774a813
--- /dev/null
+++ b/packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/startAutosaveLoop.ts
@@ -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(resolve => {
+ setTimeout(() => resolve(), delayTime);
+ }));
+
+ await createAutosave();
+ }
+};
+
+export default startAutosaveLoop;
diff --git a/packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/types.ts b/packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/types.ts
new file mode 100644
index 000000000..ab930a512
--- /dev/null
+++ b/packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/types.ts
@@ -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;
+}
diff --git a/packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/watchEditorForTemplateChanges.ts b/packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/watchEditorForTemplateChanges.ts
new file mode 100644
index 000000000..49eea32c6
--- /dev/null
+++ b/packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/watchEditorForTemplateChanges.ts
@@ -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;
diff --git a/packages/app-mobile/components/NoteEditor/ImageEditor/promptRestoreAutosave.ts b/packages/app-mobile/components/NoteEditor/ImageEditor/promptRestoreAutosave.ts
new file mode 100644
index 000000000..f916b5758
--- /dev/null
+++ b/packages/app-mobile/components/NoteEditor/ImageEditor/promptRestoreAutosave.ts
@@ -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;
diff --git a/packages/app-mobile/components/screens/Note.tsx b/packages/app-mobile/components/screens/Note.tsx
index cfa3d335a..d130cb778 100644
--- a/packages/app-mobile/components/screens/Note.tsx
+++ b/packages/app-mobile/components/screens/Note.tsx
@@ -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 {
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 ;
+ } else if (this.state.showImageEditor) {
+ return ;
}
// 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}
/>
);
diff --git a/packages/app-mobile/jest.config.js b/packages/app-mobile/jest.config.js
index 145c234ab..8c94a7f41 100644
--- a/packages/app-mobile/jest.config.js
+++ b/packages/app-mobile/jest.config.js
@@ -20,7 +20,7 @@ module.exports = {
// Do transform most packages in node_modules (transformations correct unrecognized
// import syntax)
- transformIgnorePatterns: ['/node_modules/jest'],
+ transformIgnorePatterns: ['/node_modules/jest', '/node_modules/js-draw'],
slowTestThreshold: 40,
};
diff --git a/packages/app-mobile/package.json b/packages/app-mobile/package.json
index b6349f7f6..d87519c86 100644
--- a/packages/app-mobile/package.json
+++ b/packages/app-mobile/package.json
@@ -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",
diff --git a/packages/app-mobile/root.tsx b/packages/app-mobile/root.tsx
index 88bfa56a4..0c15669b3 100644
--- a/packages/app-mobile/root.tsx
+++ b/packages/app-mobile/root.tsx
@@ -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'],
};
diff --git a/packages/app-mobile/tools/buildInjectedJs.ts b/packages/app-mobile/tools/buildInjectedJs.ts
index af2488520..7f96ed35b 100644
--- a/packages/app-mobile/tools/buildInjectedJs.ts
+++ b/packages/app-mobile/tools/buildInjectedJs.ts
@@ -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() {
diff --git a/packages/app-mobile/utils/shim-init-react.js b/packages/app-mobile/utils/shim-init-react.js
index 68f336898..d0ed6fb0a 100644
--- a/packages/app-mobile/utils/shim-init-react.js
+++ b/packages/app-mobile/utils/shim-init-react.js
@@ -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() {
diff --git a/packages/app-mobile/utils/types.ts b/packages/app-mobile/utils/types.ts
index 5f3601bc9..8c90f2095 100644
--- a/packages/app-mobile/utils/types.ts
+++ b/packages/app-mobile/utils/types.ts
@@ -6,5 +6,6 @@ export interface AppState extends State {
route: any;
smartFilterId: string;
noteSideMenuOptions: any;
+ disableSideMenuGestures: boolean;
themeId: number;
}
diff --git a/packages/lib/components/shared/note-screen-shared.ts b/packages/lib/components/shared/note-screen-shared.ts
index e0b196bd2..eb61f4448 100644
--- a/packages/lib/components/shared/note-screen-shared.ts
+++ b/packages/lib/components/shared/note-screen-shared.ts
@@ -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;
}
diff --git a/packages/lib/models/Setting.ts b/packages/lib/models/Setting.ts
index 72f32434d..b0cfa979f 100644
--- a/packages/lib/models/Setting.ts
+++ b/packages/lib/models/Setting.ts
@@ -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': {
diff --git a/packages/renderer/MdToHtml.ts b/packages/renderer/MdToHtml.ts
index c6b1e8075..64178da88 100644
--- a/packages/renderer/MdToHtml.ts
+++ b/packages/renderer/MdToHtml.ts
@@ -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;
diff --git a/packages/renderer/MdToHtml/createEventHandlingAttrs.test.ts b/packages/renderer/MdToHtml/createEventHandlingAttrs.test.ts
index 1ba9637be..38dab2ed9 100644
--- a/packages/renderer/MdToHtml/createEventHandlingAttrs.test.ts
+++ b/packages/renderer/MdToHtml/createEventHandlingAttrs.test.ts
@@ -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);
diff --git a/packages/renderer/MdToHtml/createEventHandlingAttrs.ts b/packages/renderer/MdToHtml/createEventHandlingAttrs.ts
index 15d10d82f..aaa060c86 100644
--- a/packages/renderer/MdToHtml/createEventHandlingAttrs.ts
+++ b/packages/renderer/MdToHtml/createEventHandlingAttrs.ts
@@ -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;
}
diff --git a/packages/renderer/MdToHtml/linkReplacement.ts b/packages/renderer/MdToHtml/linkReplacement.ts
index 5c737acc5..80662e052 100644
--- a/packages/renderer/MdToHtml/linkReplacement.ts
+++ b/packages/renderer/MdToHtml/linkReplacement.ts
@@ -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)}'`;
diff --git a/packages/renderer/MdToHtml/rules/image.ts b/packages/renderer/MdToHtml/rules/image.ts
index b66588e19..4f170719f 100644
--- a/packages/renderer/MdToHtml/rules/image.ts
+++ b/packages/renderer/MdToHtml/rules/image.ts
@@ -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 ``;
diff --git a/packages/renderer/tsconfig.json b/packages/renderer/tsconfig.json
index 115075174..ffe03e95e 100644
--- a/packages/renderer/tsconfig.json
+++ b/packages/renderer/tsconfig.json
@@ -6,5 +6,6 @@
],
"exclude": [
"**/node_modules",
+ "**/*.test.ts",
],
}
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index 13cc4c6f7..db5c026a9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"