diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/lib/media/picker.web.tsx | 4 | ||||
-rw-r--r-- | src/lib/media/types.ts | 5 | ||||
-rw-r--r-- | src/state/modals/index.tsx | 2 | ||||
-rw-r--r-- | src/view/com/composer/photos/EditImageDialog.tsx | 14 | ||||
-rw-r--r-- | src/view/com/composer/photos/EditImageDialog.web.tsx | 105 | ||||
-rw-r--r-- | src/view/com/composer/photos/Gallery.tsx | 34 | ||||
-rw-r--r-- | src/view/com/modals/CropImage.web.tsx | 145 | ||||
-rw-r--r-- | src/view/com/modals/Modal.web.tsx | 2 | ||||
-rw-r--r-- | src/view/com/modals/crop-image/CropImage.web.tsx | 228 | ||||
-rw-r--r-- | src/view/com/modals/crop-image/cropImageUtil.ts | 13 | ||||
-rw-r--r-- | src/view/com/util/UserAvatar.tsx | 18 | ||||
-rw-r--r-- | src/view/com/util/UserBanner.tsx | 15 |
12 files changed, 311 insertions, 274 deletions
diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx index 8782e1457..a53ffc961 100644 --- a/src/lib/media/picker.web.tsx +++ b/src/lib/media/picker.web.tsx @@ -18,9 +18,11 @@ export async function openCropper(opts: CropperOptions): Promise<RNImage> { name: 'crop-image', uri: opts.path, dimensions: - opts.height && opts.width + opts.width && opts.height ? {width: opts.width, height: opts.height} : undefined, + aspect: opts.webAspectRatio, + circular: opts.webCircularCrop, onSelect: (img?: RNImage) => { if (img) { resolve(img) diff --git a/src/lib/media/types.ts b/src/lib/media/types.ts index e6f442759..ec94256ea 100644 --- a/src/lib/media/types.ts +++ b/src/lib/media/types.ts @@ -18,4 +18,7 @@ export interface CameraOpts { cropperCircleOverlay?: boolean } -export type CropperOptions = Parameters<typeof openCropper>[0] +export type CropperOptions = Parameters<typeof openCropper>[0] & { + webAspectRatio?: number + webCircularCrop?: boolean +} diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 9bc96cf5e..5be21dfd3 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -39,6 +39,8 @@ export interface CropImageModal { name: 'crop-image' uri: string dimensions?: {width: number; height: number} + aspect?: number + circular?: boolean onSelect: (img?: RNImage) => void } diff --git a/src/view/com/composer/photos/EditImageDialog.tsx b/src/view/com/composer/photos/EditImageDialog.tsx new file mode 100644 index 000000000..4263587fd --- /dev/null +++ b/src/view/com/composer/photos/EditImageDialog.tsx @@ -0,0 +1,14 @@ +import React from 'react' + +import {ComposerImage} from '#/state/gallery' +import * as Dialog from '#/components/Dialog' + +export type EditImageDialogProps = { + control: Dialog.DialogOuterProps['control'] + image: ComposerImage + onChange: (next: ComposerImage) => void +} + +export const EditImageDialog = ({}: EditImageDialogProps): React.ReactNode => { + return null +} diff --git a/src/view/com/composer/photos/EditImageDialog.web.tsx b/src/view/com/composer/photos/EditImageDialog.web.tsx new file mode 100644 index 000000000..0afb83ed9 --- /dev/null +++ b/src/view/com/composer/photos/EditImageDialog.web.tsx @@ -0,0 +1,105 @@ +import 'react-image-crop/dist/ReactCrop.css' + +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import ReactCrop, {PercentCrop} from 'react-image-crop' + +import { + ImageSource, + ImageTransformation, + manipulateImage, +} from '#/state/gallery' +import {atoms as a} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {Text} from '#/components/Typography' +import {EditImageDialogProps} from './EditImageDialog' + +export const EditImageDialog = (props: EditImageDialogProps) => { + return ( + <Dialog.Outer control={props.control}> + <EditImageInner key={props.image.source.id} {...props} /> + </Dialog.Outer> + ) +} + +const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => { + const {_} = useLingui() + + const source = image.source + + const initialCrop = getInitialCrop(source, image.manips) + const [crop, setCrop] = React.useState(initialCrop) + + const isEmpty = !crop || (crop.width || crop.height) === 0 + const isNew = initialCrop ? true : !isEmpty + + const onPressSubmit = React.useCallback(async () => { + const result = await manipulateImage(image, { + crop: + crop && (crop.width || crop.height) !== 0 + ? { + originX: (crop.x * source.width) / 100, + originY: (crop.y * source.height) / 100, + width: (crop.width * source.width) / 100, + height: (crop.height * source.height) / 100, + } + : undefined, + }) + + onChange(result) + control.close() + }, [crop, image, source, control, onChange]) + + return ( + <Dialog.Inner label={_(msg`Edit image`)}> + <Dialog.Close /> + + <Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}> + <Trans>Edit image</Trans> + </Text> + + <View style={[a.align_center]}> + <ReactCrop + crop={crop} + onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)} + className="ReactCrop--no-animate"> + <img src={source.path} style={{maxHeight: `50vh`}} /> + </ReactCrop> + </View> + + <View style={[a.mt_md, a.gap_md]}> + <Button + disabled={!isNew} + label={_(msg`Save`)} + size="large" + color="primary" + variant="solid" + onPress={onPressSubmit}> + <ButtonText> + <Trans>Save</Trans> + </ButtonText> + </Button> + </View> + </Dialog.Inner> + ) +} + +const getInitialCrop = ( + source: ImageSource, + manips: ImageTransformation | undefined, +): PercentCrop | undefined => { + const initialArea = manips?.crop + + if (initialArea) { + return { + unit: '%', + x: (initialArea.originX / source.width) * 100, + y: (initialArea.originY / source.height) * 100, + width: (initialArea.width / source.width) * 100, + height: (initialArea.height / source.height) * 100, + } + } +} diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 83c1e3c80..369f08d74 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -21,6 +21,7 @@ import {ComposerImage, cropImage} from '#/state/gallery' import {Text} from '#/view/com/util/text/Text' import {useTheme} from '#/alf' import * as Dialog from '#/components/Dialog' +import {EditImageDialog} from './EditImageDialog' import {ImageAltTextDialog} from './ImageAltTextDialog' const IMAGE_GAP = 8 @@ -144,12 +145,15 @@ const GalleryItem = ({ const t = useTheme() const altTextControl = Dialog.useDialogControl() + const editControl = Dialog.useDialogControl() const onImageEdit = () => { if (isNative) { cropImage(image).then(next => { onChange(next) }) + } else { + editControl.open() } } @@ -185,21 +189,15 @@ const GalleryItem = ({ </Text> </TouchableOpacity> <View style={imageControlsStyle}> - {isNative && ( - <TouchableOpacity - testID="editPhotoButton" - accessibilityRole="button" - accessibilityLabel={_(msg`Edit image`)} - accessibilityHint="" - onPress={onImageEdit} - style={styles.imageControl}> - <FontAwesomeIcon - icon="pen" - size={12} - style={{color: colors.white}} - /> - </TouchableOpacity> - )} + <TouchableOpacity + testID="editPhotoButton" + accessibilityRole="button" + accessibilityLabel={_(msg`Edit image`)} + accessibilityHint="" + onPress={onImageEdit} + style={styles.imageControl}> + <FontAwesomeIcon icon="pen" size={12} style={{color: colors.white}} /> + </TouchableOpacity> <TouchableOpacity testID="removePhotoButton" accessibilityRole="button" @@ -237,6 +235,12 @@ const GalleryItem = ({ image={image} onChange={onChange} /> + + <EditImageDialog + control={editControl} + image={image} + onChange={onChange} + /> </View> ) } diff --git a/src/view/com/modals/CropImage.web.tsx b/src/view/com/modals/CropImage.web.tsx new file mode 100644 index 000000000..41ca30657 --- /dev/null +++ b/src/view/com/modals/CropImage.web.tsx @@ -0,0 +1,145 @@ +import React from 'react' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {Image as RNImage} from 'react-native-image-crop-picker' +import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' +import {LinearGradient} from 'expo-linear-gradient' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import ReactCrop, {PercentCrop} from 'react-image-crop' + +import {usePalette} from '#/lib/hooks/usePalette' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {getDataUriSize} from '#/lib/media/util' +import {gradients, s} from '#/lib/styles' +import {useModalControls} from '#/state/modals' +import {Text} from '#/view/com/util/text/Text' + +export const snapPoints = ['0%'] + +export function Component({ + uri, + aspect, + circular, + onSelect, +}: { + uri: string + aspect?: number + circular?: boolean + onSelect: (img?: RNImage) => void +}) { + const pal = usePalette('default') + const {_} = useLingui() + + const {closeModal} = useModalControls() + const {isMobile} = useWebMediaQueries() + + const imageRef = React.useRef<HTMLImageElement>(null) + const [crop, setCrop] = React.useState<PercentCrop>() + + const isEmpty = !crop || (crop.width || crop.height) === 0 + + const onPressCancel = () => { + onSelect(undefined) + closeModal() + } + const onPressDone = async () => { + const img = imageRef.current! + + const result = await manipulateAsync( + uri, + isEmpty + ? [] + : [ + { + crop: { + originX: (crop.x * img.naturalWidth) / 100, + originY: (crop.y * img.naturalHeight) / 100, + width: (crop.width * img.naturalWidth) / 100, + height: (crop.height * img.naturalHeight) / 100, + }, + }, + ], + { + base64: true, + format: SaveFormat.JPEG, + }, + ) + + onSelect({ + path: result.uri, + mime: 'image/jpeg', + size: result.base64 !== undefined ? getDataUriSize(result.base64) : 0, + width: result.width, + height: result.height, + }) + + closeModal() + } + + return ( + <View> + <View style={[styles.cropper, pal.borderDark]}> + <ReactCrop + aspect={aspect} + crop={crop} + onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)} + circularCrop={circular}> + <img ref={imageRef} src={uri} style={{maxHeight: '75vh'}} /> + </ReactCrop> + </View> + <View style={[styles.btns, isMobile && {paddingHorizontal: 16}]}> + <TouchableOpacity + onPress={onPressCancel} + accessibilityRole="button" + accessibilityLabel={_(msg`Cancel image crop`)} + accessibilityHint={_(msg`Exits image cropping process`)}> + <Text type="xl" style={pal.link}> + <Trans>Cancel</Trans> + </Text> + </TouchableOpacity> + <View style={s.flex1} /> + <TouchableOpacity + onPress={onPressDone} + accessibilityRole="button" + accessibilityLabel={_(msg`Save image crop`)} + accessibilityHint={_(msg`Saves image crop settings`)}> + <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}> + <Trans>Done</Trans> + </Text> + </LinearGradient> + </TouchableOpacity> + </View> + </View> + ) +} + +const styles = StyleSheet.create({ + cropper: { + marginLeft: 'auto', + marginRight: 'auto', + borderWidth: 1, + borderRadius: 4, + overflow: 'hidden', + alignItems: 'center', + }, + ctrls: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 10, + }, + btns: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 10, + }, + btn: { + borderRadius: 4, + paddingVertical: 8, + paddingHorizontal: 24, + }, +}) diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index c1024751f..a2acc23bb 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -12,7 +12,7 @@ import * as ChangeEmailModal from './ChangeEmail' import * as ChangeHandleModal from './ChangeHandle' import * as ChangePasswordModal from './ChangePassword' import * as CreateOrEditListModal from './CreateOrEditList' -import * as CropImageModal from './crop-image/CropImage.web' +import * as CropImageModal from './CropImage.web' import * as DeleteAccountModal from './DeleteAccount' import * as EditProfileModal from './EditProfile' import * as InviteCodesModal from './InviteCodes' diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx deleted file mode 100644 index 10cae2f17..000000000 --- a/src/view/com/modals/crop-image/CropImage.web.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import React from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {Image as RNImage} from 'react-native-image-crop-picker' -import {LinearGradient} from 'expo-linear-gradient' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {Slider} from '@miblanchard/react-native-slider' -import ImageEditor from 'react-avatar-editor' - -import {useModalControls} from '#/state/modals' -import {usePalette} from 'lib/hooks/usePalette' -import {RectTallIcon, RectWideIcon, SquareIcon} from 'lib/icons' -import {Dimensions} from 'lib/media/types' -import {getDataUriSize} from 'lib/media/util' -import {gradients, s} from 'lib/styles' -import {Text} from 'view/com/util/text/Text' -import {calculateDimensions} from './cropImageUtil' - -enum AspectRatio { - Square = 'square', - Wide = 'wide', - Tall = 'tall', - Custom = 'custom', -} - -const DIMS: Record<string, Dimensions> = { - [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, - dimensions, - onSelect, -}: { - uri: string - dimensions?: Dimensions - onSelect: (img?: RNImage) => void -}) { - const {closeModal} = useModalControls() - const pal = usePalette('default') - const {_} = useLingui() - const defaultAspectStyle = dimensions - ? AspectRatio.Custom - : AspectRatio.Square - const [as, setAs] = React.useState<AspectRatio>(defaultAspectStyle) - const [scale, setScale] = React.useState<number>(1) - const editorRef = React.useRef<ImageEditor>(null) - const imageEditorWidth = dimensions ? dimensions.width : DIMS[as].width - const imageEditorHeight = dimensions ? dimensions.height : DIMS[as].height - - const doSetAs = (v: AspectRatio) => () => setAs(v) - - const onPressCancel = () => { - onSelect(undefined) - closeModal() - } - const onPressDone = () => { - const canvas = editorRef.current?.getImageScaledToCanvas() - if (canvas) { - const dataUri = canvas.toDataURL('image/jpeg') - onSelect({ - path: dataUri, - mime: 'image/jpeg', - size: getDataUriSize(dataUri), - width: imageEditorWidth, - height: imageEditorHeight, - }) - } else { - onSelect(undefined) - } - 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 - } else if (as === AspectRatio.Custom) { - const cropperDimensions = calculateDimensions( - 550, - imageEditorHeight, - imageEditorWidth, - ) - cropperStyle = { - width: cropperDimensions.width, - height: cropperDimensions.height, - } - } - - return ( - <View> - <View style={[styles.cropper, pal.borderDark, cropperStyle]}> - <ImageEditor - ref={editorRef} - style={styles.imageEditor} - image={uri} - width={imageEditorWidth} - height={imageEditorHeight} - scale={scale} - border={0} - /> - </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} - /> - {as === AspectRatio.Custom ? null : ( - <> - <TouchableOpacity - onPress={doSetAs(AspectRatio.Wide)} - accessibilityRole="button" - accessibilityLabel={_(msg`Wide`)} - accessibilityHint={_(msg`Sets image aspect ratio to wide`)}> - <RectWideIcon - size={24} - style={as === AspectRatio.Wide ? s.blue3 : pal.text} - /> - </TouchableOpacity> - <TouchableOpacity - onPress={doSetAs(AspectRatio.Tall)} - accessibilityRole="button" - accessibilityLabel={_(msg`Tall`)} - accessibilityHint={_(msg`Sets image aspect ratio to tall`)}> - <RectTallIcon - size={24} - style={as === AspectRatio.Tall ? s.blue3 : pal.text} - /> - </TouchableOpacity> - <TouchableOpacity - onPress={doSetAs(AspectRatio.Square)} - accessibilityRole="button" - accessibilityLabel={_(msg`Square`)} - accessibilityHint={_(msg`Sets image aspect ratio to square`)}> - <SquareIcon - size={24} - style={as === AspectRatio.Square ? s.blue3 : pal.text} - /> - </TouchableOpacity> - </> - )} - </View> - <View style={styles.btns}> - <TouchableOpacity - onPress={onPressCancel} - accessibilityRole="button" - accessibilityLabel={_(msg`Cancel image crop`)} - accessibilityHint={_(msg`Exits image cropping process`)}> - <Text type="xl" style={pal.link}> - <Trans>Cancel</Trans> - </Text> - </TouchableOpacity> - <View style={s.flex1} /> - <TouchableOpacity - onPress={onPressDone} - accessibilityRole="button" - accessibilityLabel={_(msg`Save image crop`)} - accessibilityHint={_(msg`Saves image crop settings`)}> - <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}> - <Trans>Done</Trans> - </Text> - </LinearGradient> - </TouchableOpacity> - </View> - </View> - ) -} - -const styles = StyleSheet.create({ - cropper: { - marginLeft: 'auto', - marginRight: 'auto', - borderWidth: 1, - borderRadius: 4, - overflow: 'hidden', - }, - 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, - }, -}) diff --git a/src/view/com/modals/crop-image/cropImageUtil.ts b/src/view/com/modals/crop-image/cropImageUtil.ts deleted file mode 100644 index 303d15ba5..000000000 --- a/src/view/com/modals/crop-image/cropImageUtil.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const calculateDimensions = ( - maxWidth: number, - originalHeight: number, - originalWidth: number, -) => { - const aspectRatio = originalWidth / originalHeight - const newHeight = maxWidth / aspectRatio - const newWidth = maxWidth - return { - width: newWidth, - height: newHeight, - } -} diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index b2f56c138..76d9d1503 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -8,17 +8,17 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' -import {logger} from '#/logger' -import {usePalette} from 'lib/hooks/usePalette' +import {usePalette} from '#/lib/hooks/usePalette' import { useCameraPermission, usePhotoLibraryPermission, -} from 'lib/hooks/usePermissions' -import {makeProfileLink} from 'lib/routes/links' -import {colors} from 'lib/styles' -import {isAndroid, isNative, isWeb} from 'platform/detection' -import {precacheProfile} from 'state/queries/profile' -import {HighPriorityImage} from 'view/com/util/images/Image' +} from '#/lib/hooks/usePermissions' +import {makeProfileLink} from '#/lib/routes/links' +import {colors} from '#/lib/styles' +import {logger} from '#/logger' +import {isAndroid, isNative, isWeb} from '#/platform/detection' +import {precacheProfile} from '#/state/queries/profile' +import {HighPriorityImage} from '#/view/com/util/images/Image' import {tokens, useTheme} from '#/alf' import { Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled, @@ -321,6 +321,8 @@ let EditableUserAvatar = ({ height: 1000, width: 1000, path: item.path, + webAspectRatio: 1, + webCircularCrop: true, }) onSelectNewAvatar(croppedImage) diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 93ea32750..13f4081fc 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -6,16 +6,16 @@ import {ModerationUI} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {logger} from '#/logger' -import {usePalette} from 'lib/hooks/usePalette' +import {usePalette} from '#/lib/hooks/usePalette' import { useCameraPermission, usePhotoLibraryPermission, -} from 'lib/hooks/usePermissions' -import {colors} from 'lib/styles' -import {useTheme} from 'lib/ThemeContext' -import {isAndroid, isNative} from 'platform/detection' -import {EventStopper} from 'view/com/util/EventStopper' +} from '#/lib/hooks/usePermissions' +import {colors} from '#/lib/styles' +import {useTheme} from '#/lib/ThemeContext' +import {logger} from '#/logger' +import {isAndroid, isNative} from '#/platform/detection' +import {EventStopper} from '#/view/com/util/EventStopper' import {tokens, useTheme as useAlfTheme} from '#/alf' import { Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled, @@ -72,6 +72,7 @@ export function UserBanner({ path: items[0].path, width: 3000, height: 1000, + webAspectRatio: 3, }), ) } catch (e: any) { |