├── .babelrc ├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .prettierignore ├── LICENSE ├── README.md ├── docs ├── bundle.js ├── einshtein.jpg ├── einshtein2.jpeg ├── example.gif ├── index.html └── user.png ├── example ├── app.jsx └── index.html ├── package-lock.json ├── package.json ├── src ├── avatar.d.ts └── avatar.jsx ├── webpack.dev.js ├── webpack.example.js └── webpack.prod.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"], 3 | "env": { 4 | "production": { 5 | "presets": ["minify"] 6 | } 7 | }, 8 | "plugins": ["@babel/plugin-proposal-class-properties"] 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | # Change these settings to your own preference 10 | indent_style = space 11 | indent_size = 2 12 | 13 | # We recommend you to keep these unchanged 14 | end_of_line = lf 15 | charset = utf-8 16 | trim_trailing_whitespace = true 17 | insert_final_newline = true 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | }, 6 | extends: [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "prettier", 11 | ], 12 | overrides: [], 13 | parser: "@typescript-eslint/parser", 14 | parserOptions: { 15 | ecmaVersion: "latest", 16 | sourceType: "module", 17 | }, 18 | plugins: ["react", "@typescript-eslint"], 19 | rules: {}, 20 | }; 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | 30 | # OS X 31 | *.DS_Store 32 | 33 | dist/ 34 | .idea 35 | lib/ 36 | 37 | react-avatar.iml 38 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | example 39 | docs 40 | dist 41 | webpack.dev.js 42 | webpack.example.js 43 | webpack.prod.js 44 | .babelrc 45 | .gitignore 46 | .idea 47 | .editorconfig 48 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | docs 3 | lib 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Kirill Novikov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-avatar-edit 2 | 3 | 👤 Load, crop and preview avatar with ReactJS component 4 | 5 | - Works from the box 6 | - Fully typed with TypeScript 7 | - Drag and Drop support 8 | - A lot of customization 9 | - Powered with KonvaJS 10 | 11 | Downloads 12 | Downloads 13 | Downloads 14 | 15 | ## Demo 16 | 17 | ![](https://media.giphy.com/media/3o7aD1fCeJxzNu2uYg/giphy.gif) 18 | 19 | ## [Demo website](https://kirill3333.github.io/react-avatar/) 20 | 21 | ## Install 22 | 23 | `npm i react-avatar-edit` 24 | 25 | ## Usage 26 | 27 | ```javascript 28 | import React from "react"; 29 | import ReactDOM from "react-dom"; 30 | import Avatar from "react-avatar-edit"; 31 | 32 | class App extends React.Component { 33 | constructor(props) { 34 | super(props); 35 | const src = "./example/einshtein.jpg"; 36 | this.state = { 37 | preview: null, 38 | src, 39 | }; 40 | this.onCrop = this.onCrop.bind(this); 41 | this.onClose = this.onClose.bind(this); 42 | this.onBeforeFileLoad = this.onBeforeFileLoad.bind(this); 43 | } 44 | 45 | onClose() { 46 | this.setState({ preview: null }); 47 | } 48 | 49 | onCrop(preview) { 50 | this.setState({ preview }); 51 | } 52 | 53 | onBeforeFileLoad(elem) { 54 | if (elem.target.files[0].size > 71680) { 55 | alert("File is too big!"); 56 | elem.target.value = ""; 57 | } 58 | } 59 | 60 | render() { 61 | return ( 62 |
63 | 71 | Preview 72 |
73 | ); 74 | } 75 | } 76 | 77 | ReactDOM.render(, document.getElementById("root")); 78 | ``` 79 | 80 | ## Component properties 81 | 82 | | Prop | Type | Description | 83 | | ---------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | 84 | | img | Image | The Image object to display | 85 | | src | String/Base64 | The url to base64 string to load (use urls from your domain to prevent security errors) | 86 | | width | Number | The width of the editor | 87 | | height | Number | The height of the editor (image will fit to this height if neither imageHeight, nor imageWidth is set) | 88 | | imageWidth | Number | The desired width of the image, can not be used together with imageHeight | 89 | | imageHeight | Number | The desired height of the image, can not be used together with imageWidth | 90 | | cropRadius | Number | The crop area radius in px (default: calculated as min image with/height / 3) | 91 | | cropColor | String | The crop border color (default: white) | 92 | | lineWidth | Number | The crop border width (default: 4) | 93 | | minCropRadius | Number | The min crop area radius in px (default: 30) | 94 | | backgroundColor | String | The color of the image background (default: white) | 95 | | closeIconColor | String | The close button color (default: white) | 96 | | shadingColor | String | The shading color (default: grey) | 97 | | shadingOpacity | Number | The shading area opacity (default: 0.6) | 98 | | mimeTypes | String | The mime types used to filter loaded files (default: image/jpeg,image/png) | 99 | | label | String | Label text (default: Choose a file) | 100 | | labelStyle | Object | The style object for preview label (use camel case for css properties fore example: fontSize) | 101 | | borderStyle | Object | The style for object border preview (use camel case for css properties fore example: fontSize) | 102 | | onImageLoad(image) | Function | Invoked when image based on src prop finish loading | 103 | | onCrop(image) | Function | Invoked when user drag&drop event stop and return cropped image in base64 string | 104 | | onBeforeFileLoad(file) | Function | Invoked when user before upload file with internal file loader (etc. check file size) | 105 | | onFileLoad(file) | Function | Invoked when user upload file with internal file loader | 106 | | onClose() | Function | Invoked when user clicks on close editor button | 107 | | exportAsSquare | Boolean | The exported image is a square and the circle is not cut-off from the image | 108 | | exportSize | Number | The size the exported image should have (width and height equal). The cropping will be made on the original image to ensure a high quality. | 109 | | exportMimeType | String | The mime type that should be used to export the image, supported are: image/jpeg, image/png (Default: image/png) | 110 | | exportQuality | Number | The quality that should be used when exporting in image/jpeg. A value between 0.0 and 1.0. | 111 | 112 | ## Contributing 113 | 114 | - To start developer server please use `npm run start` 115 | - To build production bundle use `npm run build` 116 | -------------------------------------------------------------------------------- /docs/einshtein.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kirill3333/react-avatar/2b9cb5f8aa6aff0c986b9f30c4e8c0ef76298807/docs/einshtein.jpg -------------------------------------------------------------------------------- /docs/einshtein2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kirill3333/react-avatar/2b9cb5f8aa6aff0c986b9f30c4e8c0ef76298807/docs/einshtein2.jpeg -------------------------------------------------------------------------------- /docs/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kirill3333/react-avatar/2b9cb5f8aa6aff0c986b9f30c4e8c0ef76298807/docs/example.gif -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React Avatar 6 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /docs/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kirill3333/react-avatar/2b9cb5f8aa6aff0c986b9f30c4e8c0ef76298807/docs/user.png -------------------------------------------------------------------------------- /example/app.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import Avatar from "../src/avatar.jsx"; 4 | 5 | class App extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | const src = SOURCE_PATH + "/einshtein.jpg"; 9 | this.state = { 10 | preview: null, 11 | defaultPreview: null, 12 | src, 13 | }; 14 | this.onCrop = this.onCrop.bind(this); 15 | this.onCropDefault = this.onCropDefault.bind(this); 16 | this.onClose = this.onClose.bind(this); 17 | this.onCloseDefault = this.onCloseDefault.bind(this); 18 | this.onLoadNewImage = this.onLoadNewImage.bind(this); 19 | } 20 | 21 | onCropDefault(preview) { 22 | this.setState({ defaultPreview: preview }); 23 | } 24 | 25 | onCrop(preview) { 26 | this.setState({ preview }); 27 | } 28 | 29 | onCloseDefault() { 30 | this.setState({ defaultPreview: null }); 31 | } 32 | 33 | onClose() { 34 | this.setState({ preview: null }); 35 | } 36 | 37 | onLoadNewImage() { 38 | const src = SOURCE_PATH + "/einshtein2.jpeg"; 39 | this.setState({ src }); 40 | } 41 | 42 | render() { 43 | return ( 44 |
45 |
49 |
50 |
51 | 62 |

70 | React avatar editor 71 |

72 |
73 |
74 |
75 |
76 |
77 |
78 |

Default usage

79 |
80 |
81 |
82 |
83 |
84 |
85 | 92 |
93 |
94 |
Preview
95 | 100 |
101 |
102 |
103 |
104 |
105 |
106 |

107 | With provided src property 108 |

109 |
110 |
111 |
112 |
113 |
114 |
115 | 123 |
124 | 131 |
132 |
133 |
134 |
Preview
135 | 140 |
141 |
142 |
143 |
147 |
148 | 156 |
157 |
158 |
159 | ); 160 | } 161 | } 162 | 163 | ReactDOM.render(, document.getElementById("root")); 164 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React Avatar 6 | 12 | 13 | 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-avatar-edit", 3 | "version": "1.2.0", 4 | "description": "ReactJS component to upload, crop, and preview avatars", 5 | "main": "lib/react-avatar.js", 6 | "types": "src/avatar.d.ts", 7 | "scripts": { 8 | "start": "webpack-dev-server --open --config webpack.dev.js", 9 | "build": "webpack --config webpack.prod.js", 10 | "build:example": "webpack --config webpack.example.js" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/kirill3333/react-avatar.git" 15 | }, 16 | "author": { 17 | "name": "Kirill Novikov", 18 | "email": "kirillpodium@gmail.com" 19 | }, 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/kirill3333/react-avatar/issues" 23 | }, 24 | "keywords": [ 25 | "avatar", 26 | "react", 27 | "canvas" 28 | ], 29 | "homepage": "https://github.com/kirill3333/react-avatar#readme", 30 | "dependencies": { 31 | "blueimp-load-image": "5.16.0", 32 | "exif-js": "2.3.0", 33 | "konva": "8.3.14" 34 | }, 35 | "devDependencies": { 36 | "@babel/cli": "7.20.7", 37 | "@babel/core": "7.20.7", 38 | "@babel/plugin-proposal-class-properties": "7.18.6", 39 | "@babel/preset-env": "7.20.2", 40 | "@babel/preset-es2015": "7.0.0-beta.53", 41 | "@babel/preset-react": "7.18.6", 42 | "@typescript-eslint/eslint-plugin": "^5.47.0", 43 | "@typescript-eslint/parser": "^5.47.0", 44 | "babel-loader": "9.1.0", 45 | "compression-webpack-plugin": "9.0.0", 46 | "eslint": "^8.30.0", 47 | "eslint-config-prettier": "^8.5.0", 48 | "eslint-plugin-react": "^7.31.11", 49 | "html-webpack-plugin": "5.3.1", 50 | "prettier": "2.8.1", 51 | "react": "18.2.0", 52 | "react-dom": "18.2.0", 53 | "rollup-webpack-loader": "1.0.0", 54 | "webpack": "5.75.0", 55 | "webpack-bundle-analyzer": "4.7.0", 56 | "webpack-cli": "5.0.1", 57 | "webpack-dev-server": "4.11.1" 58 | }, 59 | "peerDependencies": { 60 | "react": "^16.x || ^17 || ^18" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/avatar.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for react-avatar 0.5.9 2 | // Definitions by: Andrew Makarov 3 | // TypeScript Version: 2.3 4 | 5 | import * as React from "react"; 6 | 7 | export interface Props { 8 | /** 9 | * The Image object to display 10 | */ 11 | img?: HTMLImageElement; 12 | 13 | /** 14 | * The url ot base64 string to load (use urls from your domain to prevent security errors) 15 | */ 16 | src?: string; 17 | 18 | /** 19 | * The width of the editor 20 | */ 21 | width: number; 22 | 23 | /** 24 | * The height of the editor (image will fit to this height) 25 | */ 26 | height: number; 27 | 28 | /** 29 | * The desired width of the image, can not be used together with imageHeight 30 | */ 31 | imageWidth?: number; 32 | 33 | /** 34 | * The desired height of the image, can not be used together with imageWidth 35 | */ 36 | imageHeight?: number; 37 | 38 | /** 39 | * The crop area radius in px ( 40 | * Default: 100 41 | */ 42 | cropRadius?: number; 43 | 44 | /** 45 | * The crop border color 46 | * Default: white 47 | */ 48 | cropColor?: string; 49 | 50 | /** 51 | * The crop border width 52 | * Default: 4 53 | */ 54 | lineWidth?: number; 55 | 56 | /** 57 | * The min crop area radius in px 58 | * Default: 30 59 | */ 60 | minCropRadius?: number; 61 | 62 | /** 63 | * The color of the image background 64 | * Default: white 65 | */ 66 | backgroundColor?: string; 67 | 68 | /** 69 | * The close button color 70 | * Default: white 71 | */ 72 | closeIconColor?: string; 73 | 74 | /** 75 | * The shading color 76 | * Default: grey 77 | */ 78 | shadingColor?: string; 79 | 80 | /** 81 | * The shading area opacity 82 | * Default: 0.6 83 | */ 84 | shadingOpacity?: number; 85 | 86 | /** 87 | * The mime types used to filter loaded files 88 | * Default: image/jpeg, image/png 89 | */ 90 | mimeTypes?: string; 91 | 92 | /** 93 | * When set to true the returned data for onCrop is a square instead of a circle. 94 | * Default: false 95 | */ 96 | exportAsSquare?: boolean; 97 | 98 | /** 99 | * The number of pixels width/height should have on the exported image. 100 | * Default: original size of the image 101 | */ 102 | exportSize?: number; 103 | 104 | /** 105 | * The mime type used to generate the data param for onCrop 106 | * Default: image/png 107 | */ 108 | exportMimeType?: string; 109 | 110 | /** 111 | * The quality used to generate the data param for onCrop, only relevant for image/jpeg as exportMimeType 112 | * Default: 1.0 113 | */ 114 | exportQuality?: number; 115 | 116 | /** 117 | * Label 118 | * Default: Choose a file 119 | */ 120 | label?: React.ReactNode; 121 | 122 | /** 123 | * The style object for preview label 124 | */ 125 | labelStyle?: React.CSSProperties; 126 | 127 | /** 128 | * The style for object border preview 129 | */ 130 | borderStyle?: React.CSSProperties; 131 | 132 | /** 133 | * Invoked when image based on src prop finish loading 134 | */ 135 | onImageLoad?: (data: HTMLImageElement) => void; 136 | 137 | /** 138 | * Invoked when user drag&drop event stop and return croped image in base64 sting 139 | */ 140 | onCrop?: (data: string) => void; 141 | 142 | /** 143 | * Invoked when user upload file with internal file loader 144 | */ 145 | onBeforeFileLoad?: (event: React.ChangeEvent) => void; 146 | 147 | /** 148 | * Invoked when user upload file with internal file loader 149 | */ 150 | onFileLoad?: (data: React.ChangeEvent | File) => void; 151 | 152 | /** 153 | * Invoked when user clock on close editor button 154 | */ 155 | onClose?: () => void; 156 | } 157 | 158 | declare class Avatar extends React.Component { 159 | constructor(props: Props); 160 | } 161 | 162 | export default Avatar; 163 | -------------------------------------------------------------------------------- /src/avatar.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Konva from "konva/lib/Core"; 3 | import EXIF from "exif-js"; 4 | import LoadImage from "blueimp-load-image"; 5 | import "konva/lib/shapes/Image"; 6 | import "konva/lib/shapes/Circle"; 7 | import "konva/lib/shapes/Rect"; 8 | import "konva/lib/shapes/Path"; 9 | import "konva/lib/Animation"; 10 | import "konva/lib/DragAndDrop"; 11 | 12 | class Avatar extends React.Component { 13 | static defaultProps = { 14 | shadingColor: "grey", 15 | shadingOpacity: 0.6, 16 | cropColor: "white", 17 | closeIconColor: "white", 18 | lineWidth: 4, 19 | minCropRadius: 30, 20 | backgroundColor: "grey", 21 | mimeTypes: "image/jpeg,image/png", 22 | exportAsSquare: false, 23 | exportSize: undefined, 24 | exportMimeType: "image/png", 25 | exportQuality: 1.0, 26 | mobileScaleSpeed: 0.5, // experimental 27 | onClose: () => {}, 28 | onCrop: () => {}, 29 | onFileLoad: () => {}, 30 | onImageLoad: () => {}, 31 | onBeforeFileLoad: () => {}, 32 | label: "Choose a file", 33 | labelStyle: { 34 | fontSize: "1.25em", 35 | fontWeight: "700", 36 | color: "black", 37 | display: "inline-block", 38 | fontFamily: "sans-serif", 39 | cursor: "pointer", 40 | }, 41 | borderStyle: { 42 | border: "2px solid #979797", 43 | borderStyle: "dashed", 44 | borderRadius: "8px", 45 | textAlign: "center", 46 | }, 47 | }; 48 | 49 | constructor(props) { 50 | super(props); 51 | const containerId = this.generateHash("avatar_container"); 52 | const loaderId = this.generateHash("avatar_loader"); 53 | this.onFileLoad = this.onFileLoad.bind(this); 54 | this.onCloseClick = this.onCloseClick.bind(this); 55 | this.state = { 56 | imgWidth: 0, 57 | imgHeight: 0, 58 | scale: 1, 59 | containerId, 60 | loaderId, 61 | lastMouseY: 0, 62 | showLoader: !(this.props.src || this.props.img), 63 | }; 64 | } 65 | 66 | get lineWidth() { 67 | return this.props.lineWidth; 68 | } 69 | 70 | get containerId() { 71 | return this.state.containerId; 72 | } 73 | 74 | get closeIconColor() { 75 | return this.props.closeIconColor; 76 | } 77 | 78 | get cropColor() { 79 | return this.props.cropColor; 80 | } 81 | 82 | get loaderId() { 83 | return this.state.loaderId; 84 | } 85 | 86 | get mimeTypes() { 87 | return this.props.mimeTypes; 88 | } 89 | 90 | get backgroundColor() { 91 | return this.props.backgroundColor; 92 | } 93 | 94 | get shadingColor() { 95 | return this.props.shadingColor; 96 | } 97 | 98 | get shadingOpacity() { 99 | return this.props.shadingOpacity; 100 | } 101 | 102 | get mobileScaleSpeed() { 103 | return this.props.mobileScaleSpeed; 104 | } 105 | 106 | get cropRadius() { 107 | return this.state.cropRadius; 108 | } 109 | 110 | get minCropRadius() { 111 | return this.props.minCropRadius; 112 | } 113 | 114 | get scale() { 115 | return this.state.scale; 116 | } 117 | 118 | get width() { 119 | return this.state.imgWidth; 120 | } 121 | 122 | get halfWidth() { 123 | return this.state.imgWidth / 2; 124 | } 125 | 126 | get height() { 127 | return this.state.imgHeight; 128 | } 129 | 130 | get halfHeight() { 131 | return this.state.imgHeight / 2; 132 | } 133 | 134 | get image() { 135 | return this.state.image; 136 | } 137 | 138 | generateHash(prefix) { 139 | const s4 = () => 140 | Math.floor((1 + Math.random()) * 0x10000) 141 | .toString(16) 142 | .substring(1); 143 | return prefix + "-" + s4() + "-" + s4() + "-" + s4(); 144 | } 145 | 146 | onCloseCallback() { 147 | this.props.onClose(); 148 | } 149 | 150 | onCropCallback(img) { 151 | this.props.onCrop(img); 152 | } 153 | 154 | onFileLoadCallback(file) { 155 | this.props.onFileLoad(file); 156 | } 157 | 158 | onBeforeFileLoadCallback(elem) { 159 | this.props.onBeforeFileLoad(elem); 160 | } 161 | 162 | onImageLoadCallback(image) { 163 | this.props.onImageLoad(image); 164 | } 165 | 166 | componentDidMount() { 167 | if (this.state.showLoader) return; 168 | 169 | const image = this.props.img || new Image(); 170 | image.crossOrigin = "Anonymous"; 171 | if (!this.props.img && this.props.src) image.src = this.props.src; 172 | this.setState({ image }, () => { 173 | if (this.image.complete) return this.init(); 174 | this.image.onload = () => { 175 | this.onImageLoadCallback(this.image); 176 | this.init(); 177 | }; 178 | }); 179 | } 180 | 181 | componentDidUpdate(prevProps, prevState) { 182 | if (prevProps.src !== this.props.src) { 183 | this.image.src = this.props.src; 184 | } 185 | } 186 | 187 | onFileLoad(e) { 188 | e.preventDefault(); 189 | 190 | this.onBeforeFileLoadCallback(e); 191 | if (!e.target.value) return; 192 | 193 | let file = e.target.files[0]; 194 | 195 | this.onFileLoadCallback(file); 196 | 197 | const ref = this; 198 | EXIF.getData(file, function () { 199 | let exifOrientation = EXIF.getTag(this, "Orientation"); 200 | LoadImage( 201 | file, 202 | function (image, data) { 203 | ref.setState({ image, file, showLoader: false }, () => { 204 | ref.init(); 205 | }); 206 | }, 207 | { orientation: exifOrientation, meta: true } 208 | ); 209 | }); 210 | } 211 | 212 | onCloseClick() { 213 | this.setState({ showLoader: true }, () => this.onCloseCallback()); 214 | } 215 | 216 | init() { 217 | const { height, minCropRadius, cropRadius } = this.props; 218 | const originalWidth = this.image.width; 219 | const originalHeight = this.image.height; 220 | const ration = originalHeight / originalWidth; 221 | const { imageWidth, imageHeight } = this.props; 222 | let imgHeight; 223 | let imgWidth; 224 | 225 | if (imageHeight && imageWidth) { 226 | console.warn( 227 | "The imageWidth and imageHeight properties can not be set together, using only imageWidth." 228 | ); 229 | } 230 | 231 | if (imageHeight && !imageWidth) { 232 | imgHeight = imageHeight || originalHeight; 233 | imgWidth = imgHeight / ration; 234 | } else if (imageWidth) { 235 | imgWidth = imageWidth; 236 | imgHeight = imgWidth * ration || originalHeight; 237 | } else { 238 | imgHeight = height || originalHeight; 239 | imgWidth = imgHeight / ration; 240 | } 241 | 242 | const scale = imgHeight / originalHeight; 243 | const calculatedRadius = Math.max( 244 | minCropRadius, 245 | cropRadius || Math.min(imgWidth, imgHeight) / 3 246 | ); 247 | 248 | this.setState( 249 | { 250 | imgWidth, 251 | imgHeight, 252 | scale, 253 | cropRadius: calculatedRadius, 254 | }, 255 | this.initCanvas 256 | ); 257 | } 258 | 259 | initCanvas() { 260 | const stage = this.initStage(); 261 | const background = this.initBackground(); 262 | const shading = this.initShading(); 263 | const crop = this.initCrop(); 264 | const cropStroke = this.initCropStroke(); 265 | const resize = this.initResize(); 266 | const resizeIcon = this.initResizeIcon(); 267 | 268 | const layer = new Konva.Layer(); 269 | 270 | layer.add(background); 271 | layer.add(shading); 272 | layer.add(cropStroke); 273 | layer.add(crop); 274 | 275 | layer.add(resize); 276 | layer.add(resizeIcon); 277 | 278 | stage.add(layer); 279 | 280 | const scaledRadius = (scale = 0) => crop.radius() - scale; 281 | const isLeftCorner = (scale) => crop.x() - scaledRadius(scale) < 0; 282 | const calcLeft = () => crop.radius() + 1; 283 | const isTopCorner = (scale) => crop.y() - scaledRadius(scale) < 0; 284 | const calcTop = () => crop.radius() + 1; 285 | const isRightCorner = (scale) => 286 | crop.x() + scaledRadius(scale) > stage.width(); 287 | const calcRight = () => stage.width() - crop.radius() - 1; 288 | const isBottomCorner = (scale) => 289 | crop.y() + scaledRadius(scale) > stage.height(); 290 | const calcBottom = () => stage.height() - crop.radius() - 1; 291 | const isNotOutOfScale = (scale) => 292 | !isLeftCorner(scale) && 293 | !isRightCorner(scale) && 294 | !isBottomCorner(scale) && 295 | !isTopCorner(scale); 296 | const calcScaleRadius = (scale) => 297 | scaledRadius(scale) >= this.minCropRadius 298 | ? scale 299 | : crop.radius() - this.minCropRadius; 300 | const calcResizerX = (x) => x + crop.radius() * 0.86; 301 | const calcResizerY = (y) => y - crop.radius() * 0.5; 302 | const moveResizer = (x, y) => { 303 | resize.x(calcResizerX(x) - 8); 304 | resize.y(calcResizerY(y) - 8); 305 | resizeIcon.x(calcResizerX(x) - 8); 306 | resizeIcon.y(calcResizerY(y) - 10); 307 | }; 308 | 309 | const getPreview = () => { 310 | if (this.props.exportAsSquare) { 311 | const fullSizeImage = new Konva.Image({ image: this.image }); 312 | const xScale = fullSizeImage.width() / background.width(); 313 | const yScale = fullSizeImage.height() / background.height(); 314 | 315 | const width = crop.radius() * 2 * xScale; 316 | const height = crop.radius() * 2 * yScale; 317 | const pixelRatio = this.props.exportSize 318 | ? this.props.exportSize / width 319 | : undefined; 320 | 321 | return fullSizeImage.toDataURL({ 322 | x: (crop.x() - crop.radius()) * xScale, 323 | y: (crop.y() - crop.radius()) * yScale, 324 | width, 325 | height, 326 | pixelRatio, 327 | mimeType: this.props.exportMimeType, 328 | quality: this.props.exportQuality, 329 | }); 330 | } else { 331 | const width = crop.radius() * 2; 332 | const height = crop.radius() * 2; 333 | const pixelRatio = this.props.exportSize 334 | ? this.props.exportSize / width 335 | : undefined; 336 | 337 | return crop.toDataURL({ 338 | x: crop.x() - crop.radius(), 339 | y: crop.y() - crop.radius(), 340 | width, 341 | height, 342 | pixelRatio, 343 | mimeType: this.props.exportMimeType, 344 | quality: this.props.exportQuality, 345 | }); 346 | } 347 | }; 348 | 349 | const onScaleCallback = (scaleY) => { 350 | const scale = scaleY > 0 || isNotOutOfScale(scaleY) ? scaleY : 0; 351 | cropStroke.radius(cropStroke.radius() - calcScaleRadius(scale)); 352 | crop.radius(crop.radius() - calcScaleRadius(scale)); 353 | resize.fire("resize"); 354 | }; 355 | 356 | this.onCropCallback(getPreview()); 357 | 358 | crop.on("dragmove", () => crop.fire("resize")); 359 | crop.on("dragend", () => this.onCropCallback(getPreview())); 360 | 361 | crop.on("resize", () => { 362 | const x = isLeftCorner() 363 | ? calcLeft() 364 | : isRightCorner() 365 | ? calcRight() 366 | : crop.x(); 367 | const y = isTopCorner() 368 | ? calcTop() 369 | : isBottomCorner() 370 | ? calcBottom() 371 | : crop.y(); 372 | moveResizer(x, y); 373 | crop.setFillPatternOffset({ x: x / this.scale, y: y / this.scale }); 374 | crop.x(x); 375 | cropStroke.x(x); 376 | crop.y(y); 377 | cropStroke.y(y); 378 | }); 379 | 380 | crop.on("mouseenter", () => (stage.container().style.cursor = "move")); 381 | crop.on("mouseleave", () => (stage.container().style.cursor = "default")); 382 | crop.on("dragstart", () => (stage.container().style.cursor = "move")); 383 | crop.on("dragend", () => (stage.container().style.cursor = "default")); 384 | 385 | resize.on("touchstart", (evt) => { 386 | resize.on("dragmove", (dragEvt) => { 387 | if (dragEvt.evt.type !== "touchmove") return; 388 | const scaleY = 389 | dragEvt.evt.changedTouches["0"].pageY - 390 | evt.evt.changedTouches["0"].pageY || 0; 391 | onScaleCallback(scaleY * this.mobileScaleSpeed); 392 | }); 393 | }); 394 | 395 | resize.on("dragmove", (evt) => { 396 | if (evt.evt.type === "touchmove") return; 397 | const newMouseY = evt.evt.y; 398 | const ieScaleFactor = newMouseY 399 | ? newMouseY - this.state.lastMouseY 400 | : undefined; 401 | const scaleY = evt.evt.movementY || ieScaleFactor || 0; 402 | this.setState({ 403 | lastMouseY: newMouseY, 404 | }); 405 | onScaleCallback(scaleY); 406 | }); 407 | resize.on("dragend", () => this.onCropCallback(getPreview())); 408 | 409 | resize.on("resize", () => moveResizer(crop.x(), crop.y())); 410 | 411 | resize.on( 412 | "mouseenter", 413 | () => (stage.container().style.cursor = "nesw-resize") 414 | ); 415 | resize.on("mouseleave", () => (stage.container().style.cursor = "default")); 416 | resize.on("dragstart", (evt) => { 417 | this.setState({ 418 | lastMouseY: evt.evt.y, 419 | }); 420 | stage.container().style.cursor = "nesw-resize"; 421 | }); 422 | resize.on("dragend", () => (stage.container().style.cursor = "default")); 423 | } 424 | 425 | initStage() { 426 | return new Konva.Stage({ 427 | container: this.containerId, 428 | width: this.width, 429 | height: this.height, 430 | }); 431 | } 432 | 433 | initBackground() { 434 | return new Konva.Image({ 435 | x: 0, 436 | y: 0, 437 | width: this.width, 438 | height: this.height, 439 | image: this.image, 440 | }); 441 | } 442 | 443 | initShading() { 444 | return new Konva.Rect({ 445 | x: 0, 446 | y: 0, 447 | width: this.width, 448 | height: this.height, 449 | fill: this.shadingColor, 450 | strokeWidth: 4, 451 | opacity: this.shadingOpacity, 452 | }); 453 | } 454 | 455 | initCrop() { 456 | return new Konva.Circle({ 457 | x: this.halfWidth, 458 | y: this.halfHeight, 459 | radius: this.cropRadius, 460 | fillPatternImage: this.image, 461 | fillPatternOffset: { 462 | x: this.halfWidth / this.scale, 463 | y: this.halfHeight / this.scale, 464 | }, 465 | fillPatternScale: { 466 | x: this.scale, 467 | y: this.scale, 468 | }, 469 | opacity: 1, 470 | draggable: true, 471 | dashEnabled: true, 472 | dash: [10, 5], 473 | }); 474 | } 475 | 476 | initCropStroke() { 477 | return new Konva.Circle({ 478 | x: this.halfWidth, 479 | y: this.halfHeight, 480 | radius: this.cropRadius, 481 | stroke: this.cropColor, 482 | strokeWidth: this.lineWidth, 483 | strokeScaleEnabled: true, 484 | dashEnabled: true, 485 | dash: [10, 5], 486 | }); 487 | } 488 | 489 | initResize() { 490 | return new Konva.Rect({ 491 | x: this.halfWidth + this.cropRadius * 0.86 - 8, 492 | y: this.halfHeight + this.cropRadius * -0.5 - 8, 493 | width: 16, 494 | height: 16, 495 | draggable: true, 496 | dragBoundFunc: function (pos) { 497 | return { 498 | x: this.getAbsolutePosition().x, 499 | y: pos.y, 500 | }; 501 | }, 502 | }); 503 | } 504 | 505 | initResizeIcon() { 506 | return new Konva.Path({ 507 | x: this.halfWidth + this.cropRadius * 0.86 - 8, 508 | y: this.halfHeight + this.cropRadius * -0.5 - 10, 509 | data: "M47.624,0.124l12.021,9.73L44.5,24.5l10,10l14.661-15.161l9.963,12.285v-31.5H47.624z M24.5,44.5 L9.847,59.653L0,47.5V79h31.5l-12.153-9.847L34.5,54.5L24.5,44.5z", 510 | fill: this.cropColor, 511 | scale: { 512 | x: 0.2, 513 | y: 0.2, 514 | }, 515 | }); 516 | } 517 | 518 | render() { 519 | const { width, height } = this.props; 520 | 521 | const style = { 522 | display: "flex", 523 | justifyContent: "center", 524 | backgroundColor: this.backgroundColor, 525 | width: width || this.width, 526 | position: "relative", 527 | }; 528 | 529 | const inputStyle = { 530 | width: 0.1, 531 | height: 0.1, 532 | opacity: 0, 533 | overflow: "hidden", 534 | position: "absolute", 535 | zIndex: -1, 536 | }; 537 | 538 | const label = this.props.label; 539 | 540 | const labelStyle = { 541 | ...this.props.labelStyle, 542 | ...{ lineHeight: (height || 200) + "px" }, 543 | }; 544 | 545 | const borderStyle = { 546 | ...this.props.borderStyle, 547 | ...{ 548 | width: width || 200, 549 | height: height || 200, 550 | }, 551 | }; 552 | 553 | const closeBtnStyle = { 554 | position: "absolute", 555 | zIndex: 999, 556 | cursor: "pointer", 557 | left: "10px", 558 | top: "10px", 559 | }; 560 | 561 | return ( 562 |
563 | {this.state.showLoader ? ( 564 |
565 | this.onFileLoad(e)} 567 | name={this.loaderId} 568 | type="file" 569 | id={this.loaderId} 570 | style={inputStyle} 571 | accept={this.mimeTypes} 572 | /> 573 | 576 |
577 | ) : ( 578 |
579 | 586 | 587 | 591 | 595 | 596 | 597 |
598 |
599 | )} 600 |
601 | ); 602 | } 603 | } 604 | 605 | export default Avatar; 606 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpack = require("webpack"); 3 | 4 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 5 | const HtmlWebpackPluginConfig = new HtmlWebpackPlugin({ 6 | template: "./example/index.html", 7 | filename: "index.html", 8 | inject: "body", 9 | }); 10 | 11 | module.exports = { 12 | entry: "./example/app.jsx", 13 | mode: "development", 14 | output: { 15 | path: path.resolve("dist"), 16 | filename: "bundle.js", 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | loader: "babel-loader", 22 | test: /\.js$/, 23 | exclude: /node_modules/, 24 | }, 25 | { 26 | loader: "babel-loader", 27 | test: /\.jsx$/, 28 | exclude: /node_modules/, 29 | }, 30 | ], 31 | }, 32 | plugins: [ 33 | HtmlWebpackPluginConfig, 34 | new webpack.DefinePlugin({ 35 | SOURCE_PATH: JSON.stringify("./docs"), 36 | }), 37 | ], 38 | devtool: "eval-cheap-module-source-map", 39 | devServer: { 40 | static: { 41 | directory: path.join(__dirname, "docs"), 42 | publicPath: "/docs", 43 | }, 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /webpack.example.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpack = require("webpack"); 3 | 4 | module.exports = { 5 | entry: "./example/app.jsx", 6 | mode: "development", 7 | output: { 8 | path: path.resolve("docs"), 9 | filename: "bundle.js", 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | loader: "babel-loader", 15 | test: /\.js$/, 16 | exclude: /node_modules/, 17 | }, 18 | { 19 | loader: "babel-loader", 20 | test: /\.jsx$/, 21 | exclude: /node_modules/, 22 | }, 23 | ], 24 | }, 25 | plugins: [ 26 | new webpack.DefinePlugin({ 27 | SOURCE_PATH: JSON.stringify("."), 28 | }), 29 | ], 30 | }; 31 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpack = require("webpack"); 3 | const CompressionPlugin = require("compression-webpack-plugin"); 4 | // const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin 5 | 6 | module.exports = { 7 | entry: "./src/avatar.jsx", 8 | mode: "production", 9 | output: { 10 | path: path.resolve("lib"), 11 | filename: "react-avatar.js", 12 | library: "reactAvatar", 13 | libraryTarget: "umd", 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | loader: "babel-loader", 19 | test: /\.js$/, 20 | exclude: /node_modules/, 21 | }, 22 | { 23 | loader: "babel-loader", 24 | test: /\.jsx$/, 25 | exclude: /node_modules/, 26 | }, 27 | ], 28 | }, 29 | externals: { 30 | react: "react", 31 | }, 32 | plugins: [ 33 | // new BundleAnalyzerPlugin(), 34 | new CompressionPlugin({ 35 | test: /\.js/, 36 | }), 37 | ], 38 | }; 39 | --------------------------------------------------------------------------------