aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorilyayudovin <46264063+ilyayudovin@users.noreply.github.com>2020-11-17 15:33:02 +0300
committerGitHub <noreply@github.com>2020-11-17 15:33:02 +0300
commitdb731f1b88fdfa95f16255767e44762211f47196 (patch)
tree84750528e47ae35e136bc8f24c1a575ccc81092f
parentcda51156c20c04a20a9fcfe1e0f3aa51f54e9ad2 (diff)
parent99b4e4aa53d3ade389fc270f9ba9b02904da93f6 (diff)
downloadwhich-ui-db731f1b88fdfa95f16255767e44762211f47196.tar.gz
Merge pull request #108 from which-ecosystem/avatarCrop
feat: Add avatar crop
-rw-r--r--package-lock.json24
-rw-r--r--package.json2
-rw-r--r--src/components/ImageCropAreaSelect/ImageCropAreaSelect.tsx72
-rw-r--r--src/components/ModalScreen/ModalScreen.tsx7
-rw-r--r--src/containers/AvatarCropModal/AvatarCropModal.tsx57
-rw-r--r--src/containers/AvatarCropModal/canvasUtils.js54
-rw-r--r--src/containers/PollCreation/PollCreation.tsx8
-rw-r--r--src/containers/Profile/ProfileInfo.tsx23
8 files changed, 234 insertions, 13 deletions
diff --git a/package-lock.json b/package-lock.json
index 86992bb..7b9db26 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1714,6 +1714,15 @@
"@types/react": "*"
}
},
+ "@types/react-easy-crop": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@types/react-easy-crop/-/react-easy-crop-2.0.0.tgz",
+ "integrity": "sha512-dUkoNMW3OrXQBYgozC43d3vYPQD23uJFt3ZmM4vcVrj7UjmIc2WxCnO9Zl5fs6bp7StnCtYb3Kp7h7s5T2Xu4w==",
+ "dev": true,
+ "requires": {
+ "react-easy-crop": "*"
+ }
+ },
"@types/react-router": {
"version": "5.1.8",
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.8.tgz",
@@ -10922,6 +10931,21 @@
"scheduler": "^0.19.1"
}
},
+ "react-easy-crop": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-3.2.2.tgz",
+ "integrity": "sha512-clDimDXVvxOAhM/jKx5RO6zrk+ODqXj1FI50Fznm/qYlsZlbhS8z1R4yk9yynTYOpt8524inTczywhGVaCGlRA==",
+ "requires": {
+ "tslib": "2.0.1"
+ },
+ "dependencies": {
+ "tslib": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.1.tgz",
+ "integrity": "sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ=="
+ }
+ }
+ },
"react-error-boundary": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-2.3.1.tgz",
diff --git a/package.json b/package.json
index 6f0bc12..6a58a9c 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"notistack": "^0.9.17",
"react": "^16.13.1",
"react-dom": "^16.13.1",
+ "react-easy-crop": "^3.2.2",
"react-error-boundary": "^2.3.1",
"react-icons": "^3.10.0",
"react-router-dom": "^5.2.0",
@@ -49,6 +50,7 @@
"@types/node": "^12.12.44",
"@types/react": "^16.9.35",
"@types/react-dom": "^16.9.8",
+ "@types/react-easy-crop": "^2.0.0",
"@types/react-router-dom": "^5.1.5",
"@types/react-virtualized": "^9.21.10",
"@types/yup": "^0.29.6",
diff --git a/src/components/ImageCropAreaSelect/ImageCropAreaSelect.tsx b/src/components/ImageCropAreaSelect/ImageCropAreaSelect.tsx
new file mode 100644
index 0000000..b4540da
--- /dev/null
+++ b/src/components/ImageCropAreaSelect/ImageCropAreaSelect.tsx
@@ -0,0 +1,72 @@
+import React, {useCallback, useState} from 'react';
+import Cropper from 'react-easy-crop';
+import {makeStyles} from '@material-ui/core/styles';
+import {Slider} from "@material-ui/core";
+
+interface Area {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+interface PropTypes {
+ image: string;
+ setArea: (area: Area) => void;
+}
+
+const useStyles = makeStyles(theme => ({
+ cropperArea: {
+ position: 'relative',
+ width: '100%',
+ height: 'calc(100vh - 130px)',
+ background: '#333',
+ [theme.breakpoints.up('sm')]: {
+ height: 400,
+ }
+ },
+ sliderContainer: {
+ padding: 20,
+ }
+}));
+
+const ImageCropAreaSelect: React.FC<PropTypes> = ({image, setArea}) => {
+ const classes = useStyles();
+ const [crop, setCrop] = useState({x: 0, y: 0});
+ const [zoom, setZoom] = useState<any>(1);
+
+ const onCropComplete = useCallback((areaPercentage: Area, areaPixels: Area): void => {
+ setArea(areaPixels);
+ }, [setArea]);
+
+
+ return (
+ <div>
+ <div className={classes.cropperArea}>
+ <Cropper
+ image={image}
+ crop={crop}
+ zoom={zoom}
+ aspect={1}
+ cropShape="round"
+ showGrid={false}
+ onCropChange={setCrop}
+ onCropComplete={onCropComplete}
+ onZoomChange={setZoom}
+ />
+ </div>
+ <div className={classes.sliderContainer}>
+ <Slider
+ value={zoom}
+ min={1}
+ max={3}
+ step={0.01}
+ aria-labelledby="Zoom"
+ onChange={(e, zoom) => setZoom(zoom)}
+ />
+ </div>
+ </div>
+ )
+};
+
+export default ImageCropAreaSelect;
diff --git a/src/components/ModalScreen/ModalScreen.tsx b/src/components/ModalScreen/ModalScreen.tsx
index cf76272..b71c2c8 100644
--- a/src/components/ModalScreen/ModalScreen.tsx
+++ b/src/components/ModalScreen/ModalScreen.tsx
@@ -1,5 +1,4 @@
import React, { useState, useCallback } from 'react';
-import { useHistory } from 'react-router-dom';
import {
AppBar,
Dialog,
@@ -20,6 +19,7 @@ interface PropTypes {
actionIcon?: JSX.Element;
handleAction?: () => void;
isActionDisabled?: boolean;
+ handleCloseModal?: ()=> void;
}
const useStyles = makeStyles(theme => ({
@@ -42,15 +42,14 @@ const Transition = React.forwardRef((
ref: React.Ref<unknown>
) => <Slide direction="left" ref={ref} {...props} />);
-const ModalScreen: React.FC<PropTypes> = ({ title, actionIcon, handleAction, isActionDisabled, children }) => {
+const ModalScreen: React.FC<PropTypes> = ({ title, actionIcon, handleAction, isActionDisabled, handleCloseModal, children }) => {
const [isOpen, setIsOpen] = useState<boolean>(true);
const classes = useStyles();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
- const history = useHistory();
const handleClose = useCallback(() => setIsOpen(false), [setIsOpen]);
- const onExited = useCallback(() => history.goBack(), [history]);
+ const onExited = useCallback(() => handleCloseModal && handleCloseModal(), [handleCloseModal]);
const handleClickAction = useCallback(async () => {
if (handleAction) await handleAction();
diff --git a/src/containers/AvatarCropModal/AvatarCropModal.tsx b/src/containers/AvatarCropModal/AvatarCropModal.tsx
new file mode 100644
index 0000000..4decdfb
--- /dev/null
+++ b/src/containers/AvatarCropModal/AvatarCropModal.tsx
@@ -0,0 +1,57 @@
+import React, { useState } from 'react';
+import { makeStyles } from '@material-ui/core/styles';
+import SendIcon from "@material-ui/icons/Send";
+import { getCroppedImg } from './canvasUtils'
+
+import ImageCropAreaSelect from '../../components/ImageCropAreaSelect/ImageCropAreaSelect';
+import ModalScreen from '../../components/ModalScreen/ModalScreen';
+
+interface Area {
+ x: number;
+ y: number;
+ width?: number;
+ height?: number;
+}
+
+interface PropTypes {
+ avatar: string;
+ callback: (file: File) => void;
+}
+
+const useStyles = makeStyles(theme => ({
+ cropContainer: {
+ position: 'relative',
+ width: '100%',
+ // height: '100vh',
+ // background: '#333',
+ [theme.breakpoints.up('sm')]: {
+ // height: 400,
+ },
+ }
+}));
+
+const AvatarCropModal: React.FC<PropTypes> = ({ avatar, callback }) => {
+ const classes = useStyles();
+ const [area, setArea] = useState<Area>({ x: 0, y: 0 });
+
+ const handleAction = async () => getCroppedImg(avatar, area)
+ .then(croppedImage => callback(croppedImage));
+
+ return (
+ <ModalScreen
+ title="Choose area"
+ actionIcon={<SendIcon />}
+ handleAction={handleAction}
+ isActionDisabled={false}
+ >
+ <div className={classes.cropContainer}>
+ <ImageCropAreaSelect
+ image={avatar}
+ setArea={setArea}
+ />
+ </div>
+ </ModalScreen>
+ )
+};
+
+export default AvatarCropModal;
diff --git a/src/containers/AvatarCropModal/canvasUtils.js b/src/containers/AvatarCropModal/canvasUtils.js
new file mode 100644
index 0000000..b01aadc
--- /dev/null
+++ b/src/containers/AvatarCropModal/canvasUtils.js
@@ -0,0 +1,54 @@
+const createImage = url =>
+ new Promise((resolve, reject) => {
+ const image = new Image();
+ image.addEventListener('load', () => resolve(image));
+ image.addEventListener('error', error => reject(error));
+ image.setAttribute('crossOrigin', 'anonymous') ;// needed to avoid cross-origin issues on CodeSandbox
+ image.src = url
+ });
+
+export async function getCroppedImg(imageSrc, pixelCrop) {
+ const image = await createImage(imageSrc);
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+
+ const maxSize = Math.max(image.width, image.height);
+ const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2));
+
+ // set each dimensions to double largest dimension to allow for a safe area for the
+ // image to rotate in without being clipped by canvas context
+ canvas.width = safeArea;
+ canvas.height = safeArea;
+
+ // translate canvas context to a central location on image to allow rotating around the center.
+ ctx.translate(safeArea / 2, safeArea / 2);
+ ctx.translate(-safeArea / 2, -safeArea / 2);
+
+ // draw rotated image and store data.
+ ctx.drawImage(
+ image,
+ safeArea / 2 - image.width * 0.5,
+ safeArea / 2 - image.height * 0.5
+ );
+ const data = ctx.getImageData(0, 0, safeArea, safeArea);
+
+ // set canvas width to final desired crop size - this will clear existing context
+ canvas.width = pixelCrop.width;
+ canvas.height = pixelCrop.height;
+
+ // paste generated rotate image with correct offsets for x,y crop values.
+ ctx.putImageData(
+ data,
+ Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x),
+ Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y)
+ );
+
+ // As Base64 string
+ // return canvas.toDataURL('image/jpeg');
+
+ // As a blob
+
+ return new Promise(resolve => {
+ canvas.toBlob(file => resolve(file))
+ })
+}
diff --git a/src/containers/PollCreation/PollCreation.tsx b/src/containers/PollCreation/PollCreation.tsx
index b761c73..46ab28d 100644
--- a/src/containers/PollCreation/PollCreation.tsx
+++ b/src/containers/PollCreation/PollCreation.tsx
@@ -1,4 +1,4 @@
-import React, { ChangeEvent, useState, useMemo } from 'react';
+import React, {ChangeEvent, useState, useMemo, useCallback} from 'react';
import Bluebird from 'bluebird';
import { makeStyles } from '@material-ui/core/styles';
import { Card, Container, LinearProgress, InputBase, Typography } from '@material-ui/core';
@@ -13,6 +13,7 @@ import UserStrip from '../../components/UserStrip/UserStrip';
import { post } from '../../requests';
import { useFeed, useProfile } from '../../hooks/APIClient';
import { useAuth } from '../../hooks/useAuth';
+import {useHistory} from "react-router";
const useStyles = makeStyles(theme => ({
images: {
@@ -32,6 +33,7 @@ const useStyles = makeStyles(theme => ({
const PollCreation: React.FC = () => {
const [description, setDescription] = useState<string>('');
const classes = useStyles();
+ const history = useHistory();
const { enqueueSnackbar } = useSnackbar();
const { user } = useAuth();
const { mutate: updateFeed } = useFeed();
@@ -71,6 +73,9 @@ const PollCreation: React.FC = () => {
}
};
+ const handleClose = useCallback(() => history.goBack(), [history]);
+
+
const isSubmitting = useMemo(() => leftProgress + rightProgress > 0, [leftProgress, rightProgress]);
return (
@@ -79,6 +84,7 @@ const PollCreation: React.FC = () => {
actionIcon={<SendIcon />}
handleAction={handleSubmit}
isActionDisabled={!(left && right) || isSubmitting}
+ handleCloseModal={handleClose}
>
<Container maxWidth="sm" disableGutters>
<Card elevation={3}>
diff --git a/src/containers/Profile/ProfileInfo.tsx b/src/containers/Profile/ProfileInfo.tsx
index da952e9..4c9f17e 100644
--- a/src/containers/Profile/ProfileInfo.tsx
+++ b/src/containers/Profile/ProfileInfo.tsx
@@ -11,6 +11,7 @@ import Avatar from '../../components/Avatar/Avatar';
import { patch } from '../../requests';
import { useAuth } from '../../hooks/useAuth';
import uploadFileToS3 from '../../utils/uploadFileToS3';
+import AvatarCropModal from '../AvatarCropModal/AvatarCropModal';
interface PropTypes {
savedPolls: number;
@@ -107,21 +108,27 @@ const ProfileInfo: React.FC<PropTypes> = ({
const classes = useStyles();
const { user } = useAuth();
const [progress, setProgress] = useState<number>(0);
-
+ const [avatarToCrop, setAvatarToCrop] = useState<string>('');
const dateSince = useMemo(() => formatDate(userInfo?.createdAt), [userInfo]);
const handleUpdateAvatar = useCallback(async (file: File) => {
- if (user) {
- uploadFileToS3(file, 0.8, setProgress)
- .then(avatarUrl => patch(`/users/${user._id}`, { avatarUrl }))
- .then(response => setUserInfo(response.data))
- .then(() => setProgress(0));
- }
+ if (user) uploadFileToS3(file, 0.8, setProgress)
+ .then(avatarUrl => patch(`/users/${user._id}`, { avatarUrl }))
+ .then(response => setUserInfo(response.data))
+ .then(() => setProgress(0));
}, [user, setUserInfo]);
+ const handleCropAvatar = useCallback( async(file: File) => {
+ const imageSrc = URL.createObjectURL(file);
+ setAvatarToCrop(imageSrc);
+ },[]);
+
return (
<div className={classes.root}>
{
+ avatarToCrop && <AvatarCropModal avatar={avatarToCrop} callback={handleUpdateAvatar}/>
+ }
+ {
!userInfo
? <Skeleton animation="wave" variant="circle" width={150} height={150} className={classes.avatar} />
: userInfo?._id === user?._id
@@ -135,7 +142,7 @@ const ProfileInfo: React.FC<PropTypes> = ({
}}
className={classes.avatarContainer}
badgeContent={(
- <FileUpload callback={handleUpdateAvatar}>
+ <FileUpload callback={handleCropAvatar}>
<div className={classes.badge}>
<CameraAlt />
</div>