You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Mobile: Add support for drawing pictures (#7588)
This commit is contained in:
		| @@ -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" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user