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"