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:
parent
487112fd4d
commit
849427d1bf
@ -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
12
.gitignore
vendored
@ -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
|
||||
|
5
packages/app-mobile/.gitignore
vendored
5
packages/app-mobile/.gitignore
vendored
@ -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-*
|
||||
|
@ -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(() => {
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -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;
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
@ -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]);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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;
|
@ -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());
|
||||
};
|
@ -0,0 +1,6 @@
|
||||
|
||||
const isEditableResource = (resourceMime: string) => {
|
||||
return resourceMime === 'image/svg+xml';
|
||||
};
|
||||
|
||||
export default isEditableResource;
|
@ -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;
|
@ -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);
|
||||
});
|
||||
});
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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",
|
||||
|
@ -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'],
|
||||
};
|
||||
|
@ -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() {
|
||||
|
@ -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() {
|
||||
|
@ -6,5 +6,6 @@ export interface AppState extends State {
|
||||
route: any;
|
||||
smartFilterId: string;
|
||||
noteSideMenuOptions: any;
|
||||
disableSideMenuGestures: boolean;
|
||||
themeId: number;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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': {
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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)}'`;
|
||||
|
@ -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}/>`;
|
||||
|
@ -6,5 +6,6 @@
|
||||
],
|
||||
"exclude": [
|
||||
"**/node_modules",
|
||||
"**/*.test.ts",
|
||||
],
|
||||
}
|
44
yarn.lock
44
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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user