You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Mobile: Camera screen: Support scanning QR codes (#11245)
This commit is contained in:
		| @@ -1,247 +0,0 @@ | ||||
| const { RNCamera } = require('react-native-camera'); | ||||
| const React = require('react'); | ||||
| const Component = React.Component; | ||||
| const { connect } = require('react-redux'); | ||||
| const { View, TouchableOpacity, Text, Dimensions } = require('react-native'); | ||||
| const Icon = require('react-native-vector-icons/Ionicons').default; | ||||
| const { _ } = require('@joplin/lib/locale'); | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
|  | ||||
| class CameraView extends Component { | ||||
| 	public constructor() { | ||||
| 		super(); | ||||
|  | ||||
| 		const dimensions = Dimensions.get('window'); | ||||
|  | ||||
| 		this.state = { | ||||
| 			snapping: false, | ||||
| 			ratios: [], | ||||
| 			screenWidth: dimensions.width, | ||||
| 			screenHeight: dimensions.height, | ||||
| 		}; | ||||
|  | ||||
| 		this.back_onPress = this.back_onPress.bind(this); | ||||
| 		this.photo_onPress = this.photo_onPress.bind(this); | ||||
| 		this.reverse_onPress = this.reverse_onPress.bind(this); | ||||
| 		this.ratio_onPress = this.ratio_onPress.bind(this); | ||||
| 		this.onCameraReady = this.onCameraReady.bind(this); | ||||
| 		this.onLayout = this.onLayout.bind(this); | ||||
| 	} | ||||
|  | ||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 	public onLayout(event: any) { | ||||
| 		this.setState({ | ||||
| 			screenWidth: event.nativeEvent.layout.width, | ||||
| 			screenHeight: event.nativeEvent.layout.height, | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	private back_onPress() { | ||||
| 		if (this.props.onCancel) this.props.onCancel(); | ||||
| 	} | ||||
|  | ||||
| 	private reverse_onPress() { | ||||
| 		if (this.props.cameraType === RNCamera.Constants.Type.back) { | ||||
| 			Setting.setValue('camera.type', RNCamera.Constants.Type.front); | ||||
| 		} else { | ||||
| 			Setting.setValue('camera.type', RNCamera.Constants.Type.back); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private ratio_onPress() { | ||||
| 		if (this.state.ratios.length <= 1) return; | ||||
|  | ||||
| 		let index = this.state.ratios.indexOf(this.props.cameraRatio); | ||||
| 		index++; | ||||
| 		if (index >= this.state.ratios.length) index = 0; | ||||
| 		Setting.setValue('camera.ratio', this.state.ratios[index]); | ||||
| 	} | ||||
|  | ||||
| 	private async photo_onPress() { | ||||
| 		if (!this.camera || !this.props.onPhoto) return; | ||||
|  | ||||
| 		this.setState({ snapping: true }); | ||||
|  | ||||
| 		const result = await this.camera.takePictureAsync({ | ||||
| 			quality: 0.8, | ||||
| 			exif: true, | ||||
| 			fixOrientation: true, | ||||
| 		}); | ||||
|  | ||||
| 		this.setState({ snapping: false }); | ||||
|  | ||||
| 		if (this.props.onPhoto) this.props.onPhoto(result); | ||||
|  | ||||
| 	} | ||||
|  | ||||
| 	public async onCameraReady() { | ||||
| 		if (this.supportsRatios()) { | ||||
| 			const ratios = await this.camera.getSupportedRatiosAsync(); | ||||
| 			this.setState({ ratios: ratios }); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any -- Old code before rule was applied, Old code before rule was applied | ||||
| 	public renderButton(onPress: Function, iconNameOrIcon: any, style: any) { | ||||
| 		let icon = null; | ||||
|  | ||||
| 		if (typeof iconNameOrIcon === 'string') { | ||||
| 			icon = ( | ||||
| 				<Icon | ||||
| 					name={iconNameOrIcon} | ||||
| 					style={{ | ||||
| 						fontSize: 40, | ||||
| 						color: 'black', | ||||
| 					}} | ||||
| 				/> | ||||
| 			); | ||||
| 		} else { | ||||
| 			icon = iconNameOrIcon; | ||||
| 		} | ||||
|  | ||||
| 		return ( | ||||
| 			<TouchableOpacity onPress={onPress} style={{ ...style }}> | ||||
| 				<View style={{ borderRadius: 32, width: 60, height: 60, borderColor: '#00000040', borderWidth: 1, borderStyle: 'solid', backgroundColor: '#ffffff77', justifyContent: 'center', alignItems: 'center', alignSelf: 'baseline' }}> | ||||
| 					{ icon } | ||||
| 				</View> | ||||
| 			</TouchableOpacity> | ||||
| 		); | ||||
| 	} | ||||
|  | ||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 	public fitRectIntoBounds(rect: any, bounds: any) { | ||||
| 		const rectRatio = rect.width / rect.height; | ||||
| 		const boundsRatio = bounds.width / bounds.height; | ||||
|  | ||||
| 		// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 		const newDimensions: any = {}; | ||||
|  | ||||
| 		// Rect is more landscape than bounds - fit to width | ||||
| 		if (rectRatio > boundsRatio) { | ||||
| 			newDimensions.width = bounds.width; | ||||
| 			newDimensions.height = rect.height * (bounds.width / rect.width); | ||||
| 		} else { // Rect is more portrait than bounds - fit to height | ||||
| 			newDimensions.width = rect.width * (bounds.height / rect.height); | ||||
| 			newDimensions.height = bounds.height; | ||||
| 		} | ||||
|  | ||||
| 		return newDimensions; | ||||
| 	} | ||||
|  | ||||
| 	public cameraRect(ratio: string) { | ||||
| 		// To keep the calculations simpler, it's assumed that the phone is in | ||||
| 		// portrait orientation. Then at the end we swap the values if needed. | ||||
| 		const splitted = ratio.split(':'); | ||||
|  | ||||
| 		const output = this.fitRectIntoBounds({ | ||||
| 			width: Number(splitted[1]), | ||||
| 			height: Number(splitted[0]), | ||||
| 		}, { | ||||
| 			width: Math.min(this.state.screenWidth, this.state.screenHeight), | ||||
| 			height: Math.max(this.state.screenWidth, this.state.screenHeight), | ||||
| 		}); | ||||
|  | ||||
| 		if (this.state.screenWidth > this.state.screenHeight) { | ||||
| 			const w = output.width; | ||||
| 			output.width = output.height; | ||||
| 			output.height = w; | ||||
| 		} | ||||
|  | ||||
| 		return output; | ||||
| 	} | ||||
|  | ||||
| 	public supportsRatios() { | ||||
| 		return shim.mobilePlatform() === 'android'; | ||||
| 	} | ||||
|  | ||||
| 	public render() { | ||||
| 		const photoIcon = this.state.snapping ? 'checkmark' : 'camera'; | ||||
|  | ||||
| 		const displayRatios = this.supportsRatios() && this.state.ratios.length > 1; | ||||
|  | ||||
| 		const reverseCameraButton = this.renderButton(this.reverse_onPress, 'camera-reverse', { flex: 1, flexDirection: 'row', justifyContent: 'flex-start', marginLeft: 20 }); | ||||
| 		const ratioButton = !displayRatios ? <View style={{ flex: 1 }}/> : this.renderButton(this.ratio_onPress, <Text style={{ fontWeight: 'bold', fontSize: 20 }}>{Setting.value('camera.ratio')}</Text>, { flex: 1, flexDirection: 'row', justifyContent: 'flex-end', marginRight: 20 }); | ||||
|  | ||||
| 		let cameraRatio = '4:3'; | ||||
| 		// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 		const cameraProps: any = {}; | ||||
|  | ||||
| 		if (displayRatios) { | ||||
| 			cameraProps.ratio = this.props.cameraRatio; | ||||
| 			cameraRatio = this.props.cameraRatio; | ||||
| 		} | ||||
|  | ||||
| 		const cameraRect = this.cameraRect(cameraRatio); | ||||
| 		cameraRect.left = (this.state.screenWidth - cameraRect.width) / 2; | ||||
| 		cameraRect.top = (this.state.screenHeight - cameraRect.height) / 2; | ||||
|  | ||||
| 		return ( | ||||
| 			<View style={{ ...this.props.style, position: 'relative' }} onLayout={this.onLayout}> | ||||
| 				<View style={{ position: 'absolute', backgroundColor: '#000000', width: '100%', height: '100%' }}/> | ||||
| 				<RNCamera | ||||
| 					style={({ position: 'absolute', ...cameraRect })} | ||||
| 					// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 					ref={(ref: any) => { | ||||
| 						this.camera = ref; | ||||
| 					}} | ||||
| 					type={this.props.cameraType} | ||||
| 					captureAudio={false} | ||||
| 					onCameraReady={this.onCameraReady} | ||||
| 					androidCameraPermissionOptions={{ | ||||
| 						title: _('Permission to use camera'), | ||||
| 						message: _('Your permission to use your camera is required.'), | ||||
| 						buttonPositive: _('OK'), | ||||
| 						buttonNegative: _('Cancel'), | ||||
| 					}} | ||||
|  | ||||
| 					{ ...cameraProps } | ||||
| 				> | ||||
| 					<View style={{ flex: 1, justifyContent: 'space-between', flexDirection: 'column' }}> | ||||
| 						<View style={{ flex: 1, justifyContent: 'flex-start' }}> | ||||
| 							<TouchableOpacity onPress={this.back_onPress}> | ||||
| 								<View style={{ marginLeft: 5, marginTop: 5, borderColor: '#00000040', borderWidth: 1, borderStyle: 'solid', borderRadius: 90, width: 50, height: 50, display: 'flex', backgroundColor: '#ffffff77', justifyContent: 'center', alignItems: 'center' }}> | ||||
| 									<Icon | ||||
| 										name={'arrow-back'} | ||||
| 										style={{ | ||||
| 											fontSize: 40, | ||||
| 											color: 'black', | ||||
| 										}} | ||||
| 									/> | ||||
| 								</View> | ||||
| 							</TouchableOpacity> | ||||
| 						</View> | ||||
| 						<View style={{ flex: 1, flexDirection: 'row', justifyContent: 'center', alignItems: 'flex-end' }}> | ||||
| 							<View style={{ flex: 1, flexDirection: 'row', justifyContent: 'center', alignItems: 'center', marginBottom: 20 }}> | ||||
| 								{ reverseCameraButton } | ||||
| 								<TouchableOpacity onPress={this.photo_onPress} disabled={this.state.snapping}> | ||||
| 									<View style={{ flexDirection: 'row', borderRadius: 90, width: 90, height: 90, backgroundColor: '#ffffffaa', display: 'flex', justifyContent: 'center', alignItems: 'center' }}> | ||||
| 										<Icon | ||||
| 											name={photoIcon} | ||||
| 											style={{ | ||||
| 												fontSize: 60, | ||||
| 												color: 'black', | ||||
| 											}} | ||||
| 										/> | ||||
| 									</View> | ||||
| 								</TouchableOpacity> | ||||
| 								{ ratioButton } | ||||
| 							</View> | ||||
| 						</View> | ||||
| 					</View> | ||||
| 				</RNCamera> | ||||
| 			</View> | ||||
| 		); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| const mapStateToProps = (state: any) => { | ||||
| 	return { | ||||
| 		cameraRatio: state.settings['camera.ratio'], | ||||
| 		cameraType: state.settings['camera.type'], | ||||
| 	}; | ||||
| }; | ||||
|  | ||||
|  | ||||
| export default connect(mapStateToProps)(CameraView); | ||||
							
								
								
									
										155
									
								
								packages/app-mobile/components/CameraView/ActionButtons.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										155
									
								
								packages/app-mobile/components/CameraView/ActionButtons.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,155 @@ | ||||
| import * as React from 'react'; | ||||
| import { useMemo } from 'react'; | ||||
| import { View, StyleSheet, ViewStyle, Platform } from 'react-native'; | ||||
| import IconButton from '../IconButton'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import { CameraDirection } from '@joplin/lib/models/settings/builtInMetadata'; | ||||
| import { ActivityIndicator } from 'react-native-paper'; | ||||
|  | ||||
| interface Props { | ||||
| 	themeId: number; | ||||
| 	onCameraReverse: ()=> void; | ||||
| 	cameraDirection: CameraDirection; | ||||
|  | ||||
| 	onSetCameraRatio: ()=> void; | ||||
| 	cameraRatio: string; | ||||
|  | ||||
| 	onCancelPhoto: ()=> void; | ||||
| 	onTakePicture: ()=> void; | ||||
| 	takingPicture: boolean; | ||||
|  | ||||
| 	cameraReady: boolean; | ||||
| } | ||||
|  | ||||
| const useStyles = () => { | ||||
| 	return useMemo(() => { | ||||
| 		const buttonContainer: ViewStyle = { | ||||
| 			borderRadius: 32, | ||||
| 			minWidth: 60, | ||||
| 			minHeight: 60, | ||||
| 			borderColor: '#00000040', | ||||
| 			borderWidth: 1, | ||||
| 			borderStyle: 'solid', | ||||
| 			backgroundColor: '#ffffff77', | ||||
| 			justifyContent: 'center', | ||||
| 			alignItems: 'center', | ||||
| 			alignSelf: 'flex-end', | ||||
| 		}; | ||||
| 		const buttonRowContainer: ViewStyle = { | ||||
| 			display: 'flex', | ||||
| 			flexDirection: 'row', | ||||
| 			justifyContent: 'space-between', | ||||
| 			left: 20, | ||||
| 			right: 20, | ||||
| 			position: 'absolute', | ||||
| 		}; | ||||
|  | ||||
| 		return StyleSheet.create({ | ||||
| 			buttonRowContainerTop: { | ||||
| 				...buttonRowContainer, | ||||
| 				top: 20, | ||||
| 			}, | ||||
| 			buttonRowContainerBottom: { | ||||
| 				...buttonRowContainer, | ||||
| 				bottom: 20, | ||||
| 			}, | ||||
| 			buttonContainer, | ||||
| 			buttonContent: { | ||||
| 				color: 'black', | ||||
| 				fontSize: 40, | ||||
| 			}, | ||||
| 			buttonPlaceHolder: { | ||||
| 				width: 60, | ||||
| 			}, | ||||
|  | ||||
| 			qrCodeButtonDimmed: { | ||||
| 				...buttonContainer, | ||||
| 				backgroundColor: '#ffffff44', | ||||
| 				borderWidth: 0, | ||||
| 			}, | ||||
|  | ||||
| 			takePhotoButtonContainer: { | ||||
| 				...buttonContainer, | ||||
| 				minWidth: 80, | ||||
| 				minHeight: 80, | ||||
| 				borderRadius: 80, | ||||
| 			}, | ||||
| 			takePhotoButtonContent: { | ||||
| 				color: 'black', | ||||
| 				fontSize: 60, | ||||
| 			}, | ||||
|  | ||||
| 			ratioButtonContainer: { | ||||
| 				...buttonContainer, | ||||
| 				aspectRatio: undefined, | ||||
| 				padding: 12, | ||||
| 			}, | ||||
| 			ratioButtonContent: { | ||||
| 				fontSize: 20, | ||||
| 				color: 'black', | ||||
| 			}, | ||||
| 		}); | ||||
| 	}, []); | ||||
| }; | ||||
|  | ||||
| const ActionButtons: React.FC<Props> = props => { | ||||
| 	const styles = useStyles(); | ||||
| 	const reverseButton = ( | ||||
| 		<IconButton | ||||
| 			iconName='ionicon camera-reverse' | ||||
| 			onPress={props.onCameraReverse} | ||||
| 			description={props.cameraDirection === CameraDirection.Front ? _('Switch to back-facing camera') : _('Switch to front-facing camera')} | ||||
| 			themeId={props.themeId} | ||||
| 			iconStyle={styles.buttonContent} | ||||
| 			containerStyle={styles.buttonContainer} | ||||
| 		/> | ||||
| 	); | ||||
| 	const takePhotoButton = ( | ||||
| 		<IconButton | ||||
| 			iconName={props.takingPicture ? 'ionicon checkmark' : 'ionicon camera'} | ||||
| 			onPress={props.onTakePicture} | ||||
| 			description={props.takingPicture ? _('Processing photo...') : _('Take picture')} | ||||
| 			themeId={props.themeId} | ||||
| 			iconStyle={styles.takePhotoButtonContent} | ||||
| 			containerStyle={styles.takePhotoButtonContainer} | ||||
| 		/> | ||||
| 	); | ||||
| 	const ratioButton = ( | ||||
| 		<IconButton | ||||
| 			themeId={props.themeId} | ||||
| 			iconName={`text ${props.cameraRatio}`} | ||||
| 			onPress={props.onSetCameraRatio} | ||||
| 			iconStyle={styles.ratioButtonContent} | ||||
| 			containerStyle={styles.ratioButtonContainer} | ||||
| 			description={_('Change ratio')} | ||||
| 		/> | ||||
| 	); | ||||
|  | ||||
| 	const cameraActions = ( | ||||
| 		<View style={styles.buttonRowContainerBottom}> | ||||
| 			{reverseButton} | ||||
| 			{takePhotoButton} | ||||
| 			{ | ||||
| 				// Changing ratio is only supported on Android: | ||||
| 				Platform.OS === 'android' ? ratioButton : <View style={styles.buttonPlaceHolder}/> | ||||
| 			} | ||||
| 		</View> | ||||
| 	); | ||||
|  | ||||
|  | ||||
| 	return <> | ||||
| 		<View style={styles.buttonRowContainerTop}> | ||||
| 			<IconButton | ||||
| 				themeId={props.themeId} | ||||
| 				iconName='ionicon arrow-back' | ||||
| 				containerStyle={styles.buttonContainer} | ||||
| 				iconStyle={styles.buttonContent} | ||||
| 				onPress={props.onCancelPhoto} | ||||
| 				description={_('Back')} | ||||
| 			/> | ||||
| 		</View> | ||||
| 		{props.cameraReady ? cameraActions : <ActivityIndicator/>} | ||||
| 	</>; | ||||
| }; | ||||
|  | ||||
| export default ActionButtons; | ||||
| @@ -0,0 +1,40 @@ | ||||
| import * as React from 'react'; | ||||
| import { ForwardedRef, forwardRef, useCallback, useImperativeHandle } from 'react'; | ||||
| import { CameraRef, Props } from './types'; | ||||
| import { PrimaryButton } from '../../buttons'; | ||||
| import { Surface, Text } from 'react-native-paper'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import { TextInput } from 'react-native'; | ||||
|  | ||||
| const Camera = (props: Props, ref: ForwardedRef<CameraRef>) => { | ||||
| 	useImperativeHandle(ref, () => ({ | ||||
| 		takePictureAsync: async () => { | ||||
| 			const path = `${shim.fsDriver().getCacheDirectoryPath()}/test-photo.svg`; | ||||
| 			await shim.fsDriver().writeFile( | ||||
| 				path, | ||||
| 				`<svg viewBox="0 0 232 78" width="232" height="78" version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg"> | ||||
| 					<text style="font-family: serif; font-size: 104px; fill: rgb(128, 51, 128);">Test!</text> | ||||
| 				</svg>`, | ||||
| 				'utf8', | ||||
| 			); | ||||
| 			return { uri: path, type: 'image/svg+xml' }; | ||||
| 		}, | ||||
| 	}), []); | ||||
|  | ||||
| 	const onCodeChange = useCallback((data: string) => { | ||||
| 		props.codeScanner.onBarcodeScanned?.({ | ||||
| 			data, | ||||
| 			type: 'qr', | ||||
| 		}); | ||||
| 	}, [props.codeScanner]); | ||||
|  | ||||
| 	return <Surface elevation={1}> | ||||
| 		<Text>Camera mock</Text> | ||||
| 		<PrimaryButton onPress={props.onPermissionRequestFailure}>Reject permission</PrimaryButton> | ||||
| 		<PrimaryButton onPress={props.onHasPermission}>Accept permission</PrimaryButton> | ||||
| 		<PrimaryButton onPress={props.onCameraReady}>On camera ready</PrimaryButton> | ||||
| 		<TextInput placeholder='QR code data' onChangeText={onCodeChange}/> | ||||
| 	</Surface>; | ||||
| }; | ||||
|  | ||||
| export default forwardRef(Camera); | ||||
							
								
								
									
										57
									
								
								packages/app-mobile/components/CameraView/Camera/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								packages/app-mobile/components/CameraView/Camera/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| import * as React from 'react'; | ||||
| import { CameraDirection } from '@joplin/lib/models/settings/builtInMetadata'; | ||||
| import { BarcodeSettings, CameraRatio, CameraView, useCameraPermissions } from 'expo-camera'; | ||||
| import { ForwardedRef, forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; | ||||
| import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect'; | ||||
| import { CameraRef, Props } from './types'; | ||||
|  | ||||
| const barcodeScannerSettings: BarcodeSettings = { | ||||
| 	// Rocketbook pages use both QR and datamatrix | ||||
| 	barcodeTypes: ['qr', 'datamatrix'], | ||||
| }; | ||||
|  | ||||
| const Camera = (props: Props, ref: ForwardedRef<CameraRef>) => { | ||||
| 	const cameraRef = useRef<CameraView>(null); | ||||
|  | ||||
| 	useImperativeHandle(ref, () => ({ | ||||
| 		takePictureAsync: async () => { | ||||
| 			const result = await cameraRef.current.takePictureAsync(); | ||||
| 			return { | ||||
| 				uri: result.uri, | ||||
| 				type: 'image/jpg', | ||||
| 			}; | ||||
| 		}, | ||||
| 	}), []); | ||||
|  | ||||
| 	const [hasPermission, requestPermission] = useCameraPermissions(); | ||||
| 	useAsyncEffect(async () => { | ||||
| 		try { | ||||
| 			if (!hasPermission?.granted) { | ||||
| 				await requestPermission(); | ||||
| 			} | ||||
| 		} finally { | ||||
| 			if (!!hasPermission && !hasPermission.canAskAgain) { | ||||
| 				props.onPermissionRequestFailure(); | ||||
| 			} | ||||
| 		} | ||||
| 	}, [hasPermission, requestPermission, props.onPermissionRequestFailure]); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		if (hasPermission?.granted) { | ||||
| 			props.onHasPermission(); | ||||
| 		} | ||||
| 	}, [hasPermission, props.onHasPermission]); | ||||
|  | ||||
| 	return <CameraView | ||||
| 		ref={cameraRef} | ||||
| 		style={props.style} | ||||
| 		facing={props.cameraType === CameraDirection.Front ? 'front' : 'back'} | ||||
| 		ratio={props.ratio as CameraRatio} | ||||
| 		onCameraReady={props.onCameraReady} | ||||
| 		animateShutter={false} | ||||
| 		barcodeScannerSettings={barcodeScannerSettings} | ||||
| 		onBarcodeScanned={props.codeScanner.onBarcodeScanned} | ||||
| 	/>; | ||||
| }; | ||||
|  | ||||
| export default forwardRef(Camera); | ||||
							
								
								
									
										19
									
								
								packages/app-mobile/components/CameraView/Camera/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								packages/app-mobile/components/CameraView/Camera/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| import { CameraDirection } from '@joplin/lib/models/settings/builtInMetadata'; | ||||
| import { ViewStyle } from 'react-native'; | ||||
| import { BarcodeScanner } from '../utils/useBarcodeScanner'; | ||||
| import { CameraResult } from '../types'; | ||||
|  | ||||
| export interface Props { | ||||
| 	style: ViewStyle; | ||||
| 	cameraType: CameraDirection; | ||||
| 	ratio: string|undefined; | ||||
| 	codeScanner: BarcodeScanner; | ||||
|  | ||||
| 	onCameraReady: ()=> void; | ||||
| 	onPermissionRequestFailure: ()=> void; | ||||
| 	onHasPermission: ()=> void; | ||||
| } | ||||
|  | ||||
| export interface CameraRef { | ||||
| 	takePictureAsync(): Promise<CameraResult>; | ||||
| } | ||||
| @@ -0,0 +1,88 @@ | ||||
| import * as React from 'react'; | ||||
| import CameraView from './CameraView'; | ||||
| import { CameraResult } from './types'; | ||||
| import { fireEvent, render, screen } from '@testing-library/react-native'; | ||||
| import '@testing-library/jest-native/extend-expect'; | ||||
| import createMockReduxStore from '../../utils/testing/createMockReduxStore'; | ||||
| import TestProviderStack from '../testing/TestProviderStack'; | ||||
|  | ||||
| interface WrapperProps { | ||||
| 	onPhoto?: (result: CameraResult)=> void; | ||||
| 	onInsertBarcode?: (text: string)=> void; | ||||
| 	onCancel?: ()=> void; | ||||
| } | ||||
|  | ||||
| const emptyFn = ()=>{}; | ||||
| const store = createMockReduxStore(); | ||||
| const CameraViewWrapper: React.FC<WrapperProps> = props => { | ||||
| 	return <TestProviderStack store={store}> | ||||
| 		<CameraView | ||||
| 			style={{}} | ||||
| 			onPhoto={props.onPhoto ?? emptyFn} | ||||
| 			onInsertBarcode={props.onInsertBarcode ?? emptyFn} | ||||
| 			onCancel={props.onCancel ?? emptyFn} | ||||
| 		/> | ||||
| 	</TestProviderStack>; | ||||
| }; | ||||
|  | ||||
| const rejectCameraPermission = () => { | ||||
| 	const rejectPermissionButton = screen.getByRole('button', { name: 'Reject permission' }); | ||||
| 	fireEvent.press(rejectPermissionButton); | ||||
| }; | ||||
|  | ||||
| const acceptCameraPermission = () => { | ||||
| 	const acceptPermissionButton = screen.getByRole('button', { name: 'Accept permission' }); | ||||
| 	fireEvent.press(acceptPermissionButton); | ||||
| }; | ||||
|  | ||||
| const startCamera = () => { | ||||
| 	const startCameraButton = screen.getByRole('button', { name: 'On camera ready' }); | ||||
| 	fireEvent.press(startCameraButton); | ||||
| }; | ||||
|  | ||||
| const setQrCodeData = (data: string) => { | ||||
| 	const qrCodeDataInput = screen.getByPlaceholderText('QR code data'); | ||||
| 	fireEvent.changeText(qrCodeDataInput, data); | ||||
| }; | ||||
|  | ||||
| describe('CameraView', () => { | ||||
| 	test('should hide permissions error if camera permission is granted', async () => { | ||||
| 		const view = render(<CameraViewWrapper/>); | ||||
|  | ||||
| 		const queryPermissionsError = () => screen.queryByText('Missing camera permission'); | ||||
|  | ||||
| 		expect(queryPermissionsError()).toBeNull(); | ||||
| 		rejectCameraPermission(); | ||||
| 		expect(queryPermissionsError()).toBeVisible(); | ||||
| 		acceptCameraPermission(); | ||||
| 		expect(queryPermissionsError()).toBeNull(); | ||||
|  | ||||
| 		expect(await screen.findByRole('button', { name: 'Back' })).toBeVisible(); | ||||
| 		startCamera(); | ||||
| 		expect(await screen.findByRole('button', { name: 'Take picture' })).toBeVisible(); | ||||
|  | ||||
| 		view.unmount(); | ||||
| 	}); | ||||
|  | ||||
| 	test('should allow inserting QR code text', async () => { | ||||
| 		const onInsertBarcode = jest.fn(); | ||||
| 		const view = render(<CameraViewWrapper onInsertBarcode={onInsertBarcode}/>); | ||||
| 		acceptCameraPermission(); | ||||
| 		startCamera(); | ||||
|  | ||||
| 		const qrCodeData = 'Test!'; | ||||
| 		setQrCodeData(qrCodeData); | ||||
|  | ||||
| 		const qrCodeButton = await screen.findByRole('button', { name: 'QR Code' }); | ||||
| 		expect(qrCodeButton).toBeVisible(); | ||||
| 		fireEvent.press(qrCodeButton); | ||||
|  | ||||
| 		const addToNoteButton = await screen.findByRole('button', { name: 'Add to note' }); | ||||
| 		fireEvent.press(addToNoteButton); | ||||
|  | ||||
| 		expect(onInsertBarcode).toHaveBeenCalledTimes(1); | ||||
| 		expect(onInsertBarcode).toHaveBeenCalledWith(qrCodeData); | ||||
|  | ||||
| 		view.unmount(); | ||||
| 	}); | ||||
| }); | ||||
							
								
								
									
										216
									
								
								packages/app-mobile/components/CameraView/CameraView.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										216
									
								
								packages/app-mobile/components/CameraView/CameraView.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,216 @@ | ||||
| import * as React from 'react'; | ||||
| import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | ||||
|  | ||||
| import { connect } from 'react-redux'; | ||||
| import { Text, StyleSheet, Linking, View, Platform, useWindowDimensions } from 'react-native'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import { ViewStyle } from 'react-native'; | ||||
| import { AppState } from '../../utils/types'; | ||||
| import ActionButtons from './ActionButtons'; | ||||
| import { CameraDirection } from '@joplin/lib/models/settings/builtInMetadata'; | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
| import { LinkButton, PrimaryButton } from '../buttons'; | ||||
| import BackButtonService from '../../services/BackButtonService'; | ||||
| import { themeStyle } from '../global-style'; | ||||
| import fitRectIntoBounds from './utils/fitRectIntoBounds'; | ||||
| import useBarcodeScanner from './utils/useBarcodeScanner'; | ||||
| import ScannedBarcodes from './ScannedBarcodes'; | ||||
| import { CameraRef } from './Camera/types'; | ||||
| import Camera from './Camera'; | ||||
| import { CameraResult } from './types'; | ||||
|  | ||||
| interface Props { | ||||
| 	themeId: number; | ||||
| 	style: ViewStyle; | ||||
| 	cameraType: CameraDirection; | ||||
| 	cameraRatio: string; | ||||
| 	onPhoto: (data: CameraResult)=> void; | ||||
| 	onCancel: ()=> void; | ||||
| 	onInsertBarcode: (barcodeText: string)=> void; | ||||
| } | ||||
|  | ||||
| interface UseStyleProps { | ||||
| 	themeId: number; | ||||
| 	style: ViewStyle; | ||||
| 	cameraRatio: string; | ||||
| } | ||||
|  | ||||
| const useStyles = ({ themeId, style, cameraRatio }: UseStyleProps) => { | ||||
| 	const { width: screenWidth, height: screenHeight } = useWindowDimensions(); | ||||
| 	const outputPositioning = useMemo((): ViewStyle => { | ||||
| 		const ratioMatch = cameraRatio?.match(/^(\d+):(\d+)$/); | ||||
| 		if (!ratioMatch) { | ||||
| 			return { left: 0, top: 0 }; | ||||
| 		} | ||||
| 		const output = fitRectIntoBounds({ | ||||
| 			width: Number(ratioMatch[2]), | ||||
| 			height: Number(ratioMatch[1]), | ||||
| 		}, { | ||||
| 			width: Math.min(screenWidth, screenHeight), | ||||
| 			height: Math.max(screenWidth, screenHeight), | ||||
| 		}); | ||||
|  | ||||
| 		if (screenWidth > screenHeight) { | ||||
| 			const w = output.width; | ||||
| 			output.width = output.height; | ||||
| 			output.height = w; | ||||
| 		} | ||||
|  | ||||
| 		return { | ||||
| 			left: (screenWidth - output.width) / 2, | ||||
| 			top: (screenHeight - output.height) / 2, | ||||
| 			width: output.width, | ||||
| 			height: output.height, | ||||
| 			flexBasis: output.height, | ||||
| 			flexGrow: 0, | ||||
| 			alignContent: 'center', | ||||
| 		}; | ||||
| 	}, [cameraRatio, screenWidth, screenHeight]); | ||||
|  | ||||
| 	return useMemo(() => { | ||||
| 		const theme = themeStyle(themeId); | ||||
| 		return StyleSheet.create({ | ||||
| 			container: { | ||||
| 				backgroundColor: '#000', | ||||
| 				...style, | ||||
| 			}, | ||||
| 			camera: { | ||||
| 				position: 'relative', | ||||
| 				...outputPositioning, | ||||
| 				...style, | ||||
| 			}, | ||||
| 			errorContainer: { | ||||
| 				position: 'absolute', | ||||
| 				top: 0, | ||||
| 				alignSelf: 'center', | ||||
| 				backgroundColor: theme.backgroundColor, | ||||
| 				maxWidth: 600, | ||||
| 				padding: 28, | ||||
| 				borderRadius: 28, | ||||
| 			}, | ||||
| 		}); | ||||
| 	}, [themeId, style, outputPositioning]); | ||||
| }; | ||||
|  | ||||
| const androidRatios = ['1:1', '4:3', '16:9']; | ||||
| const iOSRatios: string[] = []; | ||||
| const useAvailableRatios = (): string[] => { | ||||
| 	return Platform.OS === 'android' ? androidRatios : iOSRatios; | ||||
| }; | ||||
|  | ||||
|  | ||||
| const CameraViewComponent: React.FC<Props> = props => { | ||||
| 	const styles = useStyles(props); | ||||
| 	const cameraRef = useRef<CameraRef|null>(null); | ||||
| 	const [cameraReady, setCameraReady] = useState(false); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		const handler = () => { | ||||
| 			props.onCancel(); | ||||
| 			return true; | ||||
| 		}; | ||||
| 		BackButtonService.addHandler(handler); | ||||
| 		return () => { | ||||
| 			BackButtonService.removeHandler(handler); | ||||
| 		}; | ||||
| 	}, [props.onCancel]); | ||||
|  | ||||
| 	const onCameraReverse = useCallback(() => { | ||||
| 		const newDirection = props.cameraType === CameraDirection.Front ? CameraDirection.Back : CameraDirection.Front; | ||||
| 		Setting.setValue('camera.type', newDirection); | ||||
| 	}, [props.cameraType]); | ||||
|  | ||||
| 	const availableRatios = useAvailableRatios(); | ||||
| 	const onNextCameraRatio = useCallback(async () => { | ||||
| 		const ratioIndex = Math.max(0, availableRatios.indexOf(props.cameraRatio)); | ||||
|  | ||||
| 		Setting.setValue('camera.ratio', availableRatios[(ratioIndex + 1) % availableRatios.length]); | ||||
| 	}, [props.cameraRatio, availableRatios]); | ||||
|  | ||||
| 	const codeScanner = useBarcodeScanner(); | ||||
|  | ||||
| 	const onCameraReady = useCallback(() => { | ||||
| 		setCameraReady(true); | ||||
| 	}, []); | ||||
|  | ||||
| 	const [takingPicture, setTakingPicture] = useState(false); | ||||
| 	const takingPictureRef = useRef(takingPicture); | ||||
| 	takingPictureRef.current = takingPicture; | ||||
| 	const onTakePicture = useCallback(async () => { | ||||
| 		if (takingPictureRef.current) return; | ||||
| 		setTakingPicture(true); | ||||
| 		try { | ||||
| 			const picture = await cameraRef.current.takePictureAsync(); | ||||
| 			if (picture) { | ||||
| 				props.onPhoto(picture); | ||||
| 			} | ||||
| 		} finally { | ||||
| 			setTakingPicture(false); | ||||
| 		} | ||||
| 	}, [props.onPhoto]); | ||||
|  | ||||
| 	const [permissionRequestFailed, setPermissionRequestFailed] = useState(false); | ||||
| 	const onPermissionRequestFailure = useCallback(() => { | ||||
| 		setPermissionRequestFailed(true); | ||||
| 	}, []); | ||||
| 	const onHasPermission = useCallback(() => { | ||||
| 		setPermissionRequestFailed(false); | ||||
| 	}, []); | ||||
|  | ||||
| 	let overlay; | ||||
| 	if (permissionRequestFailed) { | ||||
| 		overlay = <View style={styles.errorContainer}> | ||||
| 			<Text>{_('Missing camera permission')}</Text> | ||||
| 			<LinkButton onPress={() => Linking.openSettings()}>{_('Open settings')}</LinkButton> | ||||
| 			<PrimaryButton onPress={props.onCancel}>{_('Go back')}</PrimaryButton> | ||||
| 		</View>; | ||||
| 	} else { | ||||
| 		overlay = <> | ||||
| 			<ActionButtons | ||||
| 				themeId={props.themeId} | ||||
| 				onCameraReverse={onCameraReverse} | ||||
| 				cameraDirection={props.cameraType} | ||||
|  | ||||
| 				cameraRatio={props.cameraRatio} | ||||
| 				onSetCameraRatio={onNextCameraRatio} | ||||
|  | ||||
| 				onTakePicture={onTakePicture} | ||||
| 				takingPicture={takingPicture} | ||||
| 				onCancelPhoto={props.onCancel} | ||||
|  | ||||
| 				cameraReady={cameraReady} | ||||
| 			/> | ||||
| 			<ScannedBarcodes | ||||
| 				themeId={props.themeId} | ||||
| 				codeScanner={codeScanner} | ||||
| 				onInsertCode={props.onInsertBarcode} | ||||
| 			/> | ||||
| 		</>; | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| 		<View style={styles.container}> | ||||
| 			<Camera | ||||
| 				ref={cameraRef} | ||||
| 				style={styles.camera} | ||||
| 				cameraType={props.cameraType} | ||||
| 				ratio={availableRatios.includes(props.cameraRatio) ? props.cameraRatio : undefined} | ||||
| 				onCameraReady={onCameraReady} | ||||
| 				codeScanner={codeScanner} | ||||
| 				onPermissionRequestFailure={onPermissionRequestFailure} | ||||
| 				onHasPermission={onHasPermission} | ||||
| 			/> | ||||
| 			{overlay} | ||||
| 		</View> | ||||
| 	); | ||||
| }; | ||||
|  | ||||
| const mapStateToProps = (state: AppState) => { | ||||
| 	return { | ||||
| 		themeId: state.settings.theme, | ||||
| 		cameraRatio: state.settings['camera.ratio'], | ||||
| 		cameraType: state.settings['camera.type'], | ||||
| 	}; | ||||
| }; | ||||
|  | ||||
| export default connect(mapStateToProps)(CameraViewComponent); | ||||
							
								
								
									
										103
									
								
								packages/app-mobile/components/CameraView/ScannedBarcodes.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								packages/app-mobile/components/CameraView/ScannedBarcodes.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,103 @@ | ||||
| import * as React from 'react'; | ||||
| import { useCallback, useMemo, useState } from 'react'; | ||||
| import { Platform, ScrollView, StyleSheet, View } from 'react-native'; | ||||
| import { BarcodeScanner } from './utils/useBarcodeScanner'; | ||||
| import { LinkButton, PrimaryButton } from '../buttons'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import DismissibleDialog, { DialogSize } from '../DismissibleDialog'; | ||||
| import { Chip, Text } from 'react-native-paper'; | ||||
| import { isCallbackUrl, parseCallbackUrl } from '@joplin/lib/callbackUrlUtils'; | ||||
| import CommandService from '@joplin/lib/services/CommandService'; | ||||
|  | ||||
| interface Props { | ||||
| 	themeId: number; | ||||
| 	codeScanner: BarcodeScanner; | ||||
| 	onInsertCode: (codeText: string)=> void; | ||||
| } | ||||
|  | ||||
| const useStyles = () => { | ||||
| 	return useMemo(() => { | ||||
| 		return StyleSheet.create({ | ||||
| 			container: { | ||||
| 				position: 'absolute', | ||||
| 				right: 10, | ||||
| 				top: 10, | ||||
| 			}, | ||||
| 			spacer: { | ||||
| 				flexGrow: 1, | ||||
| 			}, | ||||
| 			scannedCode: { | ||||
| 				padding: 20, | ||||
| 				fontFamily: Platform.select({ | ||||
| 					android: 'monospace', | ||||
| 					ios: 'Courier New', | ||||
| 					default: undefined, | ||||
| 				}), | ||||
| 			}, | ||||
| 		}); | ||||
| 	}, []); | ||||
| }; | ||||
|  | ||||
| const ScannedBarcodes: React.FC<Props> = props => { | ||||
| 	const styles = useStyles(); | ||||
| 	const [dialogVisible, setDialogVisible] = useState(false); | ||||
|  | ||||
| 	const [dismissedAtTime, setDismissedAtTime] = useState(0); | ||||
| 	const onHideCodeNotification = useCallback(() => { | ||||
| 		setDismissedAtTime(performance.now()); | ||||
| 	}, []); | ||||
|  | ||||
| 	const onShowDialog = useCallback(() => { | ||||
| 		setDialogVisible(true); | ||||
| 	}, []); | ||||
| 	const onHideDialog = useCallback(() => { | ||||
| 		setDialogVisible(false); | ||||
| 		onHideCodeNotification(); | ||||
| 	}, [onHideCodeNotification]); | ||||
|  | ||||
| 	const codeScanner = props.codeScanner; | ||||
| 	const scannedText = codeScanner.lastScan?.text; | ||||
|  | ||||
| 	const isLink = useMemo(() => { | ||||
| 		return scannedText && isCallbackUrl(scannedText); | ||||
| 	}, [scannedText]); | ||||
| 	const onFollowLink = useCallback(() => { | ||||
| 		setDialogVisible(false); | ||||
| 		const data = parseCallbackUrl(scannedText); | ||||
| 		if (data && data.params.id) { | ||||
| 			void CommandService.instance().execute('openItem', `:/${data.params.id}`); | ||||
| 		} | ||||
| 	}, [scannedText]); | ||||
| 	const onInsertText = useCallback(() => { | ||||
| 		setDialogVisible(false); | ||||
| 		props.onInsertCode(scannedText); | ||||
| 	}, [scannedText, props.onInsertCode]); | ||||
|  | ||||
| 	const codeChipHidden = !scannedText || dialogVisible || codeScanner.lastScan.timestamp < dismissedAtTime; | ||||
| 	const dialogOpenButton = <Chip icon='qrcode' onPress={onShowDialog} onClose={onHideCodeNotification}> | ||||
| 		{_('QR Code')} | ||||
| 	</Chip>; | ||||
| 	return <View style={styles.container}> | ||||
| 		{codeChipHidden ? null : dialogOpenButton} | ||||
| 		<DismissibleDialog | ||||
| 			visible={dialogVisible} | ||||
| 			onDismiss={onHideDialog} | ||||
| 			themeId={props.themeId} | ||||
| 			size={DialogSize.Small} | ||||
| 		> | ||||
| 			<ScrollView> | ||||
| 				<Text variant='titleMedium' role='heading'>{_('Scanned code')}</Text> | ||||
| 				<Text | ||||
| 					style={styles.scannedCode} | ||||
| 					variant='labelLarge' | ||||
| 					selectable={true} | ||||
| 				>{scannedText}</Text> | ||||
| 			</ScrollView> | ||||
| 			<View style={styles.spacer}/> | ||||
| 			{isLink ? <LinkButton onPress={onFollowLink}>{_('Follow link')}</LinkButton> : null} | ||||
| 			<PrimaryButton onPress={onInsertText}>{_('Add to note')}</PrimaryButton> | ||||
| 		</DismissibleDialog> | ||||
| 	</View>; | ||||
| }; | ||||
|  | ||||
| export default ScannedBarcodes; | ||||
							
								
								
									
										5
									
								
								packages/app-mobile/components/CameraView/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								packages/app-mobile/components/CameraView/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
|  | ||||
| export interface CameraResult { | ||||
| 	uri: string; | ||||
| 	type: string; | ||||
| } | ||||
| @@ -0,0 +1,24 @@ | ||||
| export interface Rect { | ||||
| 	width: number; | ||||
| 	height: number; | ||||
| } | ||||
|  | ||||
| const fitRectIntoBounds = (rect: Rect, bounds: Rect) => { | ||||
| 	const rectRatio = rect.width / rect.height; | ||||
| 	const boundsRatio = bounds.width / bounds.height; | ||||
|  | ||||
| 	const newDimensions: Rect = { width: 0, height: 0 }; | ||||
|  | ||||
| 	// Rect is more landscape than bounds - fit to width | ||||
| 	if (rectRatio > boundsRatio) { | ||||
| 		newDimensions.width = bounds.width; | ||||
| 		newDimensions.height = rect.height * (bounds.width / rect.width); | ||||
| 	} else { // Rect is more portrait than bounds - fit to height | ||||
| 		newDimensions.width = rect.width * (bounds.height / rect.height); | ||||
| 		newDimensions.height = bounds.height; | ||||
| 	} | ||||
|  | ||||
| 	return newDimensions; | ||||
| }; | ||||
|  | ||||
| export default fitRectIntoBounds; | ||||
| @@ -0,0 +1,42 @@ | ||||
| import { useMemo, useState } from 'react'; | ||||
|  | ||||
|  | ||||
| interface ScannedData { | ||||
| 	text: string; | ||||
| 	timestamp: number; | ||||
| } | ||||
|  | ||||
| interface BarcodeScanningResult { | ||||
| 	type: string; | ||||
| 	data: string; | ||||
| } | ||||
|  | ||||
| export interface BarcodeScanner { | ||||
| 	enabled: boolean; | ||||
| 	onToggleEnabled: ()=> void; | ||||
| 	onBarcodeScanned: (scan: BarcodeScanningResult)=> void; | ||||
| 	lastScan: ScannedData|null; | ||||
| } | ||||
|  | ||||
|  | ||||
| const useBarcodeScanner = (): BarcodeScanner => { | ||||
| 	const [lastScan, setLastScan] = useState<ScannedData|null>(null); | ||||
| 	const [enabled, setEnabled] = useState(true); | ||||
| 	return useMemo(() => { | ||||
| 		return { | ||||
| 			enabled, | ||||
| 			onToggleEnabled: () => { | ||||
| 				setEnabled(enabled => !enabled); | ||||
| 			}, | ||||
| 			onBarcodeScanned: enabled ? (scanningResult: BarcodeScanningResult) => { | ||||
| 				setLastScan({ | ||||
| 					text: scanningResult.data ?? 'null', | ||||
| 					timestamp: performance.now(), | ||||
| 				}); | ||||
| 			} : null, | ||||
| 			lastScan, | ||||
| 		}; | ||||
| 	}, [lastScan, enabled]); | ||||
| }; | ||||
|  | ||||
| export default useBarcodeScanner; | ||||
| @@ -3,10 +3,8 @@ import * as React from 'react'; | ||||
| import { describe, it, beforeEach } from '@jest/globals'; | ||||
| import { act, fireEvent, render, screen, userEvent, waitFor } from '@testing-library/react-native'; | ||||
| import '@testing-library/jest-native/extend-expect'; | ||||
| import { Provider } from 'react-redux'; | ||||
|  | ||||
| import NoteScreen from './Note'; | ||||
| import { MenuProvider } from 'react-native-popup-menu'; | ||||
| import { setupDatabaseAndSynchronizer, switchClient, simulateReadOnlyShareEnv, supportDir, synchronizerStart, resourceFetcher, runWithFakeTimers } from '@joplin/lib/testing/test-utils'; | ||||
| import { waitFor as waitForWithRealTimers } from '@joplin/lib/testing/test-utils'; | ||||
| import Note from '@joplin/lib/models/Note'; | ||||
| @@ -14,7 +12,6 @@ import { AppState } from '../../utils/types'; | ||||
| import { Store } from 'redux'; | ||||
| import createMockReduxStore from '../../utils/testing/createMockReduxStore'; | ||||
| import initializeCommandService from '../../utils/initializeCommandService'; | ||||
| import { PaperProvider } from 'react-native-paper'; | ||||
| import getWebViewDomById from '../../utils/testing/getWebViewDomById'; | ||||
| import { NoteEntity } from '@joplin/lib/services/database/types'; | ||||
| import Folder from '@joplin/lib/models/Folder'; | ||||
| @@ -29,6 +26,7 @@ import getWebViewWindowById from '../../utils/testing/getWebViewWindowById'; | ||||
| import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl'; | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
| import Resource from '@joplin/lib/models/Resource'; | ||||
| import TestProviderStack from '../testing/TestProviderStack'; | ||||
|  | ||||
| interface WrapperProps { | ||||
| } | ||||
| @@ -36,13 +34,9 @@ interface WrapperProps { | ||||
| let store: Store<AppState>; | ||||
|  | ||||
| const WrappedNoteScreen: React.FC<WrapperProps> = _props => { | ||||
| 	return <MenuProvider> | ||||
| 		<PaperProvider> | ||||
| 			<Provider store={store}> | ||||
| 				<NoteScreen /> | ||||
| 			</Provider> | ||||
| 		</PaperProvider> | ||||
| 	</MenuProvider>; | ||||
| 	return <TestProviderStack store={store}> | ||||
| 		<NoteScreen /> | ||||
| 	</TestProviderStack>; | ||||
| }; | ||||
|  | ||||
| const getNoteViewerDom = async () => { | ||||
|   | ||||
| @@ -38,7 +38,7 @@ import shared, { BaseNoteScreenComponent, Props as BaseProps } from '@joplin/lib | ||||
| import { Asset, ImagePickerResponse, launchImageLibrary } from 'react-native-image-picker'; | ||||
| import SelectDateTimeDialog from '../SelectDateTimeDialog'; | ||||
| import ShareExtension from '../../utils/ShareExtension.js'; | ||||
| import CameraView from '../CameraView'; | ||||
| import CameraView from '../CameraView/CameraView'; | ||||
| import { FolderEntity, NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| import ImageEditor from '../NoteEditor/ImageEditor/ImageEditor'; | ||||
| @@ -63,6 +63,7 @@ import CommandService from '@joplin/lib/services/CommandService'; | ||||
| import { ResourceInfo } from '../NoteBodyViewer/hooks/useRerenderHandler'; | ||||
| import getImageDimensions from '../../utils/image/getImageDimensions'; | ||||
| import resizeImage from '../../utils/image/resizeImage'; | ||||
| import { CameraResult } from '../CameraView/types'; | ||||
|  | ||||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| const emptyArray: any[] = []; | ||||
| @@ -680,6 +681,39 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | ||||
| 		return await saveOriginalImage(); | ||||
| 	} | ||||
|  | ||||
| 	private async insertText(text: string) { | ||||
| 		const newNote = { ...this.state.note }; | ||||
|  | ||||
| 		if (this.state.mode === 'edit') { | ||||
| 			let newText = ''; | ||||
|  | ||||
| 			if (this.selection) { | ||||
| 				newText = `\n${text}\n`; | ||||
| 				const prefix = newNote.body.substring(0, this.selection.start); | ||||
| 				const suffix = newNote.body.substring(this.selection.end); | ||||
| 				newNote.body = `${prefix}${newText}${suffix}`; | ||||
| 			} else { | ||||
| 				newText = `\n${text}`; | ||||
| 				newNote.body = `${newNote.body}\n${newText}`; | ||||
| 			} | ||||
|  | ||||
| 			if (this.useEditorBeta()) { | ||||
| 				// The beta editor needs to be explicitly informed of changes | ||||
| 				// to the note's body | ||||
| 				if (this.editorRef.current) { | ||||
| 					this.editorRef.current.insertText(newText); | ||||
| 				} else { | ||||
| 					logger.info(`Tried to insert text ${text} to the note when the editor is not visible -- updating the note body instead.`); | ||||
| 				} | ||||
| 			} | ||||
| 		} else { | ||||
| 			newNote.body += `\n${text}`; | ||||
| 		} | ||||
|  | ||||
| 		this.setState({ note: newNote }); | ||||
| 		return newNote; | ||||
| 	} | ||||
|  | ||||
| 	public async attachFile(pickerResponse: Asset, fileType: string): Promise<ResourceEntity|null> { | ||||
| 		if (!pickerResponse) { | ||||
| 			// User has cancelled | ||||
| @@ -753,36 +787,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | ||||
| 		resource = await Resource.save(resource, { isNew: true }); | ||||
|  | ||||
| 		const resourceTag = Resource.markupTag(resource); | ||||
|  | ||||
| 		const newNote = { ...this.state.note }; | ||||
|  | ||||
| 		if (this.state.mode === 'edit') { | ||||
| 			let newText = ''; | ||||
|  | ||||
| 			if (this.selection) { | ||||
| 				newText = `\n${resourceTag}\n`; | ||||
| 				const prefix = newNote.body.substring(0, this.selection.start); | ||||
| 				const suffix = newNote.body.substring(this.selection.end); | ||||
| 				newNote.body = `${prefix}${newText}${suffix}`; | ||||
| 			} else { | ||||
| 				newText = `\n${resourceTag}`; | ||||
| 				newNote.body = `${newNote.body}\n${newText}`; | ||||
| 			} | ||||
|  | ||||
| 			if (this.useEditorBeta()) { | ||||
| 				// The beta editor needs to be explicitly informed of changes | ||||
| 				// to the note's body | ||||
| 				if (this.editorRef.current) { | ||||
| 					this.editorRef.current.insertText(newText); | ||||
| 				} else { | ||||
| 					logger.info(`Tried to attach resource ${resource.id} to the note when the editor is not visible -- updating the note body instead.`); | ||||
| 				} | ||||
| 			} | ||||
| 		} else { | ||||
| 			newNote.body += `\n${resourceTag}`; | ||||
| 		} | ||||
|  | ||||
| 		this.setState({ note: newNote }); | ||||
| 		const newNote = await this.insertText(resourceTag); | ||||
|  | ||||
| 		void this.refreshResource(resource, newNote.body); | ||||
|  | ||||
| @@ -821,19 +826,20 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 	private cameraView_onPhoto(data: any) { | ||||
| 	private cameraView_onPhoto(data: CameraResult) { | ||||
| 		void this.attachFile( | ||||
| 			{ | ||||
| 				uri: data.uri, | ||||
| 				type: 'image/jpg', | ||||
| 			}, | ||||
| 			data, | ||||
| 			'image', | ||||
| 		); | ||||
|  | ||||
| 		this.setState({ showCamera: false }); | ||||
| 	} | ||||
|  | ||||
| 	private cameraView_onInsertBarcode = (data: string) => { | ||||
| 		this.setState({ showCamera: false }); | ||||
| 		void this.insertText(data); | ||||
| 	}; | ||||
|  | ||||
| 	private cameraView_onCancel() { | ||||
| 		this.setState({ showCamera: false }); | ||||
| 	} | ||||
| @@ -1440,7 +1446,12 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | ||||
| 		const isTodo = !!Number(note.is_todo); | ||||
|  | ||||
| 		if (this.state.showCamera) { | ||||
| 			return <CameraView themeId={this.props.themeId} style={{ flex: 1 }} onPhoto={this.cameraView_onPhoto} onCancel={this.cameraView_onCancel} />; | ||||
| 			return <CameraView | ||||
| 				style={{ flex: 1 }} | ||||
| 				onPhoto={this.cameraView_onPhoto} | ||||
| 				onInsertBarcode={this.cameraView_onInsertBarcode} | ||||
| 				onCancel={this.cameraView_onCancel} | ||||
| 			/>; | ||||
| 		} else if (this.state.showImageEditor) { | ||||
| 			return <ImageEditor | ||||
| 				resourceFilename={this.state.imageEditorResourceFilepath} | ||||
|   | ||||
							
								
								
									
										23
									
								
								packages/app-mobile/components/testing/TestProviderStack.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								packages/app-mobile/components/testing/TestProviderStack.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| import * as React from 'react'; | ||||
| import { PaperProvider } from 'react-native-paper'; | ||||
| import { MenuProvider } from 'react-native-popup-menu'; | ||||
| import { Provider } from 'react-redux'; | ||||
| import { Store } from 'redux'; | ||||
| import { AppState } from '../../utils/types'; | ||||
|  | ||||
| interface Props { | ||||
| 	store: Store<AppState>; | ||||
| 	children: React.ReactNode; | ||||
| } | ||||
|  | ||||
| const TestProviderStack: React.FC<Props> = props => { | ||||
| 	return <Provider store={props.store}> | ||||
| 		<MenuProvider> | ||||
| 			<PaperProvider> | ||||
| 				{props.children} | ||||
| 			</PaperProvider> | ||||
| 		</MenuProvider> | ||||
| 	</Provider>; | ||||
| }; | ||||
|  | ||||
| export default TestProviderStack; | ||||
		Reference in New Issue
	
	Block a user