├── .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 |
12 |
13 |
14 |
15 | ## Demo
16 |
17 | 
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 |

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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------