Break out the web/native image picking code and make some progress on the web version

zio/stable
Paul Frazee 2023-01-27 15:51:24 -06:00
parent 0673129b20
commit 7916b26aad
21 changed files with 738 additions and 138 deletions

View File

@ -25,6 +25,7 @@
"@fortawesome/react-native-fontawesome": "^0.3.0",
"@gorhom/bottom-sheet": "^4",
"@mattermost/react-native-paste-input": "^0.6.0",
"@miblanchard/react-native-slider": "^2.2.0",
"@notifee/react-native": "^7.4.0",
"@react-native-async-storage/async-storage": "^1.17.6",
"@react-native-camera-roll/camera-roll": "^5.2.2",
@ -42,6 +43,7 @@
"mobx": "^6.6.1",
"mobx-react-lite": "^3.4.0",
"react": "18.2.0",
"react-avatar-editor": "^13.0.0",
"react-circular-progressbar": "^2.1.0",
"react-dom": "^18.2.0",
"react-native": "0.71.0",
@ -84,6 +86,7 @@
"@types/jest": "^26.0.23",
"@types/lodash.chunk": "^4.2.7",
"@types/lodash.omit": "^4.5.7",
"@types/react-avatar-editor": "^13.0.0",
"@types/react-native": "^0.67.3",
"@types/react-test-renderer": "^17.0.1",
"@typescript-eslint/eslint-plugin": "^5.48.2",

View File

@ -13,8 +13,7 @@
#app-root { display:flex; height:100%; }
/* Remove focus state on inputs */
input:focus,
textarea:focus {
*:focus {
outline: 0;
}
</style>

View File

@ -1,5 +1,5 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {Image as PickedImage} from '../../view/com/util/images/ImageCropPicker'
import {Image as PickedImage} from '../../view/com/util/images/image-crop-picker/ImageCropPicker'
import {
AppBskyActorGetProfile as GetProfile,
AppBskyActorProfile as Profile,

View File

@ -1,6 +1,7 @@
import {makeAutoObservable} from 'mobx'
import {ProfileViewModel} from './profile-view'
import {isObj, hasProp} from '../lib/type-guards'
import {PickedMedia} from '../../view/com/util/images/image-crop-picker/types'
export class ConfirmModal {
name = 'confirm'
@ -52,6 +53,17 @@ export class ReportAccountModal {
}
}
export class CropImageModal {
name = 'crop-image'
constructor(
public uri: string,
public onSelect: (img?: PickedMedia) => void,
) {
makeAutoObservable(this)
}
}
interface LightboxModel {}
export class ProfileImageLightbox implements LightboxModel {
@ -98,6 +110,7 @@ export class ShellUiModel {
| ServerInputModal
| ReportPostModal
| ReportAccountModal
| CropImageModal
| undefined
isLightboxActive = false
activeLightbox: ProfileImageLightbox | ImagesLightbox | undefined
@ -140,7 +153,8 @@ export class ShellUiModel {
| EditProfileModal
| ServerInputModal
| ReportPostModal
| ReportAccountModal,
| ReportAccountModal
| CropImageModal,
) {
this.isModalActive = true
this.activeModal = modal

View File

@ -16,14 +16,9 @@ export class UserLocalPhotosModel {
}
async setup() {
await this._getPhotos()
}
private async _getPhotos() {
CameraRoll.getPhotos({first: 20}).then(r => {
runInAction(() => {
this.photos = r.edges
})
const r = await CameraRoll.getPhotos({first: 20})
runInAction(() => {
this.photos = r.edges
})
}
}

View File

@ -37,8 +37,7 @@ import {
} from '../../../lib/strings'
import {getLinkMeta} from '../../../lib/link-meta'
import {downloadAndResize} from '../../../lib/images'
import {UserLocalPhotosModel} from '../../../state/models/user-local-photos'
import {PhotoCarouselPicker, cropPhoto} from './PhotoCarouselPicker'
import {PhotoCarouselPicker, cropPhoto} from './photos/PhotoCarouselPicker'
import {SelectedPhoto} from './SelectedPhoto'
import {usePalette} from '../../lib/hooks/usePalette'
@ -77,10 +76,6 @@ export const ComposePost = observer(function ComposePost({
() => new UserAutocompleteViewModel(store),
[store],
)
const localPhotos = React.useMemo<UserLocalPhotosModel>(
() => new UserLocalPhotosModel(store),
[store],
)
// HACK
// there's a bug with @mattermost/react-native-paste-input where if the input
@ -95,8 +90,7 @@ export const ComposePost = observer(function ComposePost({
// initial setup
useEffect(() => {
autocompleteView.setup()
localPhotos.setup()
}, [autocompleteView, localPhotos])
}, [autocompleteView])
// external link metadata-fetch flow
useEffect(() => {
@ -220,7 +214,7 @@ export const ComposePost = observer(function ComposePost({
}
const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri))
if (imgUri) {
const finalImgPath = await cropPhoto(imgUri)
const finalImgPath = await cropPhoto(store, imgUri)
onSelectPhotos([...selectedPhotos, finalImgPath])
}
}
@ -412,15 +406,12 @@ export const ComposePost = observer(function ComposePost({
/>
)}
</ScrollView>
{isSelectingPhotos &&
localPhotos.photos != null &&
selectedPhotos.length < 4 && (
<PhotoCarouselPicker
selectedPhotos={selectedPhotos}
onSelectPhotos={onSelectPhotos}
localPhotos={localPhotos}
/>
)}
{isSelectingPhotos && selectedPhotos.length < 4 && (
<PhotoCarouselPicker
selectedPhotos={selectedPhotos}
onSelectPhotos={onSelectPhotos}
/>
)}
<View style={[pal.border, styles.bottomBar]}>
<TouchableOpacity
testID="composerSelectPhotosButton"

View File

@ -8,14 +8,14 @@ import {
openPicker,
openCamera,
openCropper,
} from '../util/images/ImageCropPicker'
} from '../../util/images/image-crop-picker/ImageCropPicker'
import {
UserLocalPhotosModel,
PhotoIdentifier,
} from '../../../state/models/user-local-photos'
import {compressIfNeeded, scaleDownDimensions} from '../../../lib/images'
import {usePalette} from '../../lib/hooks/usePalette'
import {useStores} from '../../../state'
} from '../../../../state/models/user-local-photos'
import {compressIfNeeded, scaleDownDimensions} from '../../../../lib/images'
import {usePalette} from '../../../lib/hooks/usePalette'
import {useStores, RootStoreModel} from '../../../../state'
const MAX_WIDTH = 1000
const MAX_HEIGHT = 1000
@ -25,11 +25,10 @@ const IMAGE_PARAMS = {
width: 1000,
height: 1000,
freeStyleCropEnabled: true,
forceJpg: true, // ios only
compressImageQuality: 1.0,
}
export async function cropPhoto(
store: RootStoreModel,
path: string,
imgWidth = MAX_WIDTH,
imgHeight = MAX_HEIGHT,
@ -40,10 +39,10 @@ export async function cropPhoto(
{width: imgWidth, height: imgHeight},
{width: MAX_WIDTH, height: MAX_HEIGHT},
)
const cropperRes = await openCropper({
const cropperRes = await openCropper(store, {
mediaType: 'photo',
path,
...IMAGE_PARAMS,
freeStyleCropEnabled: true,
width,
height,
})
@ -54,19 +53,30 @@ export async function cropPhoto(
export const PhotoCarouselPicker = ({
selectedPhotos,
onSelectPhotos,
localPhotos,
}: {
selectedPhotos: string[]
onSelectPhotos: (v: string[]) => void
localPhotos: UserLocalPhotosModel
}) => {
const pal = usePalette('default')
const store = useStores()
const [localPhotos, setLocalPhotos] = React.useState<
UserLocalPhotosModel | undefined
>(undefined)
// initial setup
React.useEffect(() => {
const photos = new UserLocalPhotosModel(store)
photos.setup().then(() => {
if (photos.photos) {
setLocalPhotos(photos)
}
})
}, [store])
const handleOpenCamera = useCallback(async () => {
try {
const cameraRes = await openCamera({
const cameraRes = await openCamera(store, {
mediaType: 'photo',
cropping: true,
...IMAGE_PARAMS,
})
const img = await compressIfNeeded(cameraRes, MAX_SIZE)
@ -75,12 +85,13 @@ export const PhotoCarouselPicker = ({
// ignore
store.log.warn('Error using camera', err)
}
}, [store.log, selectedPhotos, onSelectPhotos])
}, [store, selectedPhotos, onSelectPhotos])
const handleSelectPhoto = useCallback(
async (item: PhotoIdentifier) => {
try {
const imgPath = await cropPhoto(
store,
item.node.image.uri,
item.node.image.width,
item.node.image.height,
@ -91,11 +102,11 @@ export const PhotoCarouselPicker = ({
store.log.warn('Error selecting photo', err)
}
},
[store.log, selectedPhotos, onSelectPhotos],
[store, selectedPhotos, onSelectPhotos],
)
const handleOpenGallery = useCallback(() => {
openPicker({
openPicker(store, {
multiple: true,
maxFiles: 4 - selectedPhotos.length,
mediaType: 'photo',
@ -109,10 +120,10 @@ export const PhotoCarouselPicker = ({
{width: image.width, height: image.height},
{width: MAX_WIDTH, height: MAX_HEIGHT},
)
const cropperRes = await openCropper({
const cropperRes = await openCropper(store, {
mediaType: 'photo',
path: image.path,
...IMAGE_PARAMS,
freeStyleCropEnabled: true,
width,
height,
})
@ -121,7 +132,7 @@ export const PhotoCarouselPicker = ({
}
onSelectPhotos([...selectedPhotos, ...result])
})
}, [selectedPhotos, onSelectPhotos])
}, [store, selectedPhotos, onSelectPhotos])
return (
<ScrollView
@ -150,15 +161,16 @@ export const PhotoCarouselPicker = ({
size={24}
/>
</TouchableOpacity>
{localPhotos.photos.map((item: PhotoIdentifier, index: number) => (
<TouchableOpacity
testID="openSelectPhotoButton"
key={`local-image-${index}`}
style={[pal.border, styles.photoButton]}
onPress={() => handleSelectPhoto(item)}>
<Image style={styles.photo} source={{uri: item.node.image.uri}} />
</TouchableOpacity>
))}
{localPhotos != null &&
localPhotos.photos.map((item: PhotoIdentifier, index: number) => (
<TouchableOpacity
testID="openSelectPhotoButton"
key={`local-image-${index}`}
style={[pal.border, styles.photoButton]}
onPress={() => handleSelectPhoto(item)}>
<Image style={styles.photo} source={{uri: item.node.image.uri}} />
</TouchableOpacity>
))}
</ScrollView>
)
}

View File

@ -0,0 +1,158 @@
import React, {useCallback} from 'react'
import {StyleSheet, TouchableOpacity, ScrollView} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {
openPicker,
openCamera,
openCropper,
} from '../../util/images/image-crop-picker/ImageCropPicker'
import {compressIfNeeded, scaleDownDimensions} from '../../../../lib/images'
import {usePalette} from '../../../lib/hooks/usePalette'
import {useStores, RootStoreModel} from '../../../../state'
const MAX_WIDTH = 1000
const MAX_HEIGHT = 1000
const MAX_SIZE = 300000
const IMAGE_PARAMS = {
width: 1000,
height: 1000,
freeStyleCropEnabled: true,
}
export async function cropPhoto(
store: RootStoreModel,
path: string,
imgWidth = MAX_WIDTH,
imgHeight = MAX_HEIGHT,
) {
// choose target dimensions based on the original
// this causes the photo cropper to start with the full image "selected"
const {width, height} = scaleDownDimensions(
{width: imgWidth, height: imgHeight},
{width: MAX_WIDTH, height: MAX_HEIGHT},
)
const cropperRes = await openCropper(store, {
mediaType: 'photo',
path,
freeStyleCropEnabled: true,
width,
height,
})
const img = await compressIfNeeded(cropperRes, MAX_SIZE)
return img.path
}
export const PhotoCarouselPicker = ({
selectedPhotos,
onSelectPhotos,
}: {
selectedPhotos: string[]
onSelectPhotos: (v: string[]) => void
}) => {
const pal = usePalette('default')
const store = useStores()
const handleOpenCamera = useCallback(async () => {
try {
const cameraRes = await openCamera(store, {
mediaType: 'photo',
...IMAGE_PARAMS,
})
const img = await compressIfNeeded(cameraRes, MAX_SIZE)
onSelectPhotos([...selectedPhotos, img.path])
} catch (err: any) {
// ignore
store.log.warn('Error using camera', err)
}
}, [store, selectedPhotos, onSelectPhotos])
const handleOpenGallery = useCallback(() => {
openPicker(store, {
multiple: true,
maxFiles: 4 - selectedPhotos.length,
mediaType: 'photo',
}).then(async items => {
const result = []
for (const image of items) {
// choose target dimensions based on the original
// this causes the photo cropper to start with the full image "selected"
const {width, height} = scaleDownDimensions(
{width: image.width, height: image.height},
{width: MAX_WIDTH, height: MAX_HEIGHT},
)
const cropperRes = await openCropper(store, {
mediaType: 'photo',
path: image.path,
freeStyleCropEnabled: true,
width,
height,
})
const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE)
result.push(finalImg.path)
}
onSelectPhotos([...selectedPhotos, ...result])
})
}, [store, selectedPhotos, onSelectPhotos])
return (
<ScrollView
testID="photoCarouselPickerView"
horizontal
style={[pal.view, styles.photosContainer]}
keyboardShouldPersistTaps="always"
showsHorizontalScrollIndicator={false}>
<TouchableOpacity
testID="openCameraButton"
style={[styles.galleryButton, pal.border, styles.photo]}
onPress={handleOpenCamera}>
<FontAwesomeIcon
icon="camera"
size={24}
style={pal.link as FontAwesomeIconStyle}
/>
</TouchableOpacity>
<TouchableOpacity
testID="openGalleryButton"
style={[styles.galleryButton, pal.border, styles.photo]}
onPress={handleOpenGallery}>
<FontAwesomeIcon
icon="image"
style={pal.link as FontAwesomeIconStyle}
size={24}
/>
</TouchableOpacity>
</ScrollView>
)
}
const styles = StyleSheet.create({
photosContainer: {
width: '100%',
maxHeight: 96,
padding: 8,
overflow: 'hidden',
},
galleryButton: {
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
photoButton: {
width: 75,
height: 75,
marginRight: 8,
borderWidth: 1,
borderRadius: 16,
},
photo: {
width: 75,
height: 75,
marginRight: 8,
borderRadius: 16,
},
})

View File

@ -8,7 +8,7 @@ import {
} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {ScrollView, TextInput} from './util'
import {Image as PickedImage} from '../util/images/ImageCropPicker'
import {PickedMedia} from '../util/images/image-crop-picker/ImageCropPicker'
import {Text} from '../util/text/Text'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {useStores} from '../../../state'
@ -48,12 +48,12 @@ export function Component({
const [userAvatar, setUserAvatar] = useState<string | undefined>(
profileView.avatar,
)
const [newUserBanner, setNewUserBanner] = useState<PickedImage | undefined>()
const [newUserAvatar, setNewUserAvatar] = useState<PickedImage | undefined>()
const [newUserBanner, setNewUserBanner] = useState<PickedMedia | undefined>()
const [newUserAvatar, setNewUserAvatar] = useState<PickedMedia | undefined>()
const onPressCancel = () => {
store.shell.closeModal()
}
const onSelectNewAvatar = async (img: PickedImage) => {
const onSelectNewAvatar = async (img: PickedMedia) => {
try {
const finalImg = await compressIfNeeded(img, 300000)
setNewUserAvatar(finalImg)
@ -62,7 +62,7 @@ export function Component({
setError(e.message || e.toString())
}
}
const onSelectNewBanner = async (img: PickedImage) => {
const onSelectNewBanner = async (img: PickedMedia) => {
try {
const finalImg = await compressIfNeeded(img, 500000)
setNewUserBanner(finalImg)

View File

@ -11,6 +11,7 @@ import * as EditProfileModal from './EditProfile'
import * as ServerInputModal from './ServerInput'
import * as ReportPostModal from './ReportPost'
import * as ReportAccountModal from './ReportAccount'
import * as CropImageModal from './crop-image/CropImage.web'
export const Modal = observer(function Modal() {
const store = useStores()
@ -50,6 +51,12 @@ export const Modal = observer(function Modal() {
element = <ReportPostModal.Component />
} else if (store.shell.activeModal?.name === 'report-account') {
element = <ReportAccountModal.Component />
} else if (store.shell.activeModal?.name === 'crop-image') {
element = (
<CropImageModal.Component
{...(store.shell.activeModal as models.CropImageModal)}
/>
)
} else {
return null
}

View File

@ -0,0 +1,11 @@
/**
* NOTE
* This modal is used only in the web build
* Native uses a third-party library
*/
export const snapPoints = ['0%']
export function Component() {
return null
}

View File

@ -0,0 +1,164 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import ImageEditor from 'react-avatar-editor'
import {Slider} from '@miblanchard/react-native-slider'
import LinearGradient from 'react-native-linear-gradient'
import {Text} from '../../util/text/Text'
import {PickedMedia} from '../../util/images/image-crop-picker/types'
import {s, gradients} from '../../../lib/styles'
import {useStores} from '../../../../state'
import {usePalette} from '../../../lib/hooks/usePalette'
import {SquareIcon, RectWideIcon, RectTallIcon} from '../../../lib/icons'
enum AspectRatio {
Square = 'square',
Wide = 'wide',
Tall = 'tall',
}
interface Dim {
width: number
height: number
}
const DIMS: Record<string, Dim> = {
[AspectRatio.Square]: {width: 1000, height: 1000},
[AspectRatio.Wide]: {width: 1000, height: 750},
[AspectRatio.Tall]: {width: 750, height: 1000},
}
export const snapPoints = ['0%']
export function Component({
uri,
onSelect,
}: {
uri: string
onSelect: (img?: PickedMedia) => void
}) {
const store = useStores()
const pal = usePalette('default')
const [as, setAs] = React.useState<AspectRatio>(AspectRatio.Square)
const [scale, setScale] = React.useState<number>(1)
const doSetAs = (v: AspectRatio) => () => setAs(v)
const onPressCancel = () => {
onSelect(undefined)
store.shell.closeModal()
}
const onPressDone = () => {
console.log('TODO')
onSelect(undefined) // TODO
store.shell.closeModal()
}
let cropperStyle
if (as === AspectRatio.Square) {
cropperStyle = styles.cropperSquare
} else if (as === AspectRatio.Wide) {
cropperStyle = styles.cropperWide
} else if (as === AspectRatio.Tall) {
cropperStyle = styles.cropperTall
}
return (
<View>
<View style={[styles.cropper, cropperStyle]}>
<ImageEditor
style={styles.imageEditor}
image={uri}
width={DIMS[as].width}
height={DIMS[as].height}
scale={scale}
/>
</View>
<View style={styles.ctrls}>
<Slider
value={scale}
onValueChange={(v: number | number[]) =>
setScale(Array.isArray(v) ? v[0] : v)
}
minimumValue={1}
maximumValue={3}
containerStyle={styles.slider}
/>
<TouchableOpacity onPress={doSetAs(AspectRatio.Wide)}>
<RectWideIcon
size={24}
style={as === AspectRatio.Wide ? s.blue3 : undefined}
/>
</TouchableOpacity>
<TouchableOpacity onPress={doSetAs(AspectRatio.Tall)}>
<RectTallIcon
size={24}
style={as === AspectRatio.Tall ? s.blue3 : undefined}
/>
</TouchableOpacity>
<TouchableOpacity onPress={doSetAs(AspectRatio.Square)}>
<SquareIcon
size={24}
style={as === AspectRatio.Square ? s.blue3 : undefined}
/>
</TouchableOpacity>
</View>
<View style={styles.btns}>
<TouchableOpacity onPress={onPressCancel}>
<Text type="xl" style={pal.link}>
Cancel
</Text>
</TouchableOpacity>
<View style={s.flex1} />
<TouchableOpacity onPress={onPressDone}>
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn]}>
<Text type="xl-medium" style={s.white}>
Done
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</View>
)
}
const styles = StyleSheet.create({
cropper: {
marginLeft: 'auto',
marginRight: 'auto',
},
cropperSquare: {
width: 400,
height: 400,
},
cropperWide: {
width: 400,
height: 300,
},
cropperTall: {
width: 300,
height: 400,
},
imageEditor: {
maxWidth: '100%',
},
ctrls: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 10,
},
slider: {
flex: 1,
marginRight: 10,
},
btns: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 10,
},
btn: {
borderRadius: 4,
paddingVertical: 8,
paddingHorizontal: 24,
},
})

View File

@ -6,8 +6,9 @@ import {
openCamera,
openCropper,
openPicker,
Image as PickedImage,
} from './images/ImageCropPicker'
PickedMedia,
} from './images/image-crop-picker/ImageCropPicker'
import {useStores} from '../../../state'
import {colors, gradients} from '../../lib/styles'
export function UserAvatar({
@ -21,8 +22,9 @@ export function UserAvatar({
handle: string
displayName: string | undefined
avatar?: string | null
onSelectNewAvatar?: (img: PickedImage) => void
onSelectNewAvatar?: (img: PickedMedia) => void
}) {
const store = useStores()
const initials = getInitials(displayName || handle)
const handleEditAvatar = useCallback(() => {
@ -30,37 +32,32 @@ export function UserAvatar({
{
text: 'Take a new photo',
onPress: () => {
openCamera({
openCamera(store, {
mediaType: 'photo',
cropping: true,
width: 1000,
height: 1000,
cropperCircleOverlay: true,
forceJpg: true, // ios only
compressImageQuality: 1,
}).then(onSelectNewAvatar)
},
},
{
text: 'Select from gallery',
onPress: () => {
openPicker({
openPicker(store, {
mediaType: 'photo',
}).then(async item => {
await openCropper({
}).then(async items => {
await openCropper(store, {
mediaType: 'photo',
path: item.path,
path: items[0].path,
width: 1000,
height: 1000,
cropperCircleOverlay: true,
forceJpg: true, // ios only
compressImageQuality: 1,
}).then(onSelectNewAvatar)
})
},
},
])
}, [onSelectNewAvatar])
}, [store, onSelectNewAvatar])
const renderSvg = (svgSize: number, svgInitials: string) => (
<Svg width={svgSize} height={svgSize} viewBox="0 0 100 100">

View File

@ -2,57 +2,56 @@ import React, {useCallback} from 'react'
import {StyleSheet, View, TouchableOpacity, Alert, Image} from 'react-native'
import Svg, {Rect, Defs, LinearGradient, Stop} from 'react-native-svg'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Image as PickedImage} from './images/ImageCropPicker'
import {colors, gradients} from '../../lib/styles'
import {openCamera, openCropper, openPicker} from './images/ImageCropPicker'
import {
openCamera,
openCropper,
openPicker,
PickedMedia,
} from './images/image-crop-picker/ImageCropPicker'
import {useStores} from '../../../state'
export function UserBanner({
banner,
onSelectNewBanner,
}: {
banner?: string | null
onSelectNewBanner?: (img: PickedImage) => void
onSelectNewBanner?: (img: PickedMedia) => void
}) {
const store = useStores()
const handleEditBanner = useCallback(() => {
Alert.alert('Select upload method', '', [
{
text: 'Take a new photo',
onPress: () => {
openCamera({
openCamera(store, {
mediaType: 'photo',
cropping: true,
compressImageMaxWidth: 3000,
// compressImageMaxWidth: 3000, TODO needed?
width: 3000,
compressImageMaxHeight: 1000,
// compressImageMaxHeight: 1000, TODO needed?
height: 1000,
forceJpg: true, // ios only
compressImageQuality: 1,
includeExif: true,
}).then(onSelectNewBanner)
},
},
{
text: 'Select from gallery',
onPress: () => {
openPicker({
openPicker(store, {
mediaType: 'photo',
}).then(async item => {
await openCropper({
}).then(async items => {
await openCropper(store, {
mediaType: 'photo',
path: item.path,
compressImageMaxWidth: 3000,
path: items[0].path,
// compressImageMaxWidth: 3000, TODO needed?
width: 3000,
compressImageMaxHeight: 1000,
// compressImageMaxHeight: 1000, TODO needed?
height: 1000,
forceJpg: true, // ios only
compressImageQuality: 1,
includeExif: true,
}).then(onSelectNewBanner)
})
},
},
])
}, [onSelectNewBanner])
}, [store, onSelectNewBanner])
const renderSvg = () => (
<Svg width="100%" height="150" viewBox="50 0 200 100">

View File

@ -1,6 +0,0 @@
export {
openPicker,
openCamera,
openCropper,
} from 'react-native-image-crop-picker'
export type {Image} from 'react-native-image-crop-picker'

View File

@ -1,32 +0,0 @@
import type {
Image,
Video,
ImageOrVideo,
Options,
PossibleArray,
} from 'react-native-image-crop-picker'
export type {Image} from 'react-native-image-crop-picker'
type MediaType<O> = O extends {mediaType: 'photo'}
? Image
: O extends {mediaType: 'video'}
? Video
: ImageOrVideo
export async function openPicker<O extends Options>(
_options: O,
): Promise<PossibleArray<O, MediaType<O>>> {
// TODO
throw new Error('TODO')
}
export async function openCamera<O extends Options>(
_options: O,
): Promise<PossibleArray<O, MediaType<O>>> {
// TODO
throw new Error('TODO')
}
export async function openCropper(_options: Options): Promise<Image> {
// TODO
throw new Error('TODO')
}

View File

@ -0,0 +1,92 @@
import {
openPicker as openPickerFn,
openCamera as openCameraFn,
openCropper as openCropperFn,
ImageOrVideo,
} from 'react-native-image-crop-picker'
import {RootStoreModel} from '../../../../../state'
import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
export type {PickedMedia} from './types'
/**
* NOTE
* These methods all include the RootStoreModel as the first param
* because the web versions require it. The signatures have to remain
* equivalent between the different forms, but the store param is not
* used here.
* -prf
*/
export async function openPicker(
_store: RootStoreModel,
opts: PickerOpts,
): Promise<PickedMedia[]> {
const mediaType = opts.mediaType || 'photo'
const items = await openPickerFn({
mediaType,
multiple: opts.multiple,
maxFiles: opts.maxFiles,
})
const toMedia = (item: ImageOrVideo) => ({
mediaType,
path: item.path,
mime: item.mime,
size: item.size,
width: item.width,
height: item.height,
})
if (Array.isArray(items)) {
return items.map(toMedia)
}
return [toMedia(items)]
}
export async function openCamera(
_store: RootStoreModel,
opts: CameraOpts,
): Promise<PickedMedia> {
const mediaType = opts.mediaType || 'photo'
const item = await openCameraFn({
mediaType,
width: opts.width,
height: opts.height,
freeStyleCropEnabled: opts.freeStyleCropEnabled,
cropperCircleOverlay: opts.cropperCircleOverlay,
cropping: true,
forceJpg: true, // ios only
compressImageQuality: 1.0,
})
return {
mediaType,
path: item.path,
mime: item.mime,
size: item.size,
width: item.width,
height: item.height,
}
}
export async function openCropper(
_store: RootStoreModel,
opts: CropperOpts,
): Promise<PickedMedia> {
const mediaType = opts.mediaType || 'photo'
const item = await openCropperFn({
path: opts.path,
mediaType: opts.mediaType || 'photo',
width: opts.width,
height: opts.height,
freeStyleCropEnabled: opts.freeStyleCropEnabled,
cropperCircleOverlay: opts.cropperCircleOverlay,
forceJpg: true, // ios only
compressImageQuality: 1.0,
})
return {
mediaType,
path: item.path,
mime: item.mime,
size: item.size,
width: item.width,
height: item.height,
}
}

View File

@ -0,0 +1,75 @@
/// <reference lib="dom" />
import {CropImageModal} from '../../../../../state/models/shell-ui'
import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
export type {PickedMedia} from './types'
import {RootStoreModel} from '../../../../../state'
interface PickedFile {
uri: string
path: string
size: number
}
export async function openPicker(
store: RootStoreModel,
opts: PickerOpts,
): Promise<PickedMedia[] | PickedMedia> {
const res = await selectFile(opts)
return new Promise((resolve, reject) => {
store.shell.openModal(
new CropImageModal(res.uri, (img?: PickedMedia) => {
if (img) {
resolve(img)
} else {
reject(new Error('Canceled'))
}
}),
)
})
}
export async function openCamera(
_store: RootStoreModel,
opts: CameraOpts,
): Promise<PickedMedia> {
const mediaType = opts.mediaType || 'photo'
throw new Error('TODO')
}
export async function openCropper(
_store: RootStoreModel,
opts: CropperOpts,
): Promise<PickedMedia> {
const mediaType = opts.mediaType || 'photo'
throw new Error('TODO')
}
function selectFile(opts: PickerOpts): Promise<PickedFile> {
return new Promise((resolve, reject) => {
var input = document.createElement('input')
input.type = 'file'
input.accept = opts.mediaType === 'photo' ? 'image/*' : '*/*'
input.onchange = e => {
const target = e.target as HTMLInputElement
const file = target?.files?.[0]
if (!file) {
return reject(new Error('Canceled'))
}
var reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = readerEvent => {
if (!readerEvent.target) {
return reject(new Error('Canceled'))
}
resolve({
uri: readerEvent.target.result as string,
path: file.name,
size: file.size,
})
}
}
input.click()
})
}

View File

@ -0,0 +1,31 @@
export interface PickerOpts {
mediaType?: 'photo'
multiple?: boolean
maxFiles?: number
}
export interface CameraOpts {
mediaType?: 'photo'
width: number
height: number
freeStyleCropEnabled?: boolean
cropperCircleOverlay?: boolean
}
export interface CropperOpts {
path: string
mediaType?: 'photo'
width: number
height: number
freeStyleCropEnabled?: boolean
cropperCircleOverlay?: boolean
}
export interface PickedMedia {
mediaType: 'photo'
path: string
mime: string
size: number
width: number
height: number
}

View File

@ -1,6 +1,6 @@
import React from 'react'
import {StyleProp, TextStyle, ViewStyle} from 'react-native'
import Svg, {Path} from 'react-native-svg'
import Svg, {Path, Rect} from 'react-native-svg'
export function GridIcon({
style,
@ -458,3 +458,72 @@ export function CommentBottomArrow({
</Svg>
)
}
export function SquareIcon({
style,
size,
strokeWidth = 1.3,
}: {
style?: StyleProp<TextStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
strokeWidth={strokeWidth || 1}
stroke="currentColor"
width={size || 24}
height={size || 24}
style={style}>
<Rect x="6" y="6" width="12" height="12" strokeLinejoin="round" />
</Svg>
)
}
export function RectWideIcon({
style,
size,
strokeWidth = 1.3,
}: {
style?: StyleProp<TextStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
strokeWidth={strokeWidth || 1}
stroke="currentColor"
width={size || 24}
height={size || 24}
style={style}>
<Rect x="4" y="6" width="16" height="12" strokeLinejoin="round" />
</Svg>
)
}
export function RectTallIcon({
style,
size,
strokeWidth = 1.3,
}: {
style?: StyleProp<TextStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
strokeWidth={strokeWidth || 1}
stroke="currentColor"
width={size || 24}
height={size || 24}
style={style}>
<Rect x="6" y="4" width="12" height="16" strokeLinejoin="round" />
</Svg>
)
}

View File

@ -1065,7 +1065,7 @@
dependencies:
"@babel/helper-plugin-utils" "^7.18.6"
"@babel/plugin-transform-runtime@^7.0.0", "@babel/plugin-transform-runtime@^7.16.4":
"@babel/plugin-transform-runtime@^7.0.0", "@babel/plugin-transform-runtime@^7.12.1", "@babel/plugin-transform-runtime@^7.16.4":
version "7.19.6"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.19.6.tgz#9d2a9dbf4e12644d6f46e5e75bfbf02b5d6e9194"
integrity sha512-PRH37lz4JU156lYFW1p8OxE5i7d6Sl/zV58ooyr+q1J1lnQPyg5tIiXlIwNVhJaY4W3TmOtdc8jqdXQcB1v5Yw==
@ -2088,6 +2088,11 @@
dependencies:
semver "7.3.8"
"@miblanchard/react-native-slider@^2.2.0":
version "2.2.0"
resolved "https://registry.yarnpkg.com/@miblanchard/react-native-slider/-/react-native-slider-2.2.0.tgz#5d03cf49516ad0a3b4011fbcad53cb379800832b"
integrity sha512-LepVGFVy6KtDVgMRIAAJJKQCXbcADkzK2R61t3LkD+IF2wG1J4I4KVo99GAsvU0EBKeCsjHPkR+6LGnB6xGzVA==
"@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1":
version "5.1.1-v1"
resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz#dbf733a965ca47b1973177dc0bb6c889edcfb129"
@ -2900,6 +2905,13 @@
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
"@types/react-avatar-editor@^13.0.0":
version "13.0.0"
resolved "https://registry.yarnpkg.com/@types/react-avatar-editor/-/react-avatar-editor-13.0.0.tgz#5963e16c931746c47e478d669dd72d388b427393"
integrity sha512-5ymOayy6mfT35xTqzni7UjXvCNEg8/pH4pI5RenITp9PBc02KGTYjSV1WboXiQDYSh5KomLT0ngBLEAIhV1QoQ==
dependencies:
"@types/react" "*"
"@types/react-native@^0.67.3":
version "0.67.17"
resolved "https://registry.yarnpkg.com/@types/react-native/-/react-native-0.67.17.tgz#afebc3fff1d6314840c13b7936e17fa350eb7aae"
@ -2914,7 +2926,7 @@
dependencies:
"@types/react" "^17"
"@types/react@^17":
"@types/react@*", "@types/react@^17":
version "17.0.52"
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.52.tgz#10d8b907b5c563ac014a541f289ae8eaa9bf2e9b"
integrity sha512-vwk8QqVODi0VaZZpDXQCmEmiOuyjEFPY7Ttaw5vjM112LOq37yz1CDJGrRJwA1fYEq4Iitd5rnjd1yWAc/bT+A==
@ -11162,6 +11174,15 @@ react-app-polyfill@^3.0.0:
regenerator-runtime "^0.13.9"
whatwg-fetch "^3.6.2"
react-avatar-editor@^13.0.0:
version "13.0.0"
resolved "https://registry.yarnpkg.com/react-avatar-editor/-/react-avatar-editor-13.0.0.tgz#55013625ee9ae715c1fe2dc553b8079994d8a5f2"
integrity sha512-0xw63MbRRQdDy7YI1IXU9+7tTFxYEFLV8CABvryYOGjZmXRTH2/UA0mafe57ns62uaEFX181kA4XlGlxCaeXKA==
dependencies:
"@babel/plugin-transform-runtime" "^7.12.1"
"@babel/runtime" "^7.12.5"
prop-types "^15.7.2"
react-circular-progressbar@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/react-circular-progressbar/-/react-circular-progressbar-2.1.0.tgz#99e5ae499c21de82223b498289e96f66adb8fa3a"