Split image cropping into secondary step (#473)

* Split image cropping into secondary step

* Use ImageModel and GalleryModel

* Add fix for pasting image URLs

* Move models to state folder

* Fix things that broke after rebase

* Latest -- has image display bug

* Remove contentFit

* Fix iOS display in gallery

* Tuneup the api signatures and implement compress/resize on web

* Fix await

* Lint fix and remove unused function

* Fix android image pathing

* Fix external embed x button on android

* Remove min-height from composer (no longer useful and was mispositioning the composer on android)

* Fix e2e picker

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
This commit is contained in:
Ollie Hsieh 2023-04-17 15:41:44 -07:00 committed by GitHub
parent 91fadadb58
commit 2509290fdd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 875 additions and 833 deletions

View file

@ -0,0 +1,130 @@
import React, {useCallback} from 'react'
import {GalleryModel} from 'state/models/media/gallery'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {colors} from 'lib/styles'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {ImageModel} from 'state/models/media/image'
import {Image} from 'expo-image'
interface Props {
gallery: GalleryModel
}
export const Gallery = observer(function ({gallery}: Props) {
const getImageStyle = useCallback(() => {
switch (gallery.size) {
case 1:
return styles.image250
case 2:
return styles.image175
default:
return styles.image85
}
}, [gallery])
const imageStyle = getImageStyle()
const handleRemovePhoto = useCallback(
(image: ImageModel) => {
gallery.remove(image)
},
[gallery],
)
const handleEditPhoto = useCallback(
(image: ImageModel) => {
gallery.crop(image)
},
[gallery],
)
return !gallery.isEmpty ? (
<View testID="selectedPhotosView" style={styles.gallery}>
{gallery.images.map(image =>
image.compressed !== undefined ? (
<View
key={`selected-image-${image.path}`}
style={[styles.imageContainer, imageStyle]}>
<View style={styles.imageControls}>
<TouchableOpacity
testID="cropPhotoButton"
onPress={() => {
handleEditPhoto(image)
}}
style={styles.imageControl}>
<FontAwesomeIcon
icon="pen"
size={12}
style={{color: colors.white}}
/>
</TouchableOpacity>
<TouchableOpacity
testID="removePhotoButton"
onPress={() => handleRemovePhoto(image)}
style={styles.imageControl}>
<FontAwesomeIcon
icon="xmark"
size={16}
style={{color: colors.white}}
/>
</TouchableOpacity>
</View>
<Image
testID="selectedPhotoImage"
style={[styles.image, imageStyle]}
source={{
uri: image.compressed.path,
}}
/>
</View>
) : null,
)}
</View>
) : null
})
const styles = StyleSheet.create({
gallery: {
flex: 1,
flexDirection: 'row',
marginTop: 16,
},
imageContainer: {
margin: 2,
},
image: {
resizeMode: 'cover',
borderRadius: 8,
},
image250: {
width: 250,
height: 250,
},
image175: {
width: 175,
height: 175,
},
image85: {
width: 85,
height: 85,
},
imageControls: {
position: 'absolute',
display: 'flex',
flexDirection: 'row',
gap: 4,
top: 8,
right: 8,
zIndex: 1,
},
imageControl: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderWidth: 0.5,
alignItems: 'center',
justifyContent: 'center',
},
})

View file

@ -1,4 +1,4 @@
import React from 'react'
import React, {useCallback} from 'react'
import {TouchableOpacity} from 'react-native'
import {
FontAwesomeIcon,
@ -10,62 +10,44 @@ import {useStores} from 'state/index'
import {s} from 'lib/styles'
import {isDesktopWeb} from 'platform/detection'
import {openCamera} from 'lib/media/picker'
import {compressIfNeeded} from 'lib/media/manip'
import {useCameraPermission} from 'lib/hooks/usePermissions'
import {
POST_IMG_MAX_WIDTH,
POST_IMG_MAX_HEIGHT,
POST_IMG_MAX_SIZE,
} from 'lib/constants'
import {POST_IMG_MAX} from 'lib/constants'
import {GalleryModel} from 'state/models/media/gallery'
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
export function OpenCameraBtn({
enabled,
selectedPhotos,
onSelectPhotos,
}: {
enabled: boolean
selectedPhotos: string[]
onSelectPhotos: (v: string[]) => void
}) {
type Props = {
gallery: GalleryModel
}
export function OpenCameraBtn({gallery}: Props) {
const pal = usePalette('default')
const {track} = useAnalytics()
const store = useStores()
const {requestCameraAccessIfNeeded} = useCameraPermission()
const onPressTakePicture = React.useCallback(async () => {
const onPressTakePicture = useCallback(async () => {
track('Composer:CameraOpened')
if (!enabled) {
return
}
try {
if (!(await requestCameraAccessIfNeeded())) {
return
}
const cameraRes = await openCamera(store, {
mediaType: 'photo',
width: POST_IMG_MAX_WIDTH,
height: POST_IMG_MAX_HEIGHT,
const img = await openCamera(store, {
width: POST_IMG_MAX.width,
height: POST_IMG_MAX.height,
freeStyleCropEnabled: true,
})
const img = await compressIfNeeded(cameraRes, POST_IMG_MAX_SIZE)
onSelectPhotos([...selectedPhotos, img.path])
gallery.add(img)
} catch (err: any) {
// ignore
store.log.warn('Error using camera', err)
}
}, [
track,
store,
onSelectPhotos,
selectedPhotos,
enabled,
requestCameraAccessIfNeeded,
])
}, [gallery, track, store, requestCameraAccessIfNeeded])
if (isDesktopWeb) {
return <></>
return null
}
return (
@ -76,11 +58,7 @@ export function OpenCameraBtn({
hitSlop={HITSLOP}>
<FontAwesomeIcon
icon="camera"
style={
(enabled
? pal.link
: [pal.textLight, s.dimmed]) as FontAwesomeIconStyle
}
style={pal.link as FontAwesomeIconStyle}
size={24}
/>
</TouchableOpacity>

View file

@ -0,0 +1,3 @@
export function OpenCameraBtn() {
return null
}

View file

@ -1,86 +1,36 @@
import React from 'react'
import {Platform, TouchableOpacity} from 'react-native'
import React, {useCallback} from 'react'
import {TouchableOpacity} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics'
import {useStores} from 'state/index'
import {s} from 'lib/styles'
import {isDesktopWeb} from 'platform/detection'
import {openPicker, cropAndCompressFlow, pickImagesFlow} from 'lib/media/picker'
import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions'
import {
POST_IMG_MAX_WIDTH,
POST_IMG_MAX_HEIGHT,
POST_IMG_MAX_SIZE,
} from 'lib/constants'
import {GalleryModel} from 'state/models/media/gallery'
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
export function SelectPhotoBtn({
enabled,
selectedPhotos,
onSelectPhotos,
}: {
enabled: boolean
selectedPhotos: string[]
onSelectPhotos: (v: string[]) => void
}) {
type Props = {
gallery: GalleryModel
}
export function SelectPhotoBtn({gallery}: Props) {
const pal = usePalette('default')
const {track} = useAnalytics()
const store = useStores()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const onPressSelectPhotos = React.useCallback(async () => {
const onPressSelectPhotos = useCallback(async () => {
track('Composer:GalleryOpened')
if (!enabled) {
if (!isDesktopWeb && !(await requestPhotoAccessIfNeeded())) {
return
}
if (isDesktopWeb) {
const images = await pickImagesFlow(
store,
4 - selectedPhotos.length,
{width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
POST_IMG_MAX_SIZE,
)
onSelectPhotos([...selectedPhotos, ...images])
} else {
if (!(await requestPhotoAccessIfNeeded())) {
return
}
const items = await openPicker(store, {
multiple: true,
maxFiles: 4 - selectedPhotos.length,
mediaType: 'photo',
})
const result = []
for (const image of items) {
if (Platform.OS === 'android') {
result.push(image.path)
continue
}
result.push(
await cropAndCompressFlow(
store,
image.path,
image,
{width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
POST_IMG_MAX_SIZE,
),
)
}
onSelectPhotos([...selectedPhotos, ...result])
}
}, [
track,
store,
onSelectPhotos,
selectedPhotos,
enabled,
requestPhotoAccessIfNeeded,
])
gallery.pick()
}, [track, gallery, requestPhotoAccessIfNeeded])
return (
<TouchableOpacity
@ -90,11 +40,7 @@ export function SelectPhotoBtn({
hitSlop={HITSLOP}>
<FontAwesomeIcon
icon={['far', 'image']}
style={
(enabled
? pal.link
: [pal.textLight, s.dimmed]) as FontAwesomeIconStyle
}
style={pal.link as FontAwesomeIconStyle}
size={24}
/>
</TouchableOpacity>

View file

@ -1,96 +0,0 @@
import React, {useCallback} from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Image} from 'expo-image'
import {colors} from 'lib/styles'
export const SelectedPhotos = ({
selectedPhotos,
onSelectPhotos,
}: {
selectedPhotos: string[]
onSelectPhotos: (v: string[]) => void
}) => {
const imageStyle =
selectedPhotos.length === 1
? styles.image250
: selectedPhotos.length === 2
? styles.image175
: styles.image85
const handleRemovePhoto = useCallback(
item => {
onSelectPhotos(selectedPhotos.filter(filterItem => filterItem !== item))
},
[selectedPhotos, onSelectPhotos],
)
return selectedPhotos.length !== 0 ? (
<View testID="selectedPhotosView" style={styles.gallery}>
{selectedPhotos.length !== 0 &&
selectedPhotos.map((item, index) => (
<View
key={`selected-image-${index}`}
style={[styles.imageContainer, imageStyle]}>
<TouchableOpacity
testID="removePhotoButton"
onPress={() => handleRemovePhoto(item)}
style={styles.removePhotoButton}>
<FontAwesomeIcon
icon="xmark"
size={16}
style={{color: colors.white}}
/>
</TouchableOpacity>
<Image
testID="selectedPhotoImage"
style={[styles.image, imageStyle]}
source={{uri: item}}
/>
</View>
))}
</View>
) : null
}
const styles = StyleSheet.create({
gallery: {
flex: 1,
flexDirection: 'row',
marginTop: 16,
},
imageContainer: {
margin: 2,
},
image: {
resizeMode: 'cover',
borderRadius: 8,
},
image250: {
width: 250,
height: 250,
},
image175: {
width: 175,
height: 175,
},
image85: {
width: 85,
height: 85,
},
removePhotoButton: {
position: 'absolute',
top: 8,
right: 8,
width: 24,
height: 24,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.black,
zIndex: 1,
borderColor: colors.gray4,
borderWidth: 0.5,
},
})