You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-10 22:11:50 +02:00
Mobile: Add support for scanning multi-page documents (#12635)
This commit is contained in:
@@ -625,8 +625,10 @@ packages/app-mobile/components/CameraView/Camera/index.web.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/CameraView.web.js
|
||||
packages/app-mobile/components/CameraView/CameraViewMultiPage.test.js
|
||||
packages/app-mobile/components/CameraView/CameraViewMultiPage.js
|
||||
packages/app-mobile/components/CameraView/PhotoPreview.js
|
||||
packages/app-mobile/components/CameraView/ScannedBarcodes.js
|
||||
packages/app-mobile/components/CameraView/types.js
|
||||
packages/app-mobile/components/CameraView/utils/fitRectIntoBounds.js
|
||||
@@ -637,6 +639,7 @@ packages/app-mobile/components/ComboBox.test.js
|
||||
packages/app-mobile/components/ComboBox.js
|
||||
packages/app-mobile/components/DialogManager/PromptButton.js
|
||||
packages/app-mobile/components/DialogManager/PromptDialog.js
|
||||
packages/app-mobile/components/DialogManager/TextInputDialog.js
|
||||
packages/app-mobile/components/DialogManager/hooks/useDialogControl.js
|
||||
packages/app-mobile/components/DialogManager/index.js
|
||||
packages/app-mobile/components/DialogManager/types.js
|
||||
@@ -815,6 +818,8 @@ packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useRepoApi.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useUpdateState.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/types.js
|
||||
packages/app-mobile/components/screens/DocumentScanner/DocumentScanner.js
|
||||
packages/app-mobile/components/screens/DocumentScanner/NotePreview.js
|
||||
packages/app-mobile/components/screens/JoplinCloudLoginScreen.js
|
||||
packages/app-mobile/components/screens/LogScreen.js
|
||||
packages/app-mobile/components/screens/Note/Note.test.js
|
||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@@ -600,8 +600,10 @@ packages/app-mobile/components/CameraView/Camera/index.web.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/CameraView.web.js
|
||||
packages/app-mobile/components/CameraView/CameraViewMultiPage.test.js
|
||||
packages/app-mobile/components/CameraView/CameraViewMultiPage.js
|
||||
packages/app-mobile/components/CameraView/PhotoPreview.js
|
||||
packages/app-mobile/components/CameraView/ScannedBarcodes.js
|
||||
packages/app-mobile/components/CameraView/types.js
|
||||
packages/app-mobile/components/CameraView/utils/fitRectIntoBounds.js
|
||||
@@ -612,6 +614,7 @@ packages/app-mobile/components/ComboBox.test.js
|
||||
packages/app-mobile/components/ComboBox.js
|
||||
packages/app-mobile/components/DialogManager/PromptButton.js
|
||||
packages/app-mobile/components/DialogManager/PromptDialog.js
|
||||
packages/app-mobile/components/DialogManager/TextInputDialog.js
|
||||
packages/app-mobile/components/DialogManager/hooks/useDialogControl.js
|
||||
packages/app-mobile/components/DialogManager/index.js
|
||||
packages/app-mobile/components/DialogManager/types.js
|
||||
@@ -790,6 +793,8 @@ packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useRepoApi.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useUpdateState.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/types.js
|
||||
packages/app-mobile/components/screens/DocumentScanner/DocumentScanner.js
|
||||
packages/app-mobile/components/screens/DocumentScanner/NotePreview.js
|
||||
packages/app-mobile/components/screens/JoplinCloudLoginScreen.js
|
||||
packages/app-mobile/components/screens/LogScreen.js
|
||||
packages/app-mobile/components/screens/Note/Note.test.js
|
||||
|
@@ -15,6 +15,7 @@ const Camera = (props: Props, ref: ForwardedRef<CameraRef>) => {
|
||||
await shim.fsDriver().writeFile(
|
||||
path,
|
||||
`<svg viewBox="0 -70 232 78" width="232" height="78" version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="232" height="78" y="-70" rx="32" style="fill: #ccc;"/>
|
||||
<text style="font-family: serif; font-size: 104px; fill: rgb(128, 51, 128);">Test!</text>
|
||||
</svg>`,
|
||||
'utf8',
|
||||
|
@@ -16,24 +16,12 @@ import useBarcodeScanner from './utils/useBarcodeScanner';
|
||||
import ScannedBarcodes from './ScannedBarcodes';
|
||||
import { CameraRef } from './Camera/types';
|
||||
import Camera from './Camera/index';
|
||||
import { CameraResult, OnInsertBarcode } from './types';
|
||||
import { CameraViewProps } from './types';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import useBackHandler from '../../utils/hooks/useBackHandler';
|
||||
|
||||
const logger = Logger.create('CameraView');
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
style: ViewStyle;
|
||||
cameraType: CameraDirection;
|
||||
cameraRatio: string;
|
||||
onPhoto: (data: CameraResult)=> void;
|
||||
// If null, cancelling should be handled by the parent
|
||||
// component
|
||||
onCancel: (()=> void)|null;
|
||||
onInsertBarcode: OnInsertBarcode;
|
||||
}
|
||||
|
||||
interface UseStyleProps {
|
||||
themeId: number;
|
||||
style: ViewStyle;
|
||||
@@ -104,7 +92,7 @@ const useAvailableRatios = (): string[] => {
|
||||
};
|
||||
|
||||
|
||||
const CameraViewComponent: React.FC<Props> = props => {
|
||||
const CameraViewComponent: React.FC<CameraViewProps> = props => {
|
||||
const styles = useStyles(props);
|
||||
const cameraRef = useRef<CameraRef|null>(null);
|
||||
const [cameraReady, setCameraReady] = useState(false);
|
||||
|
58
packages/app-mobile/components/CameraView/CameraView.web.tsx
Normal file
58
packages/app-mobile/components/CameraView/CameraView.web.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { AppState } from '../../utils/types';
|
||||
import { PrimaryButton } from '../buttons';
|
||||
import { themeStyle } from '../global-style';
|
||||
import { CameraViewProps } from './types';
|
||||
import pickDocument from '../../utils/pickDocument';
|
||||
|
||||
const useStyles = (themeId: number) => {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: theme.backgroundColor,
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
}, [themeId]);
|
||||
};
|
||||
|
||||
const CameraViewComponent: React.FC<CameraViewProps> = props => {
|
||||
const styles = useStyles(props.themeId);
|
||||
|
||||
const onUploadPress = useCallback(async () => {
|
||||
const response = await pickDocument({ preferCamera: true });
|
||||
for (const asset of response) {
|
||||
props.onPhoto({
|
||||
uri: asset.uri,
|
||||
type: asset.type,
|
||||
});
|
||||
}
|
||||
}, [props.onPhoto]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<PrimaryButton
|
||||
icon='file-upload'
|
||||
onPress={onUploadPress}
|
||||
>{_('Upload photo')}</PrimaryButton>
|
||||
</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);
|
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import CameraViewMultiPage, { OnComplete } from './CameraViewMultiPage';
|
||||
import CameraViewMultiPage from './CameraViewMultiPage';
|
||||
import { CameraResult, OnInsertBarcode } from './types';
|
||||
import { Store } from 'redux';
|
||||
import { AppState } from '../../utils/types';
|
||||
@@ -8,24 +8,29 @@ 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';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface WrapperProps {
|
||||
onComplete?: (finalPhotos: CameraResult[])=> void;
|
||||
onCancel?: ()=> void;
|
||||
onInsertBarcode?: OnInsertBarcode;
|
||||
onComplete?: OnComplete;
|
||||
}
|
||||
|
||||
let store: Store<AppState>;
|
||||
const WrappedCamera: React.FC<WrapperProps> = ({
|
||||
onCancel = jest.fn(),
|
||||
onComplete = jest.fn(),
|
||||
onInsertBarcode = jest.fn(),
|
||||
onCancel = jest.fn(),
|
||||
}) => {
|
||||
const [photos, setPhotos] = useState<CameraResult[]>([]);
|
||||
|
||||
return <TestProviderStack store={store}>
|
||||
<CameraViewMultiPage
|
||||
themeId={Setting.THEME_LIGHT}
|
||||
photos={photos}
|
||||
onSetPhotos={setPhotos}
|
||||
onCancel={onCancel}
|
||||
onComplete={onComplete}
|
||||
onComplete={() => onComplete(photos)}
|
||||
onInsertBarcode={onInsertBarcode}
|
||||
/>
|
||||
</TestProviderStack>;
|
||||
|
@@ -1,20 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import { CameraResult } from './types';
|
||||
import { View, StyleSheet, Platform, ImageBackground, ViewStyle, TextStyle } from 'react-native';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import CameraView from './CameraView';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { themeStyle } from '../global-style';
|
||||
import { Button, Text } from 'react-native-paper';
|
||||
import { Button } from 'react-native-paper';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import PhotoPreview from './PhotoPreview';
|
||||
|
||||
export type OnComplete = (photos: CameraResult[])=> void;
|
||||
export type OnPhotosChange = (photos: CameraResult[])=> void;
|
||||
export type OnComplete = ()=> void;
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
onCancel: ()=> void;
|
||||
onComplete: OnComplete;
|
||||
photos: CameraResult[];
|
||||
onSetPhotos: OnPhotosChange;
|
||||
onInsertBarcode: (barcodeText: string)=> void;
|
||||
}
|
||||
|
||||
@@ -42,17 +44,8 @@ const useStyle = (themeId: number) => {
|
||||
|
||||
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,
|
||||
},
|
||||
@@ -60,55 +53,13 @@ const useStyle = (themeId: number) => {
|
||||
}, [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,
|
||||
onInsertBarcode, onCancel, onComplete, themeId, photos, onSetPhotos,
|
||||
}) => {
|
||||
const [photos, setPhotos] = useState<CameraResult[]>([]);
|
||||
const onPhoto = useCallback((data: CameraResult) => {
|
||||
setPhotos(photos => [...photos, data]);
|
||||
}, []);
|
||||
|
||||
const onDonePressed = useCallback(() => {
|
||||
onComplete(photos);
|
||||
}, [photos, onComplete]);
|
||||
onSetPhotos([...photos, data]);
|
||||
}, [photos, onSetPhotos]);
|
||||
|
||||
const styles = useStyle(themeId);
|
||||
const renderLastPhoto = () => {
|
||||
@@ -137,7 +88,7 @@ const CameraViewMultiPage: React.FC<Props> = ({
|
||||
<Button
|
||||
icon='arrow-right'
|
||||
disabled={photos.length === 0}
|
||||
onPress={onDonePressed}
|
||||
onPress={onComplete}
|
||||
>{_('Next')}</Button>
|
||||
</View>
|
||||
</View>;
|
||||
|
69
packages/app-mobile/components/CameraView/PhotoPreview.tsx
Normal file
69
packages/app-mobile/components/CameraView/PhotoPreview.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import * as React from 'react';
|
||||
import { ViewStyle, TextStyle, Platform, ImageBackground, Text, StyleSheet } from 'react-native';
|
||||
import { useState } from 'react';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import { CameraResult } from './types';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
interface PhotoProps {
|
||||
source: CameraResult;
|
||||
backgroundStyle: ViewStyle;
|
||||
textStyle: TextStyle;
|
||||
label: number;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
background: {
|
||||
maxWidth: 70,
|
||||
flexShrink: 1,
|
||||
flexGrow: 1,
|
||||
alignContent: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
text: {
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
marginTop: 'auto',
|
||||
padding: 4,
|
||||
borderRadius: 32,
|
||||
color: 'white',
|
||||
backgroundColor: '#11c',
|
||||
},
|
||||
});
|
||||
|
||||
const PhotoPreview: React.FC<PhotoProps> = ({ source, label, backgroundStyle, textStyle }) => {
|
||||
const [uri, setUri] = useState('');
|
||||
|
||||
useAsyncEffect(async (event) => {
|
||||
if (!source) {
|
||||
setUri('');
|
||||
} else 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={[styles.background, backgroundStyle]}
|
||||
resizeMode='contain'
|
||||
source={{ uri }}
|
||||
accessibilityLabel={_('%d photo(s) taken', label)}
|
||||
>
|
||||
<Text
|
||||
style={[styles.text, textStyle]}
|
||||
testID='photo-count'
|
||||
>{label}</Text>
|
||||
</ImageBackground>;
|
||||
};
|
||||
|
||||
export default PhotoPreview;
|
@@ -1,3 +1,5 @@
|
||||
import type { CameraDirection } from '@joplin/lib/models/settings/builtInMetadata';
|
||||
import type { ViewStyle } from 'react-native';
|
||||
|
||||
export type OnInsertBarcode = (barcodeText: string)=> void;
|
||||
|
||||
@@ -5,3 +7,15 @@ export interface CameraResult {
|
||||
uri: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface CameraViewProps {
|
||||
themeId: number;
|
||||
style: ViewStyle;
|
||||
cameraType: CameraDirection;
|
||||
cameraRatio: string;
|
||||
onPhoto: (data: CameraResult)=> void;
|
||||
// If null, cancelling should be handled by the parent
|
||||
// component
|
||||
onCancel: (()=> void)|null;
|
||||
onInsertBarcode: OnInsertBarcode;
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { AccessibilityInfo, NativeSyntheticEvent, Platform, Role, StyleSheet, TextInput, TextInputProps, useWindowDimensions, View, ViewProps, ViewStyle } from 'react-native';
|
||||
import { AccessibilityInfo, NativeSyntheticEvent, Platform, Role, ScrollViewProps, StyleSheet, TextInput, TextInputProps, useWindowDimensions, View, ViewProps, ViewStyle } from 'react-native';
|
||||
import { TouchableRipple, Text } from 'react-native-paper';
|
||||
import { connect } from 'react-redux';
|
||||
import { AppState } from '../utils/types';
|
||||
@@ -35,6 +35,7 @@ interface BaseProps {
|
||||
onItemSelected: OnItemSelected;
|
||||
style: ViewStyle;
|
||||
searchInputProps?: TextInputProps;
|
||||
searchResultProps?: ScrollViewProps;
|
||||
}
|
||||
|
||||
type OnAddItem = (content: string)=> void;
|
||||
@@ -478,6 +479,7 @@ const ComboBox: React.FC<Props> = ({
|
||||
style: rootStyle,
|
||||
alwaysExpand,
|
||||
searchInputProps,
|
||||
searchResultProps,
|
||||
}) => {
|
||||
const [search, setSearch] = useState('');
|
||||
const { showSearchResults, setShowSearchResults } = useShowSearchResults(alwaysExpand, search);
|
||||
@@ -540,6 +542,7 @@ const ComboBox: React.FC<Props> = ({
|
||||
const searchResults = <NestableFlatList
|
||||
ref={listRef}
|
||||
data={results}
|
||||
{...searchResultProps}
|
||||
|
||||
CellRendererComponent={SearchResultWrapper}
|
||||
itemHeight={menuItemHeight}
|
||||
|
@@ -1,28 +1,19 @@
|
||||
import * as React from 'react';
|
||||
import { Dialog, Divider, Surface, Text } from 'react-native-paper';
|
||||
import { DialogType, PromptDialogData } from './types';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { DialogType, ButtonDialogData } from './types';
|
||||
import { StyleSheet, ViewStyle } from 'react-native';
|
||||
import { useMemo } from 'react';
|
||||
import { themeStyle } from '../global-style';
|
||||
import PromptButton from './PromptButton';
|
||||
|
||||
interface Props {
|
||||
dialog: PromptDialogData;
|
||||
dialog: ButtonDialogData;
|
||||
containerStyle: ViewStyle;
|
||||
themeId: number;
|
||||
}
|
||||
|
||||
const useStyles = (themeId: number, isMenu: boolean) => {
|
||||
const useStyles = (isMenu: boolean) => {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
return StyleSheet.create({
|
||||
dialogContainer: {
|
||||
backgroundColor: theme.backgroundColor,
|
||||
borderRadius: theme.borderRadius,
|
||||
paddingTop: theme.borderRadius,
|
||||
marginLeft: 4,
|
||||
marginRight: 4,
|
||||
},
|
||||
|
||||
dialogContent: {
|
||||
paddingBottom: 14,
|
||||
@@ -40,12 +31,12 @@ const useStyles = (themeId: number, isMenu: boolean) => {
|
||||
textAlign: isMenu ? 'center' : undefined,
|
||||
},
|
||||
});
|
||||
}, [themeId, isMenu]);
|
||||
}, [isMenu]);
|
||||
};
|
||||
|
||||
const PromptDialog: React.FC<Props> = ({ dialog, themeId }) => {
|
||||
const PromptDialog: React.FC<Props> = ({ dialog, containerStyle, themeId }) => {
|
||||
const isMenu = dialog.type === DialogType.Menu;
|
||||
const styles = useStyles(themeId, isMenu);
|
||||
const styles = useStyles(isMenu);
|
||||
|
||||
const buttons = dialog.buttons.map((button, index) => {
|
||||
return <PromptButton
|
||||
@@ -63,7 +54,7 @@ const PromptDialog: React.FC<Props> = ({ dialog, themeId }) => {
|
||||
return (
|
||||
<Surface
|
||||
testID={'prompt-dialog'}
|
||||
style={styles.dialogContainer}
|
||||
style={containerStyle}
|
||||
key={dialog.key}
|
||||
elevation={1}
|
||||
>
|
||||
|
@@ -0,0 +1,76 @@
|
||||
import * as React from 'react';
|
||||
import { Dialog, Surface, Text } from 'react-native-paper';
|
||||
import { TextInputDialogData } from './types';
|
||||
import { StyleSheet, ViewStyle } from 'react-native';
|
||||
import { useId, useMemo, useState } from 'react';
|
||||
import PromptButton from './PromptButton';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import TextInput from '../TextInput';
|
||||
|
||||
interface Props {
|
||||
dialog: TextInputDialogData;
|
||||
containerStyle: ViewStyle;
|
||||
themeId: number;
|
||||
}
|
||||
|
||||
const useStyles = () => {
|
||||
return useMemo(() => {
|
||||
return StyleSheet.create({
|
||||
dialogContent: {
|
||||
paddingBottom: 14,
|
||||
},
|
||||
dialogActions: {
|
||||
paddingBottom: 14,
|
||||
paddingTop: 4,
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
};
|
||||
|
||||
const TextInputDialog: React.FC<Props> = ({ dialog, containerStyle, themeId }) => {
|
||||
const styles = useStyles();
|
||||
const [text, setText] = useState('');
|
||||
const labelId = useId();
|
||||
|
||||
return (
|
||||
<Surface
|
||||
testID={'prompt-dialog'}
|
||||
style={containerStyle}
|
||||
key={dialog.key}
|
||||
elevation={1}
|
||||
>
|
||||
<Dialog.Content style={styles.dialogContent}>
|
||||
<Text
|
||||
variant='bodyMedium'
|
||||
nativeID={labelId}
|
||||
>{dialog.message}</Text>
|
||||
<TextInput
|
||||
aria-labelledby={labelId}
|
||||
themeId={themeId}
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
/>
|
||||
</Dialog.Content>
|
||||
<Dialog.Actions
|
||||
style={styles.dialogActions}
|
||||
>
|
||||
<PromptButton
|
||||
buttonSpec={{
|
||||
text: _('Cancel'),
|
||||
onPress: dialog.onDismiss,
|
||||
}}
|
||||
themeId={themeId}
|
||||
/>
|
||||
<PromptButton
|
||||
buttonSpec={{
|
||||
text: _('Okay'),
|
||||
onPress: () => dialog.onSubmit(text),
|
||||
}}
|
||||
themeId={themeId}
|
||||
/>
|
||||
</Dialog.Actions>
|
||||
</Surface>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextInputDialog;
|
@@ -1,16 +1,16 @@
|
||||
import * as React from 'react';
|
||||
import { Alert, Platform } from 'react-native';
|
||||
import { DialogControl, DialogType, MenuChoice, PromptButtonSpec, PromptDialogData, PromptOptions } from '../types';
|
||||
import { DialogControl, DialogType, MenuChoice, PromptButtonSpec, DialogData, PromptOptions } from '../types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { useMemo, useRef } from 'react';
|
||||
|
||||
type SetPromptDialogs = React.Dispatch<React.SetStateAction<PromptDialogData[]>>;
|
||||
type SetPromptDialogs = React.Dispatch<React.SetStateAction<DialogData[]>>;
|
||||
|
||||
const useDialogControl = (setPromptDialogs: SetPromptDialogs) => {
|
||||
const nextDialogIdRef = useRef(0);
|
||||
|
||||
const dialogControl: DialogControl = useMemo(() => {
|
||||
const onDismiss = (dialog: PromptDialogData) => {
|
||||
const onDismiss = (dialog: DialogData) => {
|
||||
setPromptDialogs(dialogs => dialogs.filter(d => d !== dialog));
|
||||
};
|
||||
|
||||
@@ -39,8 +39,8 @@ const useDialogControl = (setPromptDialogs: SetPromptDialogs) => {
|
||||
Alert.alert(title, message, buttons, options);
|
||||
} else {
|
||||
const cancelable = options?.cancelable ?? true;
|
||||
const dialog: PromptDialogData = {
|
||||
type: DialogType.Prompt,
|
||||
const dialog: DialogData = {
|
||||
type: DialogType.ButtonPrompt,
|
||||
key: `dialog-${nextDialogIdRef.current++}`,
|
||||
title,
|
||||
message,
|
||||
@@ -69,7 +69,7 @@ const useDialogControl = (setPromptDialogs: SetPromptDialogs) => {
|
||||
return new Promise<T>((resolve) => {
|
||||
const dismiss = () => onDismiss(dialog);
|
||||
|
||||
const dialog: PromptDialogData = {
|
||||
const dialog: DialogData = {
|
||||
type: DialogType.Menu,
|
||||
key: `menu-dialog-${nextDialogIdRef.current++}`,
|
||||
title: '',
|
||||
@@ -91,6 +91,33 @@ const useDialogControl = (setPromptDialogs: SetPromptDialogs) => {
|
||||
});
|
||||
});
|
||||
},
|
||||
promptForText: (message: string) => {
|
||||
return new Promise<string|null>((resolve) => {
|
||||
const dismiss = () => {
|
||||
onDismiss(dialog);
|
||||
};
|
||||
|
||||
const dialog: DialogData = {
|
||||
type: DialogType.TextInput,
|
||||
key: `prompt-dialog-${nextDialogIdRef.current++}`,
|
||||
message,
|
||||
onSubmit: (text) => {
|
||||
resolve(text);
|
||||
dismiss();
|
||||
},
|
||||
onDismiss: () => {
|
||||
resolve(null);
|
||||
dismiss();
|
||||
},
|
||||
};
|
||||
setPromptDialogs(dialogs => {
|
||||
return [
|
||||
...dialogs,
|
||||
dialog,
|
||||
];
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
return control;
|
||||
|
@@ -5,10 +5,11 @@ import { Portal } from 'react-native-paper';
|
||||
import Modal from '../Modal';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import makeShowMessageBox from '../../utils/makeShowMessageBox';
|
||||
import { DialogControl, PromptDialogData } from './types';
|
||||
import { DialogControl, DialogData, DialogType } from './types';
|
||||
import useDialogControl from './hooks/useDialogControl';
|
||||
import PromptDialog from './PromptDialog';
|
||||
import { themeStyle } from '../global-style';
|
||||
import TextInputDialog from './TextInputDialog';
|
||||
|
||||
export type { DialogControl } from './types';
|
||||
export const DialogContext = createContext<DialogControl>(null);
|
||||
@@ -18,10 +19,11 @@ interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const useStyles = () => {
|
||||
const useStyles = (themeId: number) => {
|
||||
const windowSize = useWindowDimensions();
|
||||
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
return StyleSheet.create({
|
||||
modalContainer: {
|
||||
marginLeft: 'auto',
|
||||
@@ -31,12 +33,20 @@ const useStyles = () => {
|
||||
width: Math.max(windowSize.width / 2, 400),
|
||||
maxWidth: '100%',
|
||||
},
|
||||
|
||||
dialogContainer: {
|
||||
backgroundColor: theme.backgroundColor,
|
||||
borderRadius: theme.borderRadius,
|
||||
paddingTop: theme.borderRadius,
|
||||
marginLeft: 4,
|
||||
marginRight: 4,
|
||||
},
|
||||
});
|
||||
}, [windowSize.width]);
|
||||
}, [windowSize.width, themeId]);
|
||||
};
|
||||
|
||||
const DialogManager: React.FC<Props> = props => {
|
||||
const [dialogModels, setPromptDialogs] = useState<PromptDialogData[]>([]);
|
||||
const [dialogModels, setPromptDialogs] = useState<DialogData[]>([]);
|
||||
|
||||
const dialogControl = useDialogControl(setPromptDialogs);
|
||||
const dialogControlRef = useRef(dialogControl);
|
||||
@@ -51,17 +61,33 @@ const DialogManager: React.FC<Props> = props => {
|
||||
}, []);
|
||||
|
||||
const theme = themeStyle(props.themeId);
|
||||
const styles = useStyles();
|
||||
const styles = useStyles(props.themeId);
|
||||
|
||||
const dialogComponents: React.ReactNode[] = [];
|
||||
for (const dialog of dialogModels) {
|
||||
dialogComponents.push(
|
||||
<PromptDialog
|
||||
key={dialog.key}
|
||||
dialog={dialog}
|
||||
themeId={props.themeId}
|
||||
/>,
|
||||
);
|
||||
const dialogProps = {
|
||||
key: dialog.key,
|
||||
containerStyle: styles.dialogContainer,
|
||||
themeId: props.themeId,
|
||||
};
|
||||
if (dialog.type === DialogType.Menu || dialog.type === DialogType.ButtonPrompt) {
|
||||
dialogComponents.push(
|
||||
<PromptDialog
|
||||
dialog={dialog}
|
||||
{...dialogProps}
|
||||
/>,
|
||||
);
|
||||
} else if (dialog.type === DialogType.TextInput) {
|
||||
dialogComponents.push(
|
||||
<TextInputDialog
|
||||
dialog={dialog}
|
||||
{...dialogProps}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
const exhaustivenessCheck: never = dialog.type;
|
||||
throw new Error(`Unexpected dialog type ${exhaustivenessCheck}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Web: Use a <Modal> wrapper for better keyboard focus handling.
|
||||
|
@@ -24,16 +24,18 @@ export interface DialogControl {
|
||||
info(message: string): Promise<void>;
|
||||
error(message: string): Promise<void>;
|
||||
prompt(title: string, message: string, buttons?: PromptButtonSpec[], options?: PromptOptions): void;
|
||||
promptForText(message: string): Promise<string>;
|
||||
showMenu<IdType>(title: string, choices: MenuChoice<IdType>[]): Promise<IdType>;
|
||||
}
|
||||
|
||||
export enum DialogType {
|
||||
Prompt,
|
||||
ButtonPrompt,
|
||||
Menu,
|
||||
TextInput,
|
||||
}
|
||||
|
||||
export interface PromptDialogData {
|
||||
type: DialogType;
|
||||
export interface ButtonDialogData {
|
||||
type: DialogType.ButtonPrompt|DialogType.Menu;
|
||||
key: string;
|
||||
title: string;
|
||||
message: string;
|
||||
@@ -41,3 +43,13 @@ export interface PromptDialogData {
|
||||
onDismiss: (()=> void)|null;
|
||||
}
|
||||
|
||||
export interface TextInputDialogData {
|
||||
type: DialogType.TextInput;
|
||||
key: string;
|
||||
message: string;
|
||||
onSubmit: (text: string)=> void;
|
||||
onDismiss: ()=> void;
|
||||
}
|
||||
|
||||
export type DialogData = ButtonDialogData | TextInputDialogData;
|
||||
|
||||
|
@@ -1,10 +1,13 @@
|
||||
import * as React from 'react';
|
||||
import { FunctionComponent, ReactElement } from 'react';
|
||||
import { FunctionComponent, ReactElement, useCallback, useContext } from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Folder, { FolderEntityWithChildren } from '@joplin/lib/models/Folder';
|
||||
import { themeStyle } from './global-style';
|
||||
import Dropdown, { DropdownListItem, OnValueChangedListener } from './Dropdown';
|
||||
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||
import { View } from 'react-native';
|
||||
import { Button } from 'react-native-paper';
|
||||
import { DialogContext } from './DialogManager';
|
||||
|
||||
interface FolderPickerProps {
|
||||
disabled?: boolean;
|
||||
@@ -16,6 +19,7 @@ interface FolderPickerProps {
|
||||
darkText?: boolean;
|
||||
themeId?: number;
|
||||
coverableChildrenRight?: ReactElement|ReactElement[];
|
||||
onNewFolder?: (title: string)=> void;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +32,7 @@ const FolderPicker: FunctionComponent<FolderPickerProps> = ({
|
||||
placeholder,
|
||||
darkText,
|
||||
coverableChildrenRight,
|
||||
onNewFolder,
|
||||
themeId,
|
||||
}) => {
|
||||
const theme = themeStyle(themeId);
|
||||
@@ -61,7 +66,15 @@ const FolderPicker: FunctionComponent<FolderPickerProps> = ({
|
||||
return output;
|
||||
};
|
||||
|
||||
return (
|
||||
const dialogs = useContext(DialogContext);
|
||||
const onNewFolderPress = useCallback(async () => {
|
||||
const title = await dialogs.promptForText(_('New notebook title'));
|
||||
if (title !== null) {
|
||||
onNewFolder(title);
|
||||
}
|
||||
}, [dialogs, onNewFolder]);
|
||||
|
||||
const dropdown = (
|
||||
<Dropdown
|
||||
items={titlePickerItems(!!mustSelect)}
|
||||
accessibilityHint={_('Selects a notebook')}
|
||||
@@ -88,6 +101,19 @@ const FolderPicker: FunctionComponent<FolderPickerProps> = ({
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (onNewFolder) {
|
||||
return <View style={{ flexDirection: 'column', flex: 1 }}>
|
||||
{dropdown}
|
||||
<Button
|
||||
style={{ alignSelf: 'flex-end' }}
|
||||
icon='plus'
|
||||
onPress={onNewFolderPress}
|
||||
>{_('Create new notebook')}</Button>
|
||||
</View>;
|
||||
} else {
|
||||
return dropdown;
|
||||
}
|
||||
};
|
||||
|
||||
export default FolderPicker;
|
||||
|
@@ -6,6 +6,7 @@ import { _ } from '@joplin/lib/locale';
|
||||
import { useCallback, useState } from 'react';
|
||||
import DismissibleDialog, { DialogSize } from '../DismissibleDialog';
|
||||
import { LinkButton } from '../buttons';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
|
||||
interface Props {
|
||||
wrapperStyle: ViewStyle;
|
||||
@@ -51,6 +52,7 @@ const WebBetaButton: React.FC<Props> = props => {
|
||||
<Text>{'At present, the web client is in beta. In the future, it may change significantly, or be removed.'}</Text>
|
||||
<View style={feedbackContainerStyles}>
|
||||
<LinkButton onPress={onLeaveFeedback}>{'Give feedback'}</LinkButton>
|
||||
<LinkButton onPress={() => NavService.go('DocumentScanner')}>{'Test work-in-progress feature: Document scanner'}</LinkButton>
|
||||
</View>
|
||||
</DismissibleDialog>
|
||||
</>
|
||||
|
@@ -64,6 +64,7 @@ interface ScreenHeaderProps {
|
||||
onSaveButtonPress: OnPressCallback;
|
||||
sortButton_press?: OnPressCallback;
|
||||
onSearchButtonPress?: OnPressCallback;
|
||||
onDeleteButtonPress?: OnPressCallback;
|
||||
|
||||
showSideMenuButton?: boolean;
|
||||
showSearchButton?: boolean;
|
||||
@@ -388,6 +389,22 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const customDeleteButton = (styles: any, onPress: OnPressCallback) => {
|
||||
return (
|
||||
<IconButton
|
||||
onPress={onPress}
|
||||
|
||||
description={_('Delete')}
|
||||
themeId={themeId}
|
||||
contentWrapperStyle={styles.iconButton}
|
||||
|
||||
iconName='fas trash'
|
||||
iconStyle={styles.topIcon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const pluginPanelToggleButton = (styles: any, onPress: OnPressCallback) => {
|
||||
const allPluginViews = Object.values(this.props.plugins).map(plugin => Object.values(plugin.views)).flat();
|
||||
@@ -610,6 +627,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||
const showSearchButton = !!this.props.showSearchButton && !this.props.noteSelectionEnabled;
|
||||
const showContextMenuButton = this.props.showContextMenuButton !== false;
|
||||
const showBackButton = !!this.props.noteSelectionEnabled || this.props.showBackButton !== false;
|
||||
const showStandardDeleteButton = !this.props.onDeleteButtonPress && !selectedFolderInTrash && this.props.noteSelectionEnabled;
|
||||
|
||||
let backButtonDisabled = !this.props.historyCanGoBack;
|
||||
if (this.props.noteSelectionEnabled) backButtonDisabled = false;
|
||||
@@ -621,7 +639,8 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||
const betaIconComp = betaIconButton();
|
||||
const selectAllButtonComp = !showSelectAllButton ? null : selectAllButton(this.styles(), () => this.selectAllButton_press());
|
||||
const searchButtonComp = !showSearchButton ? null : searchButton(this.styles(), () => this.searchButton_press());
|
||||
const deleteButtonComp = !selectedFolderInTrash && this.props.noteSelectionEnabled ? deleteButton(this.styles(), () => this.deleteButton_press(), headerItemDisabled) : null;
|
||||
const customDeleteButtonComp = this.props.onDeleteButtonPress ? customDeleteButton(this.styles(), this.props.onDeleteButtonPress) : null;
|
||||
const deleteButtonComp = showStandardDeleteButton ? deleteButton(this.styles(), () => this.deleteButton_press(), headerItemDisabled) : null;
|
||||
const restoreButtonComp = selectedFolderInTrash && this.props.noteSelectionEnabled ? restoreButton(this.styles(), () => this.restoreButton_press(), headerItemDisabled) : null;
|
||||
const duplicateButtonComp = !selectedFolderInTrash && this.props.noteSelectionEnabled ? duplicateButton(this.styles(), () => this.duplicateButton_press(), headerItemDisabled) : null;
|
||||
const sortButtonComp = !this.props.noteSelectionEnabled && this.props.sortButton_press ? sortButton(this.styles(), () => this.props.sortButton_press()) : null;
|
||||
@@ -673,6 +692,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||
{selectAllButtonComp}
|
||||
{searchButtonComp}
|
||||
{deleteButtonComp}
|
||||
{customDeleteButtonComp}
|
||||
{restoreButtonComp}
|
||||
{duplicateButtonComp}
|
||||
{sortButtonComp}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { StyleSheet, View, Text, ScrollView, ViewStyle, Platform, AccessibilityInfo } from 'react-native';
|
||||
import { StyleSheet, View, Text, ScrollView, ViewStyle, Platform, AccessibilityInfo, ScrollViewProps, TextStyle } from 'react-native';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { themeStyle } from './global-style';
|
||||
import ComboBox, { Option } from './ComboBox';
|
||||
@@ -23,9 +23,11 @@ interface Props {
|
||||
mode: TagEditorMode;
|
||||
style: ViewStyle;
|
||||
onTagsChange: (newTags: string[])=> void;
|
||||
headerStyle?: TextStyle;
|
||||
searchResultProps?: ScrollViewProps;
|
||||
}
|
||||
|
||||
const useStyles = (themeId: number) => {
|
||||
const useStyles = (themeId: number, headerStyle: TextStyle|undefined) => {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
return StyleSheet.create({
|
||||
@@ -73,6 +75,7 @@ const useStyles = (themeId: number) => {
|
||||
...theme.headerStyle,
|
||||
fontSize: theme.fontSize,
|
||||
marginBottom: theme.itemMarginBottom,
|
||||
...headerStyle,
|
||||
},
|
||||
divider: {
|
||||
marginTop: theme.margin * 1.4,
|
||||
@@ -87,7 +90,7 @@ const useStyles = (themeId: number) => {
|
||||
color: theme.colorFaded,
|
||||
},
|
||||
});
|
||||
}, [themeId]);
|
||||
}, [themeId, headerStyle]);
|
||||
};
|
||||
|
||||
type Styles = ReturnType<typeof useStyles>;
|
||||
@@ -188,7 +191,7 @@ const TagsBox: React.FC<TagsBoxProps> = props => {
|
||||
const normalizeTag = (tagText: string) => tagText.trim().toLowerCase();
|
||||
|
||||
const TagEditor: React.FC<Props> = props => {
|
||||
const styles = useStyles(props.themeId);
|
||||
const styles = useStyles(props.themeId, props.headerStyle);
|
||||
|
||||
const comboBoxItems = useMemo(() => {
|
||||
return props.allTags
|
||||
@@ -270,6 +273,7 @@ const TagEditor: React.FC<Props> = props => {
|
||||
searchInputProps={{
|
||||
autoCapitalize: 'none',
|
||||
}}
|
||||
searchResultProps={props.searchResultProps}
|
||||
/>
|
||||
</View>;
|
||||
};
|
||||
|
@@ -42,6 +42,9 @@ const useStyles = (themeId: number) => {
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
},
|
||||
buttonText: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
}, [themeId]);
|
||||
};
|
||||
@@ -60,7 +63,8 @@ const LabelledIconButton: React.FC<Props> = ({ title, icon, style, themeId, ...o
|
||||
<Text
|
||||
variant='labelMedium'
|
||||
ellipsizeMode='tail'
|
||||
numberOfLines={1}
|
||||
numberOfLines={2}
|
||||
style={styles.buttonText}
|
||||
>{title}</Text>
|
||||
</View>
|
||||
</TouchableRipple>;
|
||||
|
@@ -0,0 +1,164 @@
|
||||
import * as React from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
import { AppState } from '../../../utils/types';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { themeStyle } from '../../global-style';
|
||||
import CameraViewMultiPage from '../../CameraView/CameraViewMultiPage';
|
||||
import { Dispatch } from 'redux';
|
||||
import ScreenHeader from '../../ScreenHeader';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { CameraResult } from '../../CameraView/types';
|
||||
import NotePreview, { CreateNoteEvent } from './NotePreview';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Tag from '@joplin/lib/models/Tag';
|
||||
import { Portal, ProgressBar, Snackbar } from 'react-native-paper';
|
||||
import useBackHandler from '../../../utils/hooks/useBackHandler';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
|
||||
const logger = Logger.create('DocumentScanner');
|
||||
|
||||
interface Props {
|
||||
dispatch: Dispatch;
|
||||
themeId: number;
|
||||
}
|
||||
|
||||
const useStyles = (themeId: number) => {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
return StyleSheet.create({
|
||||
root: theme.rootStyle,
|
||||
noRemainingPhotosContainer: {
|
||||
margin: theme.margin,
|
||||
gap: theme.margin,
|
||||
},
|
||||
progressBarContainer: {
|
||||
flexGrow: 0,
|
||||
},
|
||||
});
|
||||
}, [themeId]);
|
||||
};
|
||||
|
||||
const DocumentScanner: React.FC<Props> = ({ themeId, dispatch }) => {
|
||||
const styles = useStyles(themeId);
|
||||
const [cameraVisible, setCameraVisible] = useState(true);
|
||||
const [photos, setPhotos] = useState<CameraResult[]>([]);
|
||||
const [snackbarMessage, setSnackbarMessage] = useState('');
|
||||
const [creatingNote, setCreatingNote] = useState(false);
|
||||
|
||||
useBackHandler(() => {
|
||||
if (photos.length && !cameraVisible) {
|
||||
setCameraVisible(true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const onDeleteLastPhoto = useCallback(() => {
|
||||
if (photos.length <= 1) {
|
||||
setCameraVisible(true);
|
||||
}
|
||||
|
||||
setSnackbarMessage('');
|
||||
setPhotos(photos => {
|
||||
const result = [...photos];
|
||||
result.pop();
|
||||
return result;
|
||||
});
|
||||
}, [photos]);
|
||||
|
||||
const onCloseScreen = useCallback(() => {
|
||||
setPhotos([]);
|
||||
dispatch({ type: 'NAV_BACK' });
|
||||
}, [dispatch]);
|
||||
|
||||
const onCreateNote = useCallback(async (event: CreateNoteEvent) => {
|
||||
setSnackbarMessage(_('Creating note "%s"...', event.title));
|
||||
setCreatingNote(true);
|
||||
|
||||
try {
|
||||
const resources = [];
|
||||
for (const image of photos) {
|
||||
resources.push(await shim.createResourceFromPath(
|
||||
image.uri,
|
||||
{ title: event.title, mime: image.type },
|
||||
));
|
||||
}
|
||||
|
||||
const note = await Note.save({
|
||||
title: event.title,
|
||||
body: resources.map(
|
||||
(image, index) => ``,
|
||||
).join('\n\n'),
|
||||
parent_id: event.parentId,
|
||||
});
|
||||
await Tag.setNoteTagsByTitles(note.id, event.tags);
|
||||
|
||||
await NavService.go('Note', { noteId: note.id });
|
||||
} catch (error) {
|
||||
logger.error('Error creating note', error);
|
||||
await shim.showErrorDialog(`Failed to create note: ${error}`);
|
||||
} finally {
|
||||
setCreatingNote(false);
|
||||
}
|
||||
}, [photos]);
|
||||
|
||||
const onDismissSnackbar = useCallback(() => {
|
||||
setSnackbarMessage('');
|
||||
}, []);
|
||||
|
||||
const onHideCamera = useCallback(() => {
|
||||
setCameraVisible(false);
|
||||
}, []);
|
||||
|
||||
const renderContent = () => {
|
||||
if (cameraVisible) {
|
||||
return <CameraViewMultiPage
|
||||
themeId={themeId}
|
||||
onCancel={onCloseScreen}
|
||||
onComplete={onHideCamera}
|
||||
photos={photos}
|
||||
onSetPhotos={setPhotos}
|
||||
onInsertBarcode={()=>{}}
|
||||
/>;
|
||||
} else if (photos.length > 0) {
|
||||
return <>
|
||||
<ScreenHeader title={_('Note preview')} onDeleteButtonPress={onDeleteLastPhoto}/>
|
||||
{creatingNote && <View style={styles.progressBarContainer}>
|
||||
<ProgressBar visible indeterminate aria-label={_('Creating note.')}/>
|
||||
</View>}
|
||||
<NotePreview
|
||||
imageCount={photos.length}
|
||||
lastImage={photos[photos.length - 1]}
|
||||
onCreateNote={creatingNote ? null : onCreateNote}
|
||||
/>
|
||||
</>;
|
||||
} else {
|
||||
// Error/loading state
|
||||
return <>
|
||||
<ScreenHeader title={'Document scanner'}/>
|
||||
</>;
|
||||
}
|
||||
};
|
||||
|
||||
return <View style={styles.root}>
|
||||
{renderContent()}
|
||||
<Portal>
|
||||
<Snackbar
|
||||
key={`snackbar--${snackbarMessage}`}
|
||||
visible={!!snackbarMessage}
|
||||
onDismiss={onDismissSnackbar}
|
||||
action={{
|
||||
label: _('Dismiss'),
|
||||
onPress: onDismissSnackbar,
|
||||
}}
|
||||
>{snackbarMessage}</Snackbar>
|
||||
</Portal>
|
||||
</View>;
|
||||
};
|
||||
|
||||
export default connect((state: AppState) => ({
|
||||
themeId: state.settings.theme,
|
||||
}))(DocumentScanner);
|
@@ -0,0 +1,178 @@
|
||||
import * as React from 'react';
|
||||
import { useMemo, useState, useCallback, useEffect } from 'react';
|
||||
import { themeStyle } from '../../global-style';
|
||||
import { ScrollView, StyleSheet, View } from 'react-native';
|
||||
import { CameraResult } from '../../CameraView/types';
|
||||
import TextInput from '../../TextInput';
|
||||
import PhotoPreview from '../../CameraView/PhotoPreview';
|
||||
import TagEditor, { TagEditorMode } from '../../TagEditor';
|
||||
import { AppState } from '../../../utils/types';
|
||||
import { connect } from 'react-redux';
|
||||
import { FolderEntity, TagEntity } from '@joplin/lib/services/database/types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import FolderPicker from '../../FolderPicker';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { formatMsToLocal } from '@joplin/utils/time';
|
||||
import { PrimaryButton } from '../../buttons';
|
||||
|
||||
export interface CreateNoteEvent {
|
||||
title: string;
|
||||
tags: string[];
|
||||
parentId: string;
|
||||
}
|
||||
|
||||
type OnCreateNote = (event: CreateNoteEvent)=> void;
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
imageCount: number;
|
||||
lastImage: CameraResult;
|
||||
allTags: TagEntity[];
|
||||
allFolders: FolderEntity[];
|
||||
selectedFolderId: string;
|
||||
|
||||
onCreateNote: null|OnCreateNote;
|
||||
}
|
||||
|
||||
const useStyles = (themeId: number) => {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
return StyleSheet.create({
|
||||
titleInput: {
|
||||
color: theme.color,
|
||||
fontSize: theme.fontSize,
|
||||
},
|
||||
rootScrollView: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
maxWidth: 700,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
photoBackground: {
|
||||
|
||||
},
|
||||
photoLabel: {
|
||||
|
||||
},
|
||||
tagEditor: {
|
||||
marginHorizontal: theme.margin,
|
||||
},
|
||||
tagEditorHeader: {
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
folderPickerLine: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: theme.margin * 2,
|
||||
margin: theme.margin,
|
||||
marginBottom: theme.margin * 2,
|
||||
},
|
||||
folderPicker: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
actionButton: {
|
||||
alignSelf: 'flex-end',
|
||||
margin: theme.margin,
|
||||
},
|
||||
});
|
||||
}, [themeId]);
|
||||
};
|
||||
|
||||
const tagSearchResultsProps = {
|
||||
// Required on Android when including one <ScrollView> inside another:
|
||||
nestedScrollEnabled: true,
|
||||
};
|
||||
|
||||
const NotePreview: React.FC<Props> = ({
|
||||
themeId, lastImage, imageCount, allTags, onCreateNote, allFolders, selectedFolderId: propsSelectedFolderId,
|
||||
}) => {
|
||||
const styles = useStyles(themeId);
|
||||
const [title, setTitle] = useState('');
|
||||
const [tags, setTags] = useState([]);
|
||||
const [selectedFolderId, setSelectedFolderId] = useState(propsSelectedFolderId);
|
||||
|
||||
const realFolders = useMemo(() => {
|
||||
return Folder.getRealFolders(allFolders);
|
||||
}, [allFolders]);
|
||||
|
||||
useEffect(() => {
|
||||
// Don't allow selecting a virtual folder
|
||||
if (selectedFolderId && realFolders.every(folder => folder.id !== selectedFolderId)) {
|
||||
setSelectedFolderId('');
|
||||
}
|
||||
}, [realFolders, selectedFolderId]);
|
||||
|
||||
useEffect(() => {
|
||||
const template = Setting.value('scanner.titleTemplate');
|
||||
const date = formatMsToLocal(Date.now(), Setting.value('dateFormat'));
|
||||
setTitle(
|
||||
template.replace(/{date}/g, date)
|
||||
.replace(/{count}/g, `${imageCount}`),
|
||||
);
|
||||
}, [imageCount]);
|
||||
|
||||
const onNewNote = useCallback(() => {
|
||||
if (!onCreateNote) return;
|
||||
|
||||
onCreateNote({
|
||||
tags,
|
||||
title,
|
||||
parentId: selectedFolderId ?? '',
|
||||
});
|
||||
}, [onCreateNote, tags, title, selectedFolderId]);
|
||||
|
||||
const onNewFolder = useCallback(async (title: string) => {
|
||||
const folder = await Folder.save({ title });
|
||||
setSelectedFolderId(folder.id);
|
||||
}, []);
|
||||
|
||||
return <ScrollView style={styles.rootScrollView}>
|
||||
<TextInput
|
||||
style={styles.titleInput}
|
||||
themeId={themeId}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
/>
|
||||
<View style={styles.folderPickerLine}>
|
||||
<PhotoPreview
|
||||
source={lastImage}
|
||||
backgroundStyle={styles.photoBackground}
|
||||
textStyle={styles.photoLabel}
|
||||
label={imageCount}
|
||||
/>
|
||||
<FolderPicker
|
||||
themeId={themeId}
|
||||
darkText
|
||||
placeholder={_('Select notebook')}
|
||||
folders={realFolders}
|
||||
onValueChange={setSelectedFolderId}
|
||||
selectedFolderId={selectedFolderId}
|
||||
mustSelect={true}
|
||||
onNewFolder={onNewFolder}
|
||||
/>
|
||||
</View>
|
||||
<TagEditor
|
||||
themeId={themeId}
|
||||
tags={tags}
|
||||
mode={TagEditorMode.Compact}
|
||||
allTags={allTags}
|
||||
style={styles.tagEditor}
|
||||
onTagsChange={setTags}
|
||||
headerStyle={styles.tagEditorHeader}
|
||||
searchResultProps={tagSearchResultsProps}
|
||||
/>
|
||||
<PrimaryButton
|
||||
onPress={onNewNote}
|
||||
style={styles.actionButton}
|
||||
disabled={!onCreateNote}
|
||||
>{_('Create note')}</PrimaryButton>
|
||||
</ScrollView>;
|
||||
};
|
||||
|
||||
export default connect((state: AppState) => ({
|
||||
allTags: state.tags,
|
||||
allFolders: state.folders,
|
||||
selectedFolderId: state.selectedFolderId,
|
||||
themeId: state.settings.theme,
|
||||
}))(NotePreview);
|
@@ -10,6 +10,7 @@ import TextButton, { ButtonSize, ButtonType } from '../../buttons/TextButton';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import focusView from '../../../utils/focusView';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
|
||||
const logger = Logger.create('NewNoteButton');
|
||||
|
||||
@@ -26,7 +27,8 @@ const makeNewNote = (isTodo: boolean, action?: AttachFileAction) => {
|
||||
const styles = StyleSheet.create({
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
flexWrap: 'wrap-reverse',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
},
|
||||
mainButtonRow: {
|
||||
@@ -34,9 +36,9 @@ const styles = StyleSheet.create({
|
||||
gap: 12,
|
||||
},
|
||||
shortcutButton: {
|
||||
flexGrow: 1,
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
flexBasis: 0,
|
||||
width: 88,
|
||||
},
|
||||
mainButton: {
|
||||
flexShrink: 1,
|
||||
@@ -57,9 +59,12 @@ const styles = StyleSheet.create({
|
||||
const NewNoteButton: React.FC<Props> = _props => {
|
||||
const newNoteRef = useRef<View|null>(null);
|
||||
|
||||
const renderShortcutButton = (action: AttachFileAction, icon: string, title: string) => {
|
||||
type ActionType = AttachFileAction|(()=> void);
|
||||
const renderShortcutButton = (action: ActionType, icon: string, title: string) => {
|
||||
const actionSource = typeof action === 'function' ? null : action;
|
||||
action = typeof action === 'function' ? action : () => makeNewNote(false, actionSource);
|
||||
return <LabelledIconButton
|
||||
onPress={() => makeNewNote(false, action)}
|
||||
onPress={action}
|
||||
style={styles.shortcutButton}
|
||||
title={title}
|
||||
accessibilityHint={_('Creates a new note with an attachment of type %s', title)}
|
||||
@@ -73,6 +78,7 @@ const NewNoteButton: React.FC<Props> = _props => {
|
||||
{renderShortcutButton(AttachFileAction.RecordAudio, 'material microphone', _('Recording'))}
|
||||
{renderShortcutButton(AttachFileAction.TakePhoto, 'material camera', _('Camera'))}
|
||||
{renderShortcutButton(AttachFileAction.AttachDrawing, 'material draw', _('Drawing'))}
|
||||
{renderShortcutButton(() => NavService.go('DocumentScanner'), 'material data-matrix-scan', _('Scan notebook'))}
|
||||
</View>
|
||||
<Divider/>
|
||||
<View style={[styles.buttonRow, styles.mainButtonRow]}>
|
||||
|
@@ -146,6 +146,7 @@ import FocusControl from './components/accessibility/FocusControl/FocusControl';
|
||||
import SsoLoginScreen from './components/screens/SsoLoginScreen';
|
||||
import SamlShared from '@joplin/lib/components/shared/SamlShared';
|
||||
import NoteRevisionViewer from './components/screens/NoteRevisionViewer';
|
||||
import DocumentScanner from './components/screens/DocumentScanner/DocumentScanner';
|
||||
|
||||
const logger = Logger.create('root');
|
||||
|
||||
@@ -279,6 +280,10 @@ const navHistory: any[] = [];
|
||||
function historyCanGoBackTo(route: any) {
|
||||
if (route.routeName === 'Folder') return false;
|
||||
|
||||
// This is an intermediate screen that acts more like a modal -- it should be skipped in the
|
||||
// navigation history.
|
||||
if (route.routeName === 'DocumentScanner') return false;
|
||||
|
||||
// There's no point going back to these screens in general and, at least in OneDrive case,
|
||||
// it can be buggy to do so, due to incorrectly relying on global state (reg.syncTarget...)
|
||||
if (route.routeName === 'OneDriveLogin') return false;
|
||||
@@ -1320,6 +1325,7 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
|
||||
Status: { screen: StatusScreen },
|
||||
Search: { screen: SearchScreen },
|
||||
Config: { screen: ConfigScreen },
|
||||
DocumentScanner: { screen: DocumentScanner },
|
||||
};
|
||||
|
||||
|
||||
|
@@ -1,20 +1,24 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import BackButtonService from '../../services/BackButtonService';
|
||||
|
||||
type OnBackPress = ()=>(void|boolean);
|
||||
|
||||
const useBackHandler = (onBackPress: OnBackPress|null) => {
|
||||
const onBackPressRef = useRef(onBackPress);
|
||||
onBackPressRef.current = onBackPress ?? (() => {});
|
||||
const hasHandler = !!onBackPress;
|
||||
|
||||
useEffect(() => {
|
||||
if (!onBackPress) return () => {};
|
||||
if (!hasHandler) return () => {};
|
||||
|
||||
const handler = () => {
|
||||
return !!(onBackPress() ?? true);
|
||||
return !!(onBackPressRef.current() ?? true);
|
||||
};
|
||||
BackButtonService.addHandler(handler);
|
||||
return () => {
|
||||
BackButtonService.removeHandler(handler);
|
||||
};
|
||||
}, [onBackPress]);
|
||||
}, [hasHandler]);
|
||||
};
|
||||
|
||||
export default useBackHandler;
|
||||
|
@@ -11,6 +11,7 @@ const makeMockDialogControl = (onPrompt: OnPrompt): DialogControl => {
|
||||
prompt: jest.fn((_title, _message, buttons, options) => {
|
||||
onPrompt(buttons, options.onDismiss);
|
||||
}),
|
||||
promptForText: jest.fn(),
|
||||
showMenu: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
@@ -1841,6 +1841,16 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
section: 'note',
|
||||
},
|
||||
|
||||
'scanner.titleTemplate': {
|
||||
value: 'Scan: {date} ({count})',
|
||||
type: SettingItemType.String,
|
||||
public: true,
|
||||
appTypes: [AppType.Mobile],
|
||||
label: () => _('Document scanner: Title template'),
|
||||
description: () => _('Default title to use for documents created by the scanner.'),
|
||||
section: 'note',
|
||||
},
|
||||
|
||||
'trash.autoDeletionEnabled': {
|
||||
value: true,
|
||||
type: SettingItemType.Bool,
|
||||
|
Reference in New Issue
Block a user