1
0
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:
Henry Heino
2024-10-30 14:12:27 -07:00
committed by GitHub
parent 441021bb7e
commit 100f8a23f4
33 changed files with 2995 additions and 465 deletions

View File

@@ -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);

View 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;

View File

@@ -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);

View 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);

View 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>;
}

View File

@@ -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();
});
});

View 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);

View 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;

View File

@@ -0,0 +1,5 @@
export interface CameraResult {
uri: string;
type: string;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 () => {

View File

@@ -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}

View 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;