1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-21 09:38:01 +02:00

Mobile: Resolves #9017: Support pasting images (#10751)

This commit is contained in:
Henry Heino 2024-07-16 11:28:05 -07:00 committed by GitHub
parent 2d984ce9a8
commit 71f70f4d2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 181 additions and 49 deletions

View File

@ -713,6 +713,8 @@ packages/app-mobile/utils/fs-driver/testUtil/createFilesFromPathRecord.js
packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js
packages/app-mobile/utils/getPackageInfo.js
packages/app-mobile/utils/getVersionInfoText.js
packages/app-mobile/utils/image/getImageDimensions.js
packages/app-mobile/utils/image/resizeImage.js
packages/app-mobile/utils/initializeCommandService.js
packages/app-mobile/utils/ipc/RNToWebViewMessenger.js
packages/app-mobile/utils/ipc/WebViewToRNMessenger.js
@ -795,6 +797,7 @@ packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.js
packages/editor/CodeMirror/utils/formatting/toggleSelectedLinesStartWith.js
packages/editor/CodeMirror/utils/formatting/types.js
packages/editor/CodeMirror/utils/growSelectionToNode.js
packages/editor/CodeMirror/utils/handlePasteEvent.js
packages/editor/CodeMirror/utils/isInSyntaxNode.js
packages/editor/CodeMirror/utils/setupVim.js
packages/editor/SelectionFormatting.js

3
.gitignore vendored
View File

@ -692,6 +692,8 @@ packages/app-mobile/utils/fs-driver/testUtil/createFilesFromPathRecord.js
packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js
packages/app-mobile/utils/getPackageInfo.js
packages/app-mobile/utils/getVersionInfoText.js
packages/app-mobile/utils/image/getImageDimensions.js
packages/app-mobile/utils/image/resizeImage.js
packages/app-mobile/utils/initializeCommandService.js
packages/app-mobile/utils/ipc/RNToWebViewMessenger.js
packages/app-mobile/utils/ipc/WebViewToRNMessenger.js
@ -774,6 +776,7 @@ packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.js
packages/editor/CodeMirror/utils/formatting/toggleSelectedLinesStartWith.js
packages/editor/CodeMirror/utils/formatting/types.js
packages/editor/CodeMirror/utils/growSelectionToNode.js
packages/editor/CodeMirror/utils/handlePasteEvent.js
packages/editor/CodeMirror/utils/isInSyntaxNode.js
packages/editor/CodeMirror/utils/setupVim.js
packages/editor/SelectionFormatting.js

View File

@ -385,6 +385,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
ref={editorRef}
settings={editorSettings}
pluginStates={props.plugins}
onPasteFile={null}
onEvent={onEditorEvent}
onLogMessage={logDebug}
onEditorPaste={onEditorPaste}

View File

@ -27,6 +27,21 @@ export const initCodeMirror = (
initialText,
settings,
onPasteFile: async (data) => {
const reader = new FileReader();
return new Promise<void>((resolve, reject) => {
reader.onload = async () => {
const dataUrl = reader.result as string;
const base64 = dataUrl.replace(/^data:.*;base64,/, '');
await messenger.remoteApi.onPasteFile(data.type, base64);
resolve();
};
reader.onerror = () => reject(new Error('Failed to load file.'));
reader.readAsDataURL(data);
});
},
onLogMessage: message => {
void messenger.remoteApi.logMessage(message);
},

View File

@ -38,7 +38,7 @@ describe('NoteEditor', () => {
onChange={()=>{}}
onSelectionChange={()=>{}}
onUndoRedoDepthChange={()=>{}}
onAttach={()=>{}}
onAttach={async ()=>{}}
plugins={{}}
/>
</MenuProvider>,

View File

@ -26,11 +26,14 @@ import { WebViewErrorEvent } from 'react-native-webview/lib/RNCWebViewNativeComp
import Logger from '@joplin/utils/Logger';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import useEditorCommandHandler from './hooks/useEditorCommandHandler';
import { join, dirname } from 'path';
import * as mimeUtils from '@joplin/lib/mime-utils';
import uuid from '@joplin/lib/uuid';
type ChangeEventHandler = (event: ChangeEvent)=> void;
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
type SelectionChangeEventHandler = (event: SelectionRangeChangeEvent)=> void;
type OnAttachCallback = ()=> void;
type OnAttachCallback = (filePath?: string)=> Promise<void>;
const logger = Logger.create('NoteEditor');
@ -373,6 +376,9 @@ function NoteEditor(props: Props, ref: any) {
const onEditorEvent = useRef((_event: EditorEvent) => {});
const onAttachRef = useRef(props.onAttach);
onAttachRef.current = props.onAttach;
const editorMessenger = useMemo(() => {
const localApi: WebViewToEditorApi = {
async onEditorEvent(event) {
@ -381,6 +387,16 @@ function NoteEditor(props: Props, ref: any) {
async logMessage(message) {
logger.debug('CodeMirror:', message);
},
async onPasteFile(type, data) {
const tempFilePath = join(Setting.value('tempDir'), `paste.${uuid.createNano()}.${mimeUtils.toFileExtension(type)}`);
await shim.fsDriver().mkdir(dirname(tempFilePath));
try {
await shim.fsDriver().writeFile(tempFilePath, data, 'base64');
await onAttachRef.current(tempFilePath);
} finally {
await shim.fsDriver().remove(tempFilePath);
}
},
};
const messenger = new RNToWebViewMessenger<WebViewToEditorApi, EditorBodyControl>(
'editor', webviewRef, localApi,

View File

@ -57,4 +57,5 @@ export interface SelectionRange {
export interface WebViewToEditorApi {
onEditorEvent(event: EditorEvent): Promise<void>;
logMessage(message: string): Promise<void>;
onPasteFile(type: string, dataBase64: string): Promise<void>;
}

View File

@ -6,10 +6,9 @@ import UndoRedoService from '@joplin/lib/services/UndoRedoService';
import NoteBodyViewer from '../NoteBodyViewer/NoteBodyViewer';
import checkPermissions from '../../utils/checkPermissions';
import NoteEditor from '../NoteEditor/NoteEditor';
import { Size } from '@joplin/utils/types';
const FileViewer = require('react-native-file-viewer').default;
const React = require('react');
import { Keyboard, View, TextInput, StyleSheet, Linking, Image, Share, NativeSyntheticEvent } from 'react-native';
import { Keyboard, View, TextInput, StyleSheet, Linking, Share, NativeSyntheticEvent } from 'react-native';
import { Platform, PermissionsAndroid } from 'react-native';
const { connect } = require('react-redux');
// const { MarkdownEditor } = require('@joplin/lib/../MarkdownEditor/index.js');
@ -36,7 +35,6 @@ import { BaseScreenComponent } from '../base-screen';
import { themeStyle, editorFont } from '../global-style';
const { dialogs } = require('../../utils/dialogs.js');
const DialogBox = require('react-native-dialogbox').default;
import ImageResizer from '@bam.tech/react-native-image-resizer';
import shared, { BaseNoteScreenComponent } from '@joplin/lib/components/shared/note-screen-shared';
import { Asset, ImagePickerResponse, launchImageLibrary } from 'react-native-image-picker';
import SelectDateTimeDialog from '../SelectDateTimeDialog';
@ -65,6 +63,8 @@ import debounce from '../../utils/debounce';
import { focus } from '@joplin/lib/utils/focusHandler';
import CommandService from '@joplin/lib/services/CommandService';
import * as urlUtils from '@joplin/lib/urlUtils';
import getImageDimensions from '../../utils/image/getImageDimensions';
import resizeImage from '../../utils/image/resizeImage';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const emptyArray: any[] = [];
@ -682,24 +682,9 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
return result;
}
public async imageDimensions(uri: string): Promise<Size> {
return new Promise((resolve, reject) => {
Image.getSize(
uri,
(width: number, height: number) => {
resolve({ width: width, height: height });
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(error: any) => {
reject(error);
},
);
});
}
public async resizeImage(localFilePath: string, targetPath: string, mimeType: string) {
const maxSize = Resource.IMAGE_MAX_DIMENSION;
const dimensions = await this.imageDimensions(localFilePath);
const dimensions = await getImageDimensions(localFilePath);
reg.logger().info('Original dimensions ', dimensions);
const saveOriginalImage = async () => {
@ -711,30 +696,14 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
dimensions.height = maxSize;
reg.logger().info('New dimensions ', dimensions);
const format = mimeType === 'image/png' ? 'PNG' : 'JPEG';
reg.logger().info(`Resizing image ${localFilePath}`);
const resizedImage = await ImageResizer.createResizedImage(
localFilePath,
dimensions.width,
dimensions.height,
format,
85, // quality
undefined, // rotation
undefined, // outputPath
true, // keep metadata
);
const resizedImagePath = resizedImage.uri;
reg.logger().info('Resized image ', resizedImagePath);
reg.logger().info(`Moving ${resizedImagePath} => ${targetPath}`);
await shim.fsDriver().copy(resizedImagePath, targetPath);
try {
await shim.fsDriver().unlink(resizedImagePath);
} catch (error) {
reg.logger().warn('Error when unlinking cached file: ', error);
}
await resizeImage({
inputPath: localFilePath,
outputPath: targetPath,
maxWidth: dimensions.width,
maxHeight: dimensions.height,
quality: 85,
format: mimeType === 'image/png' ? 'PNG' : 'JPEG',
});
return true;
};
@ -1140,11 +1109,19 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
const buttonId = await dialogs.pop(this, _('Choose an option'), buttons);
if (buttonId === 'takePhoto') this.takePhoto_onPress();
if (buttonId === 'attachFile') void this.attachFile_onPress();
if (buttonId === 'attachPhoto') void this.attachPhoto_onPress();
if (buttonId === 'takePhoto') await this.takePhoto_onPress();
if (buttonId === 'attachFile') await this.attachFile_onPress();
if (buttonId === 'attachPhoto') await this.attachPhoto_onPress();
}
public onAttach = async (filePath?: string) => {
if (filePath) {
await this.attachFile({ uri: filePath }, 'all');
} else {
await this.showAttachMenu();
}
};
// private vosk_:Vosk;
// private async getVosk() {
@ -1585,7 +1562,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
onChange={this.onMarkdownEditorTextChange}
onSelectionChange={this.onMarkdownEditorSelectionChange}
onUndoRedoDepthChange={this.onUndoRedoDepthChange}
onAttach={() => this.showAttachMenu()}
onAttach={this.onAttach}
readOnly={this.state.readOnly}
plugins={this.props.plugins}
style={{

View File

@ -0,0 +1,18 @@
import { Size } from '@joplin/utils/types';
import { Image as NativeImage } from 'react-native';
const getImageDimensions = async (uri: string): Promise<Size> => {
return new Promise((resolve, reject) => {
NativeImage.getSize(
uri,
(width: number, height: number) => {
resolve({ width: width, height: height });
},
(error: unknown) => {
reject(error);
},
);
});
};
export default getImageDimensions;

View File

@ -0,0 +1,43 @@
import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger';
import ImageResizer from '@bam.tech/react-native-image-resizer';
const logger = Logger.create('resizeImage');
type OutputFormat = 'PNG' | 'JPEG';
interface Options {
inputPath: string;
outputPath: string;
maxWidth: number;
maxHeight: number;
format: OutputFormat;
quality: number;
}
const resizeImage = async (options: Options) => {
const resizedImage = await ImageResizer.createResizedImage(
options.inputPath,
options.maxWidth,
options.maxHeight,
options.format,
options.quality, // quality
undefined, // rotation
undefined, // outputPath
true, // keep metadata
);
const resizedImagePath = resizedImage.uri;
logger.info('Resized image ', resizedImagePath);
logger.info(`Moving ${resizedImagePath} => ${options.outputPath}`);
await shim.fsDriver().copy(resizedImagePath, options.outputPath);
try {
await shim.fsDriver().unlink(resizedImagePath);
} catch (error) {
logger.warn('Error when unlinking cached file: ', error);
}
};
export default resizeImage;

View File

@ -39,6 +39,7 @@ describe('createEditor', () => {
settings: editorSettings,
onEvent: _event => {},
onLogMessage: _message => {},
onPasteFile: null,
});
// Force the generation of the syntax tree now.
@ -66,6 +67,7 @@ describe('createEditor', () => {
settings: editorSettings,
onEvent: _event => {},
onLogMessage: _message => {},
onPasteFile: null,
});
const getContentScriptJs = jest.fn(async () => {
@ -133,6 +135,7 @@ describe('createEditor', () => {
settings: editorSettings,
onEvent: _event => {},
onLogMessage: _message => {},
onPasteFile: null,
});
const getContentScriptJs = jest.fn(async () => {

View File

@ -30,6 +30,7 @@ import configFromSettings from './configFromSettings';
import getScrollFraction from './getScrollFraction';
import CodeMirrorControl from './CodeMirrorControl';
import insertLineAfter from './editorCommands/insertLineAfter';
import handlePasteEvent from './utils/handlePasteEvent';
const createEditor = (
parentElement: HTMLElement, props: EditorProps,
@ -257,6 +258,24 @@ const createEditor = (
fraction: getScrollFraction(view),
});
},
paste: (event, view) => {
if (props.onPasteFile) {
handlePasteEvent(event, view, props.onPasteFile);
}
},
dragover: (event, _view) => {
if (props.onPasteFile && event.dataTransfer.files.length) {
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
return true;
}
return false;
},
drop: (event, view) => {
if (props.onPasteFile) {
handlePasteEvent(event, view, props.onPasteFile);
}
},
}),
EditorState.tabSize.of(4),

View File

@ -10,6 +10,7 @@ const createEditorControl = (initialText: string) => {
settings: editorSettings,
onEvent: _event => {},
onLogMessage: _message => {},
onPasteFile: null,
});
};

View File

@ -0,0 +1,29 @@
import { EditorView } from '@codemirror/view';
import { PasteFileCallback } from '../../types';
const handlePasteEvent = (event: ClipboardEvent|DragEvent, _view: EditorView, onPaste: PasteFileCallback) => {
const dataTransfer = 'clipboardData' in event ? event.clipboardData : event.dataTransfer;
const files = dataTransfer.files;
let fileToPaste: File|null = null;
// Prefer image files, if available.
for (const file of files) {
if (['image/png', 'image/jpeg', 'image/svg+xml'].includes(file.type)) {
fileToPaste = file;
break;
}
}
// Fall back to other files
if (files.length && !fileToPaste) {
fileToPaste = files[0];
}
if (fileToPaste) {
event.preventDefault();
void onPaste(fileToPaste);
}
};
export default handlePasteEvent;

View File

@ -168,11 +168,14 @@ export interface EditorSettings {
export type LogMessageCallback = (message: string)=> void;
export type OnEventCallback = (event: EditorEvent)=> void;
export type PasteFileCallback = (data: File)=> Promise<void>;
export interface EditorProps {
settings: EditorSettings;
initialText: string;
// If null, paste and drag-and-drop will not work for resources unless handled elsewhere.
onPasteFile: PasteFileCallback|null;
onEvent: OnEventCallback;
onLogMessage: LogMessageCallback;
}