1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-07-16 00:14:34 +02:00

Chore: Mobile: Add internal support for taking multiple pictures from a camera component (#12357)

This commit is contained in:
Henry Heino
2025-06-28 12:01:13 -07:00
committed by GitHub
parent ecc781ee39
commit a33fb575fd
15 changed files with 342 additions and 52 deletions

View File

@ -620,9 +620,12 @@ packages/app-mobile/components/CameraView/Camera/index.js
packages/app-mobile/components/CameraView/Camera/types.js
packages/app-mobile/components/CameraView/CameraView.test.js
packages/app-mobile/components/CameraView/CameraView.js
packages/app-mobile/components/CameraView/CameraViewMultiPage.test.js
packages/app-mobile/components/CameraView/CameraViewMultiPage.js
packages/app-mobile/components/CameraView/ScannedBarcodes.js
packages/app-mobile/components/CameraView/types.js
packages/app-mobile/components/CameraView/utils/fitRectIntoBounds.js
packages/app-mobile/components/CameraView/utils/testing.js
packages/app-mobile/components/CameraView/utils/useBarcodeScanner.js
packages/app-mobile/components/Checkbox.js
packages/app-mobile/components/DialogManager/PromptButton.js
@ -888,6 +891,7 @@ 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/hooks/useBackHandler.js
packages/app-mobile/utils/hooks/useKeyboardState.js
packages/app-mobile/utils/hooks/useOnLongPressProps.js
packages/app-mobile/utils/hooks/useReduceMotionEnabled.js

4
.gitignore vendored
View File

@ -595,9 +595,12 @@ packages/app-mobile/components/CameraView/Camera/index.js
packages/app-mobile/components/CameraView/Camera/types.js
packages/app-mobile/components/CameraView/CameraView.test.js
packages/app-mobile/components/CameraView/CameraView.js
packages/app-mobile/components/CameraView/CameraViewMultiPage.test.js
packages/app-mobile/components/CameraView/CameraViewMultiPage.js
packages/app-mobile/components/CameraView/ScannedBarcodes.js
packages/app-mobile/components/CameraView/types.js
packages/app-mobile/components/CameraView/utils/fitRectIntoBounds.js
packages/app-mobile/components/CameraView/utils/testing.js
packages/app-mobile/components/CameraView/utils/useBarcodeScanner.js
packages/app-mobile/components/Checkbox.js
packages/app-mobile/components/DialogManager/PromptButton.js
@ -863,6 +866,7 @@ 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/hooks/useBackHandler.js
packages/app-mobile/utils/hooks/useKeyboardState.js
packages/app-mobile/utils/hooks/useOnLongPressProps.js
packages/app-mobile/utils/hooks/useReduceMotionEnabled.js

View File

@ -136,17 +136,16 @@ const ActionButtons: React.FC<Props> = props => {
</View>
);
return <>
<View style={styles.buttonRowContainerTop}>
<IconButton
{props.onCancelPhoto && <IconButton
themeId={props.themeId}
iconName='ionicon arrow-back'
containerStyle={styles.buttonContainer}
iconStyle={styles.buttonContent}
onPress={props.onCancelPhoto}
description={_('Back')}
/>
/>}
</View>
{props.cameraReady ? cameraActions : <ActivityIndicator/>}
</>;

View File

@ -9,10 +9,12 @@ import { TextInput } from 'react-native';
const Camera = (props: Props, ref: ForwardedRef<CameraRef>) => {
useImperativeHandle(ref, () => ({
takePictureAsync: async () => {
const path = `${shim.fsDriver().getCacheDirectoryPath()}/test-photo.svg`;
const parentDir = shim.fsDriver().getCacheDirectoryPath();
await shim.fsDriver().mkdir(parentDir);
const path = `${parentDir}/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">
`<svg viewBox="0 -70 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',

View File

@ -4,6 +4,7 @@ import { CameraResult } from './types';
import { fireEvent, render, screen } from '../../utils/testing/testingLibrary';
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
import TestProviderStack from '../testing/TestProviderStack';
import { acceptCameraPermission, rejectCameraPermission, setQrCodeData, startCamera } from './utils/testing';
interface WrapperProps {
onPhoto?: (result: CameraResult)=> void;
@ -24,26 +25,6 @@ const CameraViewWrapper: React.FC<WrapperProps> = props => {
</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/>);
@ -85,3 +66,4 @@ describe('CameraView', () => {
view.unmount();
});
});

View File

@ -1,5 +1,5 @@
import * as React from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { connect } from 'react-redux';
import { Text, StyleSheet, Linking, View, Platform, useWindowDimensions } from 'react-native';
@ -10,15 +10,15 @@ 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';
import Camera from './Camera/index.jest';
import { CameraResult, OnInsertBarcode } from './types';
import Logger from '@joplin/utils/Logger';
import useBackHandler from '../../utils/hooks/useBackHandler';
const logger = Logger.create('CameraView');
@ -28,8 +28,10 @@ interface Props {
cameraType: CameraDirection;
cameraRatio: string;
onPhoto: (data: CameraResult)=> void;
onCancel: ()=> void;
onInsertBarcode: (barcodeText: string)=> void;
// If null, cancelling should be handled by the parent
// component
onCancel: (()=> void)|null;
onInsertBarcode: OnInsertBarcode;
}
interface UseStyleProps {
@ -107,16 +109,7 @@ const CameraViewComponent: React.FC<Props> = 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]);
useBackHandler(props.onCancel);
const onCameraReverse = useCallback(() => {
const newDirection = props.cameraType === CameraDirection.Front ? CameraDirection.Back : CameraDirection.Front;
@ -166,7 +159,7 @@ const CameraViewComponent: React.FC<Props> = props => {
overlay = <View style={styles.errorContainer}>
<Text>{_('Missing camera permission')}</Text>
<LinkButton onPress={() => Linking.openSettings()}>{_('Open settings')}</LinkButton>
<PrimaryButton onPress={props.onCancel}>{_('Go back')}</PrimaryButton>
{props.onCancel && <PrimaryButton onPress={props.onCancel}>{_('Go back')}</PrimaryButton>}
</View>;
} else {
overlay = <>

View File

@ -0,0 +1,85 @@
import * as React from 'react';
import Setting from '@joplin/lib/models/Setting';
import CameraViewMultiPage, { OnComplete } from './CameraViewMultiPage';
import { CameraResult, OnInsertBarcode } from './types';
import { Store } from 'redux';
import { AppState } from '../../utils/types';
import TestProviderStack from '../testing/TestProviderStack';
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
import { fireEvent, render, screen, waitFor } from '@testing-library/react-native';
import { startCamera, takePhoto } from './utils/testing';
interface WrapperProps {
onCancel?: ()=> void;
onInsertBarcode?: OnInsertBarcode;
onComplete?: OnComplete;
}
let store: Store<AppState>;
const WrappedCamera: React.FC<WrapperProps> = ({
onCancel = jest.fn(),
onComplete = jest.fn(),
onInsertBarcode = jest.fn(),
}) => {
return <TestProviderStack store={store}>
<CameraViewMultiPage
themeId={Setting.THEME_LIGHT}
onCancel={onCancel}
onComplete={onComplete}
onInsertBarcode={onInsertBarcode}
/>
</TestProviderStack>;
};
const getNextButton = () => screen.getByRole('button', { name: 'Next' });
const queryPhotoCount = () => screen.queryByTestId('photo-count');
describe('CameraViewMultiPage', () => {
beforeEach(() => {
store = createMockReduxStore();
});
test('next button should be disabled until a photo has been taken', async () => {
render(<WrappedCamera/>);
expect(getNextButton()).toBeDisabled();
startCamera();
// Should still be disabled after starting the camera
expect(getNextButton()).toBeDisabled();
await takePhoto();
await waitFor(() => {
expect(getNextButton()).not.toBeDisabled();
});
});
test('should show a count of the number of photos taken', async () => {
render(<WrappedCamera/>);
startCamera();
expect(queryPhotoCount()).toBeNull();
for (let i = 1; i < 3; i++) {
await takePhoto();
await waitFor(() => {
expect(queryPhotoCount()).toHaveTextContent(String(i));
});
}
});
test('pressing "Next" should call onComplete with photo URI(s)', async () => {
const onComplete = jest.fn();
render(<WrappedCamera onComplete={onComplete}/>);
startCamera();
await takePhoto();
await waitFor(() => {
expect(getNextButton()).not.toBeDisabled();
});
fireEvent.press(getNextButton());
const imageResults: CameraResult[] = onComplete.mock.lastCall[0];
expect(imageResults).toHaveLength(1);
expect(imageResults[0].uri).toBeTruthy();
});
});

View File

@ -0,0 +1,146 @@
import * as React from 'react';
import { CameraResult } from './types';
import { View, StyleSheet, Platform, ImageBackground, ViewStyle, TextStyle } from 'react-native';
import CameraView from './CameraView';
import { useCallback, useMemo, useState } from 'react';
import { themeStyle } from '../global-style';
import { Button, Text } from 'react-native-paper';
import { _ } from '@joplin/lib/locale';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import shim from '@joplin/lib/shim';
export type OnComplete = (photos: CameraResult[])=> void;
interface Props {
themeId: number;
onCancel: ()=> void;
onComplete: OnComplete;
onInsertBarcode: (barcodeText: string)=> void;
}
const useStyle = (themeId: number) => {
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
camera: {
flex: 1,
},
root: {
flex: 1,
backgroundColor: theme.backgroundColor,
},
bottomRow: {
flexDirection: 'row',
alignItems: 'center',
},
photoWrapper: {
flexGrow: 1,
minHeight: 82,
flexDirection: 'row',
justifyContent: 'center',
},
imagePreview: {
maxWidth: 70,
flexShrink: 1,
flexGrow: 1,
alignContent: 'center',
justifyContent: 'center',
},
imageCountText: {
marginLeft: 'auto',
marginRight: 'auto',
marginTop: 'auto',
padding: 2,
borderRadius: 4,
backgroundColor: theme.backgroundColor2,
color: theme.color2,
},
});
}, [themeId]);
};
interface PhotoProps {
source: CameraResult;
backgroundStyle: ViewStyle;
textStyle: TextStyle;
label: number;
}
const PhotoPreview: React.FC<PhotoProps> = ({ source, label, backgroundStyle, textStyle }) => {
const [uri, setUri] = useState('');
useAsyncEffect(async (event) => {
if (Platform.OS === 'web') {
const file = await shim.fsDriver().fileAtPath(source.uri);
if (event.cancelled) return;
const uri = URL.createObjectURL(file);
setUri(uri);
event.onCleanup(() => {
URL.revokeObjectURL(uri);
});
} else {
setUri(source.uri);
}
}, [source]);
return <ImageBackground
style={backgroundStyle}
resizeMode='contain'
source={{ uri }}
accessibilityLabel={_('%d photo(s) taken', label)}
>
<Text
style={textStyle}
testID='photo-count'
>{label}</Text>
</ImageBackground>;
};
const CameraViewMultiPage: React.FC<Props> = ({
onInsertBarcode, onCancel, onComplete, themeId,
}) => {
const [photos, setPhotos] = useState<CameraResult[]>([]);
const onPhoto = useCallback((data: CameraResult) => {
setPhotos(photos => [...photos, data]);
}, []);
const onDonePressed = useCallback(() => {
onComplete(photos);
}, [photos, onComplete]);
const styles = useStyle(themeId);
const renderLastPhoto = () => {
if (!photos.length) return null;
return <PhotoPreview
label={photos.length}
source={photos[photos.length - 1]}
backgroundStyle={styles.imagePreview}
textStyle={styles.imageCountText}
/>;
};
return <View style={styles.root}>
<CameraView
onCancel={null}
onInsertBarcode={onInsertBarcode}
style={styles.camera}
onPhoto={onPhoto}
/>
<View style={styles.bottomRow}>
<Button icon='arrow-left' onPress={onCancel}>{_('Back')}</Button>
<View style={styles.photoWrapper}>
{renderLastPhoto()}
</View>
<Button
icon='arrow-right'
disabled={photos.length === 0}
onPress={onDonePressed}
>{_('Next')}</Button>
</View>
</View>;
};
export default CameraViewMultiPage;

View File

@ -8,11 +8,12 @@ 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';
import { OnInsertBarcode } from './types';
interface Props {
themeId: number;
codeScanner: BarcodeScanner;
onInsertCode: (codeText: string)=> void;
onInsertCode: OnInsertBarcode;
}
const useStyles = () => {

View File

@ -1,4 +1,6 @@
export type OnInsertBarcode = (barcodeText: string)=> void;
export interface CameraResult {
uri: string;
type: string;

View File

@ -0,0 +1,28 @@
// Utilities for use with the CameraView.jest.tsx mock
import { fireEvent, screen } from '@testing-library/react-native';
export const rejectCameraPermission = () => {
const rejectPermissionButton = screen.getByRole('button', { name: 'Reject permission' });
fireEvent.press(rejectPermissionButton);
};
export const acceptCameraPermission = () => {
const acceptPermissionButton = screen.getByRole('button', { name: 'Accept permission' });
fireEvent.press(acceptPermissionButton);
};
export const startCamera = () => {
const startCameraButton = screen.getByRole('button', { name: 'On camera ready' });
fireEvent.press(startCameraButton);
};
export const takePhoto = async () => {
const takePhotoButton = await screen.findByRole('button', { name: 'Take photo' });
fireEvent.press(takePhotoButton);
};
export const setQrCodeData = (data: string) => {
const qrCodeDataInput = screen.getByPlaceholderText('QR code data');
fireEvent.changeText(qrCodeDataInput, data);
};

View File

@ -34,7 +34,6 @@ import { themeStyle, editorFont } from '../../global-style';
import shared, { BaseNoteScreenComponent, Props as BaseProps } from '@joplin/lib/components/shared/note-screen-shared';
import SelectDateTimeDialog from '../../SelectDateTimeDialog';
import ShareExtension from '../../../utils/ShareExtension.js';
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';
@ -68,6 +67,7 @@ import getActivePluginEditorView from '@joplin/lib/services/plugins/utils/getAct
import EditorPluginHandler from '@joplin/lib/services/plugins/EditorPluginHandler';
import AudioRecordingBanner from '../../voiceTyping/AudioRecordingBanner';
import SpeechToTextBanner from '../../voiceTyping/SpeechToTextBanner';
import CameraView from '../../CameraView/CameraView';
import ShareNoteDialog from '../ShareNoteDialog';
import stateToWhenClauseContext from '../../../services/commands/stateToWhenClauseContext';
import { defaultWindowId } from '@joplin/lib/reducer';
@ -837,6 +837,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
pickerResponse: PickerResponse,
fileType: string,
): Promise<ResourceEntity|null> {
logger.debug('Attaching file:', pickerResponse?.uri);
if (!pickerResponse) {
// User has cancelled
return null;
@ -918,11 +919,17 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
return resource;
}
private cameraView_onPhoto(data: CameraResult) {
void this.attachFile(
data,
private async cameraView_onPhoto(data: CameraResult|CameraResult[]) {
if (!Array.isArray(data)) {
data = [data];
}
for (const item of data) {
await this.attachFile(
item,
'image',
);
}
this.setState({ showCamera: false });
}
@ -1524,10 +1531,10 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
if (this.state.showCamera) {
return <CameraView
style={{ flex: 1 }}
onPhoto={this.cameraView_onPhoto}
onInsertBarcode={this.cameraView_onInsertBarcode}
onCancel={this.cameraView_onCancel}
style={{ flex: 1 }}
/>;
} else if (this.state.showImageEditor) {
return <ImageEditor

View File

@ -48,7 +48,7 @@ const useVoiceTyping = ({ locale, provider, onSetPreview, onText }: UseVoiceTypi
const [redownloadCounter, setRedownloadCounter] = useState(0);
useQueuedAsyncEffect(async (event: AsyncEffectEvent) => {
useQueuedAsyncEffect(async (event) => {
try {
// Reset the error: If starting voice typing again resolves the error, the error
// should be hidden (and voice typing should start).

View File

@ -0,0 +1,20 @@
import { useEffect } from 'react';
import BackButtonService from '../../services/BackButtonService';
type OnBackPress = ()=>(void|boolean);
const useBackHandler = (onBackPress: OnBackPress|null) => {
useEffect(() => {
if (!onBackPress) return () => {};
const handler = () => {
return !!(onBackPress() ?? true);
};
BackButtonService.addHandler(handler);
return () => {
BackButtonService.removeHandler(handler);
};
}, [onBackPress]);
};
export default useBackHandler;

View File

@ -1,8 +1,11 @@
import shim from '../shim';
const { useEffect } = shim.react();
type CleanupCallback = ()=> void;
export interface AsyncEffectEvent {
cancelled: boolean;
onCleanup: (callback: CleanupCallback)=> void;
}
export type EffectFunction = (event: AsyncEffectEvent)=> Promise<void>;
@ -10,10 +13,24 @@ export type EffectFunction = (event: AsyncEffectEvent)=> Promise<void>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export default function(effect: EffectFunction, dependencies: any[]) {
useEffect(() => {
const event: AsyncEffectEvent = { cancelled: false };
const onCleanupCallbacks: CleanupCallback[] = [];
const event: AsyncEffectEvent = {
cancelled: false,
onCleanup: (callback) => {
if (event.cancelled) {
callback();
} else {
onCleanupCallbacks.push(callback);
}
},
};
void effect(event);
return () => {
event.cancelled = true;
for (const callback of onCleanupCallbacks) {
callback();
}
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, dependencies);