├── .babelrc ├── .editorconfig ├── .gitignore ├── .jest.js ├── .travis.yml ├── HISTORY.md ├── LICENSE ├── README.md ├── assets ├── iconfont.less └── index.less ├── examples ├── cropper.html ├── cropper.js ├── form.html ├── form.js ├── raw.html ├── raw.js ├── simple.html ├── simple.js ├── usePica.html └── usePica.js ├── index.js ├── package.json ├── src ├── CropViewer.tsx ├── Cropper.tsx ├── Icon.tsx ├── Scaler.tsx ├── Uploader.tsx ├── canvasToBlob.polyfills.ts ├── index.ts ├── locale │ ├── en_US.ts │ └── zh_CN.ts └── utils.tsx ├── tests ├── index.js ├── setup.js └── usage.spec.js └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "presets": ["es2015", "react", "stage-0"], 5 | "plugins": [ 6 | "add-module-exports" 7 | ] 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*.{js,css}] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | *.log 3 | .idea 4 | .ipr 5 | .iws 6 | *~ 7 | ~* 8 | *.diff 9 | *.patch 10 | *.bak 11 | .DS_Store 12 | Thumbs.db 13 | .project 14 | .*proj 15 | .svn 16 | *.swp 17 | *.swo 18 | *.pyc 19 | *.pyo 20 | node_modules 21 | .cache 22 | *.css 23 | build 24 | lib 25 | coverage 26 | .vscode/ 27 | es -------------------------------------------------------------------------------- /.jest.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFiles: [ 3 | './tests/setup.js', 4 | ], 5 | moduleFileExtensions: [ 6 | 'ts', 7 | 'tsx', 8 | 'js', 9 | 'jsx', 10 | 'json', 11 | ], 12 | testPathIgnorePatterns: [ 13 | '/node_modules/' 14 | ], 15 | transform: { 16 | '\\.tsx?$': './node_modules/typescript-babel-jest', 17 | '\\.js$': './node_modules/babel-jest', 18 | }, 19 | testRegex: '.*\\.spec\\.js$', 20 | collectCoverageFrom: [ 21 | 'src/**/*.{ts,tsx}', 22 | ], 23 | transformIgnorePatterns: [ 24 | '/dist/', 25 | '/node_modules/' 26 | ], 27 | snapshotSerializers: [ 28 | 'enzyme-to-json/serializer', 29 | ], 30 | }; -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | sudo: false 4 | 5 | notifications: 6 | email: 7 | - surgesoft@gmail.com 8 | 9 | node_js: 10 | - 6.9.1 11 | 12 | before_install: 13 | - | 14 | if ! git diff --name-only $TRAVIS_COMMIT_RANGE | grep -qvE '(\.md$)|(^(docs|examples))/' 15 | then 16 | echo "Only docs were updated, stopping build process." 17 | exit 18 | fi 19 | npm install npm@3.x -g 20 | phantomjs --version 21 | script: 22 | - | 23 | if [ "$TEST_TYPE" = test ]; then 24 | npm test 25 | else 26 | npm run $TEST_TYPE 27 | fi 28 | env: 29 | matrix: 30 | - TEST_TYPE=lint 31 | - TEST_TYPE=test 32 | - TEST_TYPE=coverage 33 | - TEST_TYPE=saucelabs 34 | 35 | 36 | matrix: 37 | allow_failures: 38 | - env: "TEST_TYPE=saucelabs" -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/react-component/cropping/79334d67a2ffb60996c7100ef05720f4bdb4a128/HISTORY.md -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017-present surgesoft@gmail.com 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rc-cropping 2 | --- 3 | 4 | React Cropping Component 5 | 6 | 7 | [![NPM version][npm-image]][npm-url] 8 | [![build status][travis-image]][travis-url] 9 | [![Test coverage][coveralls-image]][coveralls-url] 10 | [![gemnasium deps][gemnasium-image]][gemnasium-url] 11 | [![node version][node-image]][node-url] 12 | [![npm download][download-image]][download-url] 13 | 14 | [npm-image]: http://img.shields.io/npm/v/rc-cropping.svg?style=flat-square 15 | [npm-url]: http://npmjs.org/package/rc-cropping 16 | [travis-image]: https://img.shields.io/travis/react-component/cropping.svg?style=flat-square 17 | [travis-url]: https://travis-ci.org/react-component/cropping 18 | [coveralls-image]: https://img.shields.io/coveralls/react-component/cropping.svg?style=flat-square 19 | [coveralls-url]: https://coveralls.io/r/react-component/cropping?branch=master 20 | [gemnasium-image]: http://img.shields.io/gemnasium/react-component/cropping.svg?style=flat-square 21 | [gemnasium-url]: https://gemnasium.com/react-component/cropping 22 | [node-image]: https://img.shields.io/badge/node.js-%3E=_0.10-green.svg?style=flat-square 23 | [node-url]: http://nodejs.org/download/ 24 | [download-image]: https://img.shields.io/npm/dm/rc-cropping.svg?style=flat-square 25 | [download-url]: https://npmjs.org/package/rc-cropping 26 | 27 | ## Feature 28 | 29 | * Cropping pictures in facebook mode. 30 | * Cropping result preview. 31 | * Supports exporting circle and square picture. 32 | * I18n. 33 | * [FUTURE] Rotate picture. 34 | 35 | ## Screenshots 36 | 37 | 38 | 39 | 40 | ## Development 41 | 42 | ``` 43 | npm install 44 | npm start 45 | ``` 46 | 47 | ## Example 48 | 49 | http://localhost:8001/examples/ 50 | 51 | 52 | online example: http://react-component.github.io/cropping/ 53 | 54 | ## install 55 | 56 | 57 | [![rc-cropping](https://nodei.co/npm/rc-cropping.png)](https://npmjs.org/package/rc-cropping) 58 | 59 | 60 | ## Usage 61 | 62 | ```js 63 | var Cropping = require('rc-cropping'); 64 | var React = require('react'); 65 | 66 | ReactDOM.render( loading... } 68 | renderModal={() => } 69 | circle={true} 70 | />, document.getElementById('__react-content')); 71 | ``` 72 | 73 | ## API 74 | 75 | ### props 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 |
nametypedefaultdescription
classNameStringadditional css class of root dom node
getSpinContentFunction() => React.Component spin content of Cropper
renderModalFunction() => React.Component Modal Render of Component, you can pass any React Component to replace it.
locale'en-US' | 'zh-CN' i18n locale.
circlebooleanfalse Croppe circle image or not. If true, you'll get a circle picture. Notice: transparent background *ONLY* supported in png file, croppe jpg file will get white background.
resizerfunctionnull Cropper support custom image resize function, e.g., you can use [pica](https://github.com/nodeca/pica) to down scale your picture more perfectly
125 | 126 | 127 | ## Test Case 128 | 129 | ``` 130 | npm test 131 | npm run chrome-test 132 | ``` 133 | 134 | ## Coverage 135 | 136 | ``` 137 | npm run coverage 138 | ``` 139 | 140 | open coverage/ dir 141 | 142 | ## License 143 | 144 | rc-cropping is released under the MIT license. 145 | -------------------------------------------------------------------------------- /assets/iconfont.less: -------------------------------------------------------------------------------- 1 | @iconfont-css-prefix: cropper-icon; 2 | 3 | @font-face { 4 | font-family: 'rc-crop'; /* project id:"178487" */ 5 | src: url('//at.alicdn.com/t/font_b46n7aif4ci35wmi.eot'); 6 | src: url('//at.alicdn.com/t/font_b46n7aif4ci35wmi.eot') format('embedded-opentype'), 7 | url('//at.alicdn.com/t/font_b46n7aif4ci35wmi.woff') format('woff'), 8 | url('//at.alicdn.com/t/font_b46n7aif4ci35wmi.ttf') format('truetype'), 9 | url('//at.alicdn.com/t/font_b46n7aif4ci35wmi.svg#rc-crop') format('svg'); 10 | } 11 | 12 | .iconfont-mixin() { 13 | display: inline-block; 14 | font-style: normal; 15 | vertical-align: baseline; 16 | text-align: center; 17 | text-transform: none; 18 | text-rendering: auto; 19 | line-height: 1; 20 | 21 | &:before { 22 | display: block; 23 | font-size: 14px; 24 | font-family: "rc-crop" !important; 25 | } 26 | } 27 | 28 | .@{iconfont-css-prefix} { 29 | .iconfont-mixin(); 30 | } 31 | 32 | 33 | .@{iconfont-css-prefix}-picture:before { content: '\e602';} 34 | .@{iconfont-css-prefix}-upload:before { content: '\e617';} 35 | .@{iconfont-css-prefix}-delete:before { content: '\e656';} -------------------------------------------------------------------------------- /assets/index.less: -------------------------------------------------------------------------------- 1 | @import (inline) '../node_modules/rc-slider/assets/index.css'; 2 | @import './iconfont.less'; 3 | @crop-viewer-prefix: ~'rc'; 4 | 5 | .@{crop-viewer-prefix} { 6 | &-preview { 7 | position: relative; 8 | 9 | &-wrapper { 10 | padding: 8px; 11 | border: 1px solid #d9d9d9; 12 | border-radius: 6px; 13 | vertical-align: top; 14 | display: inline-block; 15 | } 16 | &-mask { 17 | opacity: 0; 18 | cursor: pointer; 19 | position: absolute; 20 | width: 100%; 21 | height: 100%; 22 | background: rgba(0, 0, 0, .5); 23 | font-size: 36px; 24 | color: #fff; 25 | text-align: center; 26 | transition: opacity .15s ease-in-out; 27 | &:hover { 28 | opacity: 1; 29 | } 30 | .anticon { 31 | position: absolute; 32 | top: 50%; 33 | left: 50%; 34 | margin-top: -18px; 35 | margin-left: -18px; 36 | } 37 | } 38 | } 39 | 40 | &-cropper-wrapper { 41 | overflow: hidden; 42 | background-color: #f2f2f2; 43 | padding-right: 200px; 44 | position: relative; 45 | } 46 | 47 | &-thumbnail-preview { 48 | position: absolute; 49 | height: 100%; 50 | width: 200px; 51 | right: 0; 52 | top: 0; 53 | padding: 0 14px; 54 | background-color: white; 55 | .size-1x, .size-2x { 56 | text-align: center; 57 | margin: 20px 0; 58 | } 59 | canvas { 60 | &.radius { 61 | border-radius: 50%; 62 | } 63 | image-rendering: optimizeQuality; 64 | image-rendering: -moz-crisp-edges; 65 | image-rendering: -webkit-optimize-contrast; 66 | image-rendering: optimize-contrast; 67 | -ms-interpolation-mode: nearest-neighbor; 68 | } 69 | } 70 | 71 | &-crop-viewer-uploader { 72 | width: 100%; 73 | height: 100%; 74 | border: 1px dashed #d9d9d9; 75 | transition: border-color .3s ease; 76 | cursor: pointer; 77 | border-radius: 6px; 78 | text-align: center; 79 | position: relative; 80 | 81 | .@{crop-viewer-prefix}-upload-drag-icon { 82 | .anticon { 83 | font-size: 80px; 84 | margin-top: -5px; 85 | color: #57c5f7; 86 | } 87 | } 88 | 89 | .@{crop-viewer-prefix}-upload-text { 90 | font-size: 14px; 91 | } 92 | 93 | &:hover { 94 | border-color: #57c5f7; 95 | } 96 | } 97 | 98 | &-cropper { 99 | padding-bottom: 34px; 100 | padding-top: 40px; 101 | width: 100%; 102 | height: 100%; 103 | position: relative; 104 | } 105 | 106 | &-thumbnail { 107 | width: 320px; 108 | height: 320px; 109 | margin:0 auto; 110 | position: relative; 111 | img { 112 | position: absolute; 113 | top: 0; 114 | } 115 | .thumbnail-window { 116 | width: 320px; 117 | height: 320px; 118 | box-shadow: 0 0 2px rgba(45, 183, 245, .5); 119 | overflow: hidden; 120 | position: relative; 121 | } 122 | } 123 | 124 | &-background { 125 | position: absolute; 126 | opacity: .3; 127 | cursor: move; 128 | } 129 | 130 | &-scaller { 131 | display: inline-block; 132 | text-align: center; 133 | margin-right: 200px; 134 | button { 135 | appearance: none; 136 | background: none; 137 | border: none; 138 | cursor: pointer; 139 | 140 | &:disabled { 141 | opacity: .3; 142 | } 143 | } 144 | .anticon { 145 | margin: 10px 5px; 146 | 147 | &.smaller { 148 | font-size: 12px; 149 | } 150 | 151 | &.larger { 152 | font-size: 16px; 153 | } 154 | } 155 | 156 | .@{crop-viewer-prefix}-slider { 157 | width: 150px; 158 | display: inline-block; 159 | vertical-align: bottom; 160 | border-color: transparent; 161 | margin: 3px 5px; 162 | } 163 | } 164 | } 165 | 166 | .candrag-notice-wrapper { 167 | position: absolute; 168 | width: 100%; 169 | text-align: center; 170 | top: 20px; 171 | .candrag-notice { 172 | background: rgba(0, 0, 0, .5); 173 | border: 1px solid #fff; 174 | border-color: rgba(255, 255, 255, .8); 175 | border-radius: 3px; 176 | box-shadow: 0 0 2px rgba(255, 255, 255, .5); 177 | color: #fff; 178 | display: inline-block; 179 | font-weight: 700; 180 | padding: 6px 12px; 181 | } 182 | } 183 | 184 | .@{crop-viewer-prefix}-btn { 185 | display: inline-block; 186 | margin-bottom: 0; 187 | font-weight: 500; 188 | text-align: center; 189 | -ms-touch-action: manipulation; 190 | touch-action: manipulation; 191 | cursor: pointer; 192 | background-image: none; 193 | border: 1px solid transparent; 194 | white-space: nowrap; 195 | line-height: 1.5; 196 | padding: 4px 15px; 197 | font-size: 12px; 198 | border-radius: 6px; 199 | -webkit-user-select: none; 200 | -moz-user-select: none; 201 | -ms-user-select: none; 202 | user-select: none; 203 | -webkit-transition: all .3s cubic-bezier(.645,.045,.355,1); 204 | transition: all .3s cubic-bezier(.645,.045,.355,1); 205 | position: relative; 206 | color: #666; 207 | background-color: #f7f7f7; 208 | border-color: #d9d9d9; 209 | 210 | } 211 | 212 | .@{crop-viewer-prefix}-btn-primary { 213 | color: #fff; 214 | background-color: #2db7f5; 215 | border-color: #2db7f5; 216 | 217 | &:hover { 218 | color: #fff; 219 | background-color: #57c5f7; 220 | border-color: #57c5f7; 221 | } 222 | } 223 | .@{crop-viewer-prefix}-btn-ghost { 224 | color: #666; 225 | background-color: transparent; 226 | border-color: #d9d9d9; 227 | 228 | &:hover { 229 | color: #57c5f7; 230 | background-color: transparent; 231 | border-color: #57c5f7; 232 | } 233 | } -------------------------------------------------------------------------------- /examples/cropper.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/react-component/cropping/79334d67a2ffb60996c7100ef05720f4bdb4a128/examples/cropper.html -------------------------------------------------------------------------------- /examples/cropper.js: -------------------------------------------------------------------------------- 1 | // use jsx to render html, do not modify simple.html 2 | import 'rc-cropping/assets/index.less'; 3 | import CropViewer from 'rc-cropping'; 4 | import Dialog from 'rc-dialog'; 5 | import 'rc-dialog/assets/index.css'; 6 | import Upload from 'rc-upload'; 7 | import React, { Component } from 'react'; 8 | import ReactDOM from 'react-dom'; 9 | 10 | class App extends Component { 11 | beforeUpload(file) { 12 | const cropper = this.cropper; 13 | console.log('>> cropper', this.cropper); 14 | return cropper.selectImage(file).then(image => { 15 | console.log('>> selecTImage', image); 16 | return image; 17 | }); 18 | } 19 | render() { 20 | return (
21 | 开始上传 22 | loading... } 24 | renderModal={() => } 25 | locale="zh-CN" 26 | ref={ele => this.cropper = ele} 27 | circle 28 | /> 29 |
); 30 | } 31 | } 32 | 33 | ReactDOM.render(, document.getElementById('__react-content')); 34 | -------------------------------------------------------------------------------- /examples/form.html: -------------------------------------------------------------------------------- 1 | placeholder -------------------------------------------------------------------------------- /examples/form.js: -------------------------------------------------------------------------------- 1 | // use jsx to render html, do not modify simple.html 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import PropTypes from 'prop-types'; 5 | import CropViewer from 'rc-cropping'; 6 | import { Form, Button, Modal, Spin } from 'antd'; 7 | 8 | import 'antd/dist/antd.less'; 9 | import 'rc-cropping/assets/index.less'; 10 | 11 | const FormItem = Form.Item; 12 | 13 | class NormalLoginFormComp extends React.Component { 14 | handleSubmit = (e) => { 15 | e.preventDefault(); 16 | this.props.form.validateFields((err, values) => { 17 | if (!err) { 18 | console.log('Received values of form: ', values); 19 | } 20 | }); 21 | } 22 | render() { 23 | const { getFieldDecorator } = this.props.form; 24 | const formItemLayout = { 25 | labelCol: { span: 6 }, 26 | wrapperCol: { span: 14 }, 27 | }; 28 | 29 | const tailFormItemLayout = { 30 | wrapperCol: { 31 | span: 14, 32 | offset: 6, 33 | }, 34 | }; 35 | 36 | return ( 37 |
38 | 39 | {getFieldDecorator('file', { initialValue: 'https://avatars2.githubusercontent.com/u/566097?v=3&s=88' })( 40 | } 44 | renderModal={() => } 45 | fileType="image/jpeg" 46 | accept="image/gif,image/jpeg,image/png,image/bmp,image/x-png,image/pjpeg" 47 | >请上传文件 48 | )} 49 | 50 | 51 | 54 | 55 |
56 | ); 57 | } 58 | } 59 | 60 | NormalLoginFormComp.propTypes = { 61 | form: PropTypes.object, 62 | }; 63 | 64 | const NormalLoginForm = Form.create()(NormalLoginFormComp); 65 | 66 | ReactDOM.render(, document.getElementById('__react-content')); 67 | -------------------------------------------------------------------------------- /examples/raw.html: -------------------------------------------------------------------------------- 1 | placeholder -------------------------------------------------------------------------------- /examples/raw.js: -------------------------------------------------------------------------------- 1 | // use jsx to render html, do not modify simple.html 2 | 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import PropTypes from 'prop-types'; 6 | import { Cropper } from 'rc-cropping'; 7 | 8 | import 'rc-cropping/assets/index.less'; 9 | import 'rc-dialog/assets/index.css'; 10 | 11 | 12 | function CropperContainer(props) { 13 | return (
14 | {props.title} 15 | {props.children} 16 | {props.footer} 17 |
); 18 | } 19 | 20 | CropperContainer.propTypes = { 21 | title: PropTypes.any, 22 | children: PropTypes.any, 23 | footer: PropTypes.any, 24 | }; 25 | 26 | 27 | class Test extends React.Component { 28 | constructor() { 29 | super(); 30 | this.state = { 31 | file: null, 32 | croppedFile: null, 33 | }; 34 | } 35 | onChange = () => { 36 | this.setState({ file: this.refs.file.files[0] }); 37 | } 38 | croppeFile = (fileBlob) => { 39 | const reader = new FileReader(); 40 | reader.readAsDataURL(fileBlob); 41 | reader.onload = () => { 42 | this.setState({ 43 | croppedFile: reader.result, 44 | }); 45 | }; 46 | } 47 | render() { 48 | const { croppedFile, file } = this.state; 49 | if (croppedFile) { 50 | return ; 51 | } 52 | if (!file) { 53 | return ; 54 | } 55 | return ( loading... } 59 | renderModal={(props) => } 60 | onChange={this.croppeFile} 61 | />); 62 | } 63 | } 64 | 65 | ReactDOM.render(, document.getElementById('__react-content')); 66 | -------------------------------------------------------------------------------- /examples/simple.html: -------------------------------------------------------------------------------- 1 | placeholder -------------------------------------------------------------------------------- /examples/simple.js: -------------------------------------------------------------------------------- 1 | // use jsx to render html, do not modify simple.html 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import CropViewer from 'rc-cropping'; 5 | import Dialog from 'rc-dialog'; 6 | 7 | import 'rc-cropping/assets/index.less'; 8 | import 'rc-dialog/assets/index.css'; 9 | 10 | ReactDOM.render( loading... } 12 | renderModal={() => } 13 | locale="zh-CN" 14 | circle 15 | />, document.getElementById('__react-content')); 16 | -------------------------------------------------------------------------------- /examples/usePica.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/react-component/cropping/79334d67a2ffb60996c7100ef05720f4bdb4a128/examples/usePica.html -------------------------------------------------------------------------------- /examples/usePica.js: -------------------------------------------------------------------------------- 1 | // use jsx to render html, do not modify simple.html 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import CropViewer from 'rc-cropping'; 5 | import Dialog from 'rc-dialog'; 6 | 7 | import pica from 'pica'; 8 | 9 | import 'rc-cropping/assets/index.less'; 10 | import 'rc-dialog/assets/index.css'; 11 | 12 | function resizer(from, to) { 13 | console.log('>> pica resizer', from, to); 14 | return pica().resize(from, to); 15 | } 16 | 17 | ReactDOM.render( loading... } 19 | renderModal={() => } 20 | locale="zh-CN" 21 | resizer={resizer} 22 | circle 23 | />, document.getElementById('__react-content')); 24 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // export this package's api 2 | import CropViewer from './src/'; 3 | export default CropViewer; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rc-cropping", 3 | "version": "1.0.1", 4 | "description": "CropViewer component for react", 5 | "keywords": [ 6 | "react", 7 | "react-component", 8 | "react-cropping", 9 | "cropping" 10 | ], 11 | "homepage": "https://github.com/react-component/cropping", 12 | "author": "surgesoft@gmail.com", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/react-component/cropping.git" 16 | }, 17 | "bugs": { 18 | "url": "https://github.com/react-component/cropping/issues" 19 | }, 20 | "files": [ 21 | "lib", 22 | "assets/*.css" 23 | ], 24 | "licenses": "MIT", 25 | "main": "./lib/index", 26 | "config": { 27 | "port": 8002 28 | }, 29 | "scripts": { 30 | "build": "rc-tools run build", 31 | "gh-pages": "rc-tools run gh-pages", 32 | "start": "rc-tools run server", 33 | "pub": "rc-tools run pub", 34 | "lint": "rc-tools run lint", 35 | "karma": "rc-tools run karma", 36 | "saucelabs": "rc-tools run saucelabs", 37 | "test": "jest --config .jest.js", 38 | "coverage": "jest --config .jest.js --coverage && cat ./coverage/lcov.info | coveralls" 39 | }, 40 | "dependencies": { 41 | "rc-slider": "^8.4.0" 42 | }, 43 | "devDependencies": { 44 | "coveralls": "^2.11.16", 45 | "babel-jest": "^19.0.0", 46 | "typescript": "~2.6.0", 47 | "typescript-babel-jest": "^1.0.2", 48 | "@types/classnames": "^0.0.31", 49 | "@types/react": "^0.14.44", 50 | "antd": "^2.3.2", 51 | "enzyme": "^3.1.0", 52 | "enzyme-adapter-react-16": "^1.0.2", 53 | "enzyme-to-json": "^3.1.4", 54 | "expect.js": "0.3.x", 55 | "jest": "^21.2.1", 56 | "pica": "^4.0.1", 57 | "pre-commit": "1.x", 58 | "prop-types": "^15.6.0", 59 | "rc-dialog": "^6.5.0", 60 | "rc-form": "^1.0.1", 61 | "rc-tools": "6.2.2", 62 | "rc-upload": "^2.3.7", 63 | "react": "16.0.0", 64 | "react-dom": "16.0.0" 65 | }, 66 | "pre-commit": [ 67 | "lint" 68 | ], 69 | "jest": { 70 | "collectCoverageFrom": [ 71 | "src/*" 72 | ], 73 | "setupFiles": [ 74 | "./tests/setup.js" 75 | ], 76 | "snapshotSerializers": [ 77 | "enzyme-to-json/serializer" 78 | ], 79 | "transform": { 80 | "\\.jsx?$": "./node_modules/rc-tools/scripts/jestPreprocessor.js" 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/CropViewer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Icon from './Icon'; 3 | import Uploader from './Uploader'; 4 | import Cropper from './Cropper'; 5 | 6 | export interface ICropViewerState { 7 | previewImage?: File | null; 8 | selectedImage?: string | null; 9 | } 10 | 11 | export interface ICropProps { 12 | prefixCls: string; 13 | value: Blob; 14 | onChange: (blob: Blob | null) => void; 15 | size: number[]; 16 | circle?: boolean; 17 | renderModal: (args?: any) => React.ComponentElement; 18 | getSpinContent: () => React.ComponentElement; 19 | locale?: String; 20 | accept?: string; 21 | thumbnailSizes?: number[][]; 22 | showSelected: boolean; 23 | resetPreviewAfterSelectImage: boolean; 24 | resizer?: (from: HTMLCanvasElement, to: HTMLCanvasElement) => Promise; 25 | } 26 | 27 | export default class CropViewer extends React.Component { 28 | static Cropper = Cropper; 29 | static defaultProps = { 30 | prefixCls: 'rc', 31 | size: [32, 32], 32 | circle: false, 33 | locale: 'en-US', 34 | accept: null, 35 | showSelected: true, 36 | resetPreviewAfterSelectImage: false, 37 | }; 38 | private selectImageCallback: Function | null; 39 | private cancelSelectImageCallback: Function | null; 40 | constructor(props) { 41 | super(props); 42 | this.state = { 43 | previewImage: null, 44 | selectedImage: null, 45 | }; 46 | if (props.value) { 47 | this.loadSelectedImage(props.value); 48 | } 49 | this.cancelSelectImageCallback = () => {}; 50 | } 51 | componentWillReceiveProps(nextProps) { 52 | if (nextProps.value) { 53 | this.loadSelectedImage(nextProps.value); 54 | } else { 55 | if (nextProps.value !== this.props.value) { 56 | this.setState({ 57 | previewImage: null, 58 | selectedImage: null, 59 | }); 60 | } 61 | } 62 | } 63 | loadSelectedImage = (blobOrString: Blob | string) => { 64 | const { resetPreviewAfterSelectImage } = this.props; 65 | if (typeof blobOrString === 'string') { 66 | const image = new Image(); 67 | image.onload = () => { 68 | this.setState({ 69 | selectedImage: blobOrString, 70 | previewImage: resetPreviewAfterSelectImage ? null : this.state.previewImage, 71 | }); 72 | }; 73 | image.src = blobOrString; 74 | } else { 75 | this.readBlob(blobOrString); 76 | } 77 | 78 | } 79 | readBlob = (blob: Blob) => { 80 | const { resetPreviewAfterSelectImage } = this.props; 81 | const reader = new FileReader(); 82 | reader.readAsDataURL(blob); 83 | reader.onload = () => { 84 | this.setState({ 85 | selectedImage: reader.result, 86 | previewImage: resetPreviewAfterSelectImage ? null : this.state.previewImage, 87 | }); 88 | }; 89 | } 90 | reset = () => { 91 | this.onChange(null); 92 | } 93 | selectImage = (file) => { 94 | this.setState({ 95 | previewImage: file, 96 | }); 97 | 98 | return new Promise((resolve, reject) => { 99 | this.selectImageCallback = selectedImage => { 100 | this.selectImageCallback = null; 101 | resolve(selectedImage); 102 | }; 103 | this.cancelSelectImageCallback = () => { 104 | this.cancelSelectImageCallback = null; 105 | reject(); 106 | }; 107 | }); 108 | } 109 | onChange = (fileblob: Blob | null) => { 110 | if (!this.state.previewImage) { 111 | return; 112 | } 113 | const file = fileblob ? 114 | new File( 115 | [fileblob], 116 | this.state.previewImage.name, 117 | { type: this.state.previewImage.type }, 118 | ) 119 | : null; 120 | 121 | if (this.props.onChange) { 122 | this.props.onChange(file); 123 | } 124 | if (file && this.selectImageCallback) { 125 | this.selectImageCallback(file); 126 | } 127 | if (!fileblob && this.cancelSelectImageCallback) { 128 | this.cancelSelectImageCallback(); 129 | } 130 | if (!this.props.value) { 131 | if (fileblob) { 132 | this.loadSelectedImage(fileblob); 133 | } else { 134 | this.setState({ 135 | previewImage: null, 136 | selectedImage: null, 137 | }); 138 | } 139 | } 140 | } 141 | render() { 142 | const { previewImage, selectedImage } = this.state; 143 | const { 144 | prefixCls, 145 | size, 146 | circle, 147 | getSpinContent, 148 | renderModal, 149 | locale, 150 | accept, 151 | thumbnailSizes, 152 | showSelected, 153 | resizer, 154 | } = this.props; 155 | 156 | if (showSelected && selectedImage) { 157 | return
158 |
159 |
160 | 161 |
162 | 163 |
164 |
; 165 | } 166 | if (previewImage) { 167 | return ; 179 | } 180 | return 185 | {this.props.children} 186 | ; 187 | } 188 | }; 189 | -------------------------------------------------------------------------------- /src/Cropper.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Scaler from './Scaler'; 3 | export type imageAttr = 'width' | 'height'; 4 | import dataURLtoBlob from './canvasToBlob.polyfills'; 5 | import { debounce, downScaleImage, applyTransform, getLocale } from './utils'; 6 | 7 | function isImage(file: File) { 8 | return file.type && /^image\//g.test(file.type); 9 | } 10 | 11 | export interface ImageState { 12 | width: number; 13 | height: number; 14 | left: number; 15 | top: number; 16 | [x: string]: number; 17 | }; 18 | 19 | let startX = 0; 20 | let startY = 0; 21 | let Δleft = 0; 22 | let Δtop = 0; 23 | let left = 0; 24 | let top = 0; 25 | let dragging = false; 26 | 27 | function limit(value: number, limitArray: number[]): number { 28 | const min = Math.min(limitArray[0], limitArray[1]); 29 | const max = Math.max(limitArray[0], limitArray[1]); 30 | if (value < min) { 31 | return min; 32 | } 33 | if (value > max) { 34 | return max; 35 | } 36 | return value; 37 | } 38 | 39 | export interface IDialogProps { 40 | title: any; 41 | defaultProps: any; 42 | footer: any; 43 | visible: boolean; 44 | width: number; 45 | onCancel: (args?: any) => any; 46 | onOk?: (args?: any) => any; 47 | } 48 | 49 | export interface ICropperProps { 50 | file: File; 51 | size: number[]; 52 | onChange: (args: any) => void; 53 | prefixCls?: string; 54 | circle?: boolean; 55 | spin?: React.ComponentElement; 56 | renderModal?: (args?: any) => React.ComponentElement; 57 | locale?: String; 58 | thumbnailSizes?: number[][]; 59 | resizer?: (from: HTMLCanvasElement, to: HTMLCanvasElement) => Promise; 60 | } 61 | 62 | export default class Cropper extends React.Component { 63 | static defaultProps = { 64 | prefixCls: 'rc', 65 | size: [32, 32], 66 | circle: false, 67 | onChange: () => {}, 68 | locale: 'en-US', 69 | }; 70 | 71 | refNodes: { 72 | viewport?: HTMLElement, 73 | dragger?: HTMLElement, 74 | dragNotice?: HTMLElement, 75 | Canvas2x?: HTMLCanvasElement, 76 | Canvas1x?: HTMLCanvasElement, 77 | }; 78 | 79 | updateThumbnail = debounce(() => { 80 | const { image, width, height } = this.state; 81 | if (this.refNodes) { 82 | for (const item of [ 'Canvas2x', 'Canvas1x' ]) { 83 | if (this.refNodes[item]) { 84 | this.refNodes[item].getContext('2d').drawImage(image, left, top, width, height); 85 | } 86 | } 87 | } 88 | }, 100); 89 | 90 | constructor(props) { 91 | super(props); 92 | const { size } = props; 93 | let imageState; 94 | 95 | if (size[0] === size[1]) { 96 | imageState = { 97 | width: 320, 98 | height: 320, 99 | }; 100 | } else { 101 | imageState = { 102 | height: 320, 103 | width: 320 / size[1] * size[0], 104 | } as ImageState; 105 | } 106 | 107 | this.state = { 108 | image: null, 109 | viewport: [imageState.width, imageState.height], 110 | width: 320, 111 | height: 320, 112 | dragging: false, 113 | scaleRange: [1, 1], 114 | scale: 1, 115 | visible: false, 116 | }; 117 | 118 | this.refNodes = {}; 119 | } 120 | 121 | componentDidMount() { 122 | this.readFile(this.props.file); 123 | document.addEventListener('mouseup', this.dragEnd); 124 | document.addEventListener('mousemove', this.dragOver); 125 | } 126 | 127 | componentWillUnmount() { 128 | document.removeEventListener('mouseup', this.dragEnd); 129 | document.removeEventListener('mousemove', this.dragOver); 130 | } 131 | readFile = (file: File) => { 132 | const reader = new FileReader(); 133 | if (file && isImage(file)) { 134 | reader.readAsDataURL(file); 135 | } 136 | reader.onload = () => this.loadImage(reader); 137 | } 138 | loadImage = (reader: FileReader) => { 139 | // const reader = new FileReader(); 140 | const image = new Image(); 141 | // Although you can use images without CORS approval in your canvas, doing so taints the canvas. 142 | // Once a canvas has been tainted, you can no longer pull data back out of the canvas. 143 | // For example, you can no longer use the canvas toBlob(), toDataURL(), or getImageData() methods; 144 | // doing so will throw a security error. 145 | 146 | // This protects users from having private data exposed by using images 147 | // to pull information from remote web sites without permission. 148 | 149 | image.setAttribute('crossOrigin', 'anonymous'); 150 | image.onload = () => this.normalizeImage(image); 151 | image.src = reader.result; 152 | } 153 | 154 | scaleImage = (scale) => { 155 | if (scale === this.state.scale) { 156 | return; 157 | } 158 | 159 | const { width, height } = this.state.image; 160 | const imageState = { 161 | width: width * scale, 162 | height: height * scale, 163 | } as ImageState; 164 | this.setState({ 165 | scale, 166 | width: imageState.width, 167 | height: imageState.height, 168 | widthLimit: [this.state.viewport[0] - imageState.width, 0], 169 | heightLimit: [this.state.viewport[1] - imageState.height, 0], 170 | }); 171 | 172 | left = limit( 173 | (this.state.viewport[0] - imageState.width) / 2 + Δleft, 174 | [this.state.viewport[0] - imageState.width, 0], 175 | ); 176 | top = limit( 177 | (this.state.viewport[1] - imageState.height) / 2 + Δtop, 178 | [this.state.viewport[1] - imageState.height, 0], 179 | ); 180 | this.applyPositions(); 181 | } 182 | applyImageState = (imageState) => { 183 | this.setState({ 184 | width: imageState.width, 185 | height: imageState.height, 186 | widthLimit: [this.state.viewport[0] - imageState.width, 0], 187 | heightLimit: [this.state.viewport[1] - imageState.height, 0], 188 | }, this.updateThumbnail); 189 | 190 | left = (this.state.viewport[0] - imageState.width) / 2; 191 | top = (this.state.viewport[1] - imageState.height) / 2; 192 | this.applyPositions(); 193 | } 194 | 195 | // 初始化 Cropper,图片的尺寸会默认 fit 320 * 320 196 | normalizeImage = (image) => { 197 | const { width, height } = image; 198 | const { viewport } = this.state; 199 | 200 | const widthProportional = width / viewport[0]; 201 | const heightProportional = height / viewport[1]; 202 | const ΔProportional = widthProportional / heightProportional; 203 | 204 | const IdpVar: imageAttr = ΔProportional > 1 ? 'height' : 'width'; // 自变量 205 | const depVar: imageAttr = ΔProportional > 1 ? 'width' : 'height'; // 因变量 206 | const scale = Number((viewport[Number(ΔProportional > 1)] / image[IdpVar]).toFixed(4)); 207 | // console.log('基准缩放属性:', IdpVar,':', image[IdpVar], 'px', 208 | // '缩放至:', viewport[Number(ΔProportional > 1)], 'px', 209 | // '缩放比例:', scale); // tslint:ignore 210 | const imageState = { 211 | [IdpVar]: viewport[Number(ΔProportional > 1)], 212 | [depVar]: viewport[Number(ΔProportional > 1)] / viewport[Number(ΔProportional > 1)] * image[depVar] * scale, 213 | } as ImageState; 214 | 215 | this.setState({ 216 | image, 217 | scale, 218 | scaleRange: [scale, 1.777], 219 | visible: true, 220 | }, () => this.applyImageState(imageState)); 221 | } 222 | 223 | applyPositions = () => { 224 | if (this.refNodes.viewport) { 225 | applyTransform(this.refNodes.viewport, `translate3d(${left}px,${top}px,0)`); 226 | } 227 | if (this.refNodes.dragger) { 228 | applyTransform(this.refNodes.dragger, `translate3d(${left}px,${top}px,0)`); 229 | } 230 | } 231 | 232 | onMouseDown = () => { 233 | this.setState({ 234 | dragging: true, 235 | }); 236 | } 237 | 238 | dragStart = (ev) => { 239 | dragging = true; 240 | startX = ev.clientX; 241 | startY = ev.clientY; 242 | } 243 | 244 | dragOver = (ev) => { 245 | if (dragging) { 246 | Δleft += (ev.clientX - startX); 247 | Δtop += (ev.clientY - startY); 248 | 249 | left = limit(left + (ev.clientX - startX), this.state.widthLimit); 250 | top = limit(top + (ev.clientY - startY), this.state.heightLimit); 251 | 252 | startX = ev.clientX; 253 | startY = ev.clientY; 254 | this.applyPositions(); 255 | this.updateThumbnail(); 256 | // 拖动后,不再提示可拖动 257 | if (this.refNodes.dragNotice) { 258 | this.refNodes.dragNotice.style.display = 'none'; 259 | } 260 | } 261 | } 262 | 263 | dragEnd = () => { 264 | dragging = false; 265 | } 266 | 267 | handleCancel = () => { 268 | this.props.onChange(null); 269 | this.hideModal(); 270 | } 271 | handleOk = () => { 272 | const { image, width, height, scale, viewport } = this.state; 273 | const { resizer } = this.props; 274 | downScaleImage(image, scale, resizer).then(scaledImage => { 275 | const canvas = document.createElement('canvas'); 276 | canvas.style.width = `${viewport[0]}px`; 277 | canvas.style.height = `${viewport[1]}px`; 278 | canvas.setAttribute('width', viewport[0]); 279 | canvas.setAttribute('height', viewport[1]); 280 | const context = canvas.getContext('2d'); 281 | 282 | if (!context) { 283 | return; 284 | } 285 | 286 | if (!/image\/png/g.test(this.props.file.type)) { 287 | context.fillStyle = '#fff'; 288 | context.fillRect(0, 0, viewport[0], viewport[1]); 289 | } 290 | 291 | // if circle... 292 | if (this.props.circle) { 293 | context.save(); 294 | context.beginPath(); 295 | context.arc( 296 | viewport[0] / 2, 297 | viewport[1] / 2, 298 | Math.min(viewport[0] / 2, viewport[1] / 2), 299 | 0, 300 | Math.PI * 2, 301 | true, 302 | ); 303 | context.closePath(); 304 | context.clip(); 305 | } 306 | 307 | context.drawImage(scaledImage, left, top, width, height); 308 | 309 | if (this.props.circle) { 310 | context.beginPath(); 311 | context.arc(0, 0, 2, 0, Math.PI, true); 312 | context.closePath(); 313 | context.restore(); 314 | } 315 | if (canvas.toBlob) { 316 | canvas.toBlob( blob => { 317 | this.props.onChange(blob); 318 | this.hideModal(); 319 | }, this.props.file.type); 320 | } else { 321 | const dataUrl = canvas.toDataURL(this.props.file.type); 322 | this.props.onChange(dataURLtoBlob(dataUrl)); 323 | this.hideModal(); 324 | } 325 | }); 326 | } 327 | hideModal = () => { 328 | this.setState({ 329 | visible: false, 330 | }); 331 | document.body.style.overflow = ''; 332 | } 333 | getThumbnailSize = (index): number[] => { 334 | const { size, thumbnailSizes } = this.props; 335 | if (thumbnailSizes && thumbnailSizes.hasOwnProperty(index)) { 336 | return thumbnailSizes[index]; 337 | } 338 | 339 | if (index === 0) { 340 | return [ size[0] * 2, size[1] * 2]; 341 | } 342 | return [ size[0], size[1] ]; 343 | } 344 | 345 | applyRef(refName: string, ele: Element) { 346 | this.refNodes[refName] = ele; 347 | } 348 | 349 | render() { 350 | const { prefixCls, circle, spin, renderModal } = this.props; 351 | const { image, width, height, scale, scaleRange, viewport } = this.state; 352 | const style = { left: 0, top: 0 }; 353 | const draggerEvents = { 354 | onMouseDown: this.dragStart, 355 | }; 356 | 357 | const footer = [ 358 | , 366 | , 374 | , 382 | ]; 383 | const viewPortStyle = { width: viewport[0], height: viewport[1] }; 384 | const previewClassName = circle ? 'radius' : ''; 385 | 386 | const cropperElement = image ? (
387 |
388 |
389 |
390 | 397 |
398 | 408 | {scale > scaleRange[0] ?
409 | {getLocale('drag to crop', this.props.locale)} 410 |
: null} 411 |
412 |
413 |
414 |

{getLocale('preview', this.props.locale)}

415 |
416 | 423 |

2x: {`${this.getThumbnailSize(0)[0]}px * ${this.getThumbnailSize(0)[1]}px`}

424 |
425 |
426 | 433 |

1x: {`${this.getThumbnailSize(1)[0]}px * ${this.getThumbnailSize(1)[1]}px`}

434 |
435 |
436 |
) : null; 437 | if (image) { 438 | return (
439 | {spin} 440 | {renderModal ? React.cloneElement(renderModal(), { 441 | visible: this.state.visible, 442 | title: getLocale('edit picture', this.props.locale), 443 | width: 800, 444 | footer, 445 | onCancel: this.handleCancel, 446 | }, cropperElement) :
{cropperElement} {footer}
447 | } 448 |
); 449 | } 450 | 451 | return loading... ; 452 | } 453 | } 454 | -------------------------------------------------------------------------------- /src/Icon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export default function Icon(props) { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/Scaler.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Slider from 'rc-slider'; 3 | import Icon from './Icon'; 4 | 5 | const maxValue = 1.7777; 6 | 7 | export default function CropViewer(props) { 8 | const { min, prefixCls, value } = props; 9 | if (min > maxValue || Math.abs(min - maxValue) < 0.2) { 10 | return
11 | 图像已缩放至最大比例,无法继续缩放。 12 |
; 13 | } 14 | return (
15 | 18 | 26 | 29 |
); 30 | } 31 | -------------------------------------------------------------------------------- /src/Uploader.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Icon from './Icon'; 3 | 4 | export interface IFileMeta { 5 | name: string; 6 | type: string; 7 | }; 8 | 9 | export interface IUploaderProps { 10 | onSelectImage: (file: File) => void; 11 | prefixCls?: string; 12 | accept?: string; 13 | }; 14 | 15 | export default class Uploader extends React.Component { 16 | 17 | refs: { 18 | file: HTMLInputElement; 19 | }; 20 | 21 | onClick = () => { 22 | const el = this.refs.file; 23 | if (!el) { 24 | return; 25 | } 26 | el.click(); 27 | } 28 | 29 | selectFile = () => { 30 | if (!this.refs.file || !this.refs.file.files) { 31 | return; 32 | } 33 | const file = this.refs.file.files[0]; 34 | 35 | if (/image\/*/g.test(file.type)) { 36 | this.props.onSelectImage(file); 37 | } 38 | } 39 | render() { 40 | const { prefixCls, accept } = this.props; 41 | return ( 42 | 43 | {this.props.children || Click to Upload } 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/canvasToBlob.polyfills.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * JavaScript Canvas to Blob 3 | * https://github.com/blueimp/JavaScript-Canvas-to-Blob 4 | * 5 | * Copyright 2012, Sebastian Tschan 6 | * https://blueimp.net 7 | * 8 | * Licensed under the MIT license: 9 | * http://www.opensource.org/licenses/MIT 10 | * 11 | * Based on stackoverflow user Stoive's code snippet: 12 | * http://stackoverflow.com/q/4998908 13 | */ 14 | 15 | /* global atob, Blob, define */ 16 | var CanvasPrototype = (window).HTMLCanvasElement && 17 | (window).HTMLCanvasElement.prototype 18 | var hasBlobConstructor = (window).Blob && (function () { 19 | try { 20 | return Boolean(new Blob()) 21 | } catch (e) { 22 | return false 23 | } 24 | }()) 25 | var hasArrayBufferViewSupport = hasBlobConstructor && (window).Uint8Array && 26 | (function () { 27 | try { 28 | return new Blob([new Uint8Array(100)]).size === 100 29 | } catch (e) { 30 | return false 31 | } 32 | }()) 33 | var BlobBuilder = (window).BlobBuilder || (window).WebKitBlobBuilder || 34 | (window).MozBlobBuilder || (window).MSBlobBuilder 35 | var dataURIPattern = /^data:((.*?)(;charset=.*?)?)(;base64)?,/ 36 | var dataURLtoBlob = (hasBlobConstructor || BlobBuilder) && (window).atob && 37 | (window).ArrayBuffer && (window).Uint8Array && 38 | function (dataURI) { 39 | var matches, 40 | mediaType, 41 | isBase64, 42 | dataString, 43 | byteString, 44 | arrayBuffer, 45 | intArray, 46 | i, 47 | bb 48 | // Parse the dataURI components as per RFC 2397 49 | matches = dataURI.match(dataURIPattern) 50 | if (!matches) { 51 | throw new Error('invalid data URI') 52 | } 53 | // Default to text/plain;charset=US-ASCII 54 | mediaType = matches[2] 55 | ? matches[1] 56 | : 'text/plain' + (matches[3] || ';charset=US-ASCII') 57 | isBase64 = !!matches[4] 58 | dataString = dataURI.slice(matches[0].length) 59 | if (isBase64) { 60 | // Convert base64 to raw binary data held in a string: 61 | byteString = atob(dataString) 62 | } else { 63 | // Convert base64/URLEncoded data component to raw binary: 64 | byteString = decodeURIComponent(dataString) 65 | } 66 | // Write the bytes of the string to an ArrayBuffer: 67 | arrayBuffer = new ArrayBuffer(byteString.length) 68 | intArray = new Uint8Array(arrayBuffer) 69 | for (i = 0; i < byteString.length; i += 1) { 70 | intArray[i] = byteString.charCodeAt(i) 71 | } 72 | // Write the ArrayBuffer (or ArrayBufferView) to a blob: 73 | if (hasBlobConstructor) { 74 | return new Blob( 75 | [hasArrayBufferViewSupport ? intArray : arrayBuffer], 76 | {type: mediaType} 77 | ) 78 | } 79 | bb = new BlobBuilder() 80 | bb.append(arrayBuffer) 81 | return bb.getBlob(mediaType) 82 | } 83 | 84 | if ((window).HTMLCanvasElement && !CanvasPrototype.toBlob) { 85 | if (CanvasPrototype.mozGetAsFile) { 86 | CanvasPrototype.toBlob = function (callback, type, quality) { 87 | if (quality && CanvasPrototype.toDataURL && dataURLtoBlob) { 88 | callback(dataURLtoBlob(this.toDataURL(type, quality))) 89 | } else { 90 | callback(this.mozGetAsFile('blob', type)) 91 | } 92 | } 93 | } else if (CanvasPrototype.toDataURL && dataURLtoBlob) { 94 | CanvasPrototype.toBlob = function (callback, type, quality) { 95 | callback(dataURLtoBlob(this.toDataURL(type, quality))) 96 | } 97 | } 98 | } 99 | 100 | export default dataURLtoBlob; -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // export this package's api 2 | import CropViewer from './CropViewer'; 3 | 4 | export default CropViewer; 5 | -------------------------------------------------------------------------------- /src/locale/en_US.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | upload: 'Upload', 3 | submit: 'Submit', 4 | cancel: 'Cancel', 5 | preview: 'Preview', 6 | 'edit picture': 'Edit Picture', 7 | 'drag to crop': 'Drag to Crop', 8 | 'click to upload': 'Click to upload', 9 | } -------------------------------------------------------------------------------- /src/locale/zh_CN.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | upload: '上传', 3 | submit: '提交', 4 | cancel: '取消', 5 | preview: '预览', 6 | 'edit picture': '编辑图片', 7 | 'drag to crop': '拖动以调整大小', 8 | 'click to upload': '点击上传', 9 | } -------------------------------------------------------------------------------- /src/utils.tsx: -------------------------------------------------------------------------------- 1 | import zhCN from './locale/zh_CN'; 2 | import enUS from './locale/en_US'; 3 | 4 | export type Scaler = (from: HTMLCanvasElement, to: HTMLCanvasElement) => Promise; 5 | export function debounce(func, wait, immediate: boolean = false) { 6 | let timeout; 7 | return function debounceFunc() { 8 | const context = this; 9 | const args = arguments; 10 | // https://fb.me/react-event-pooling 11 | if (args[0] && args[0].persist) { 12 | args[0].persist(); 13 | } 14 | const later = () => { 15 | timeout = null; 16 | if (!immediate) { 17 | func.apply(context, args); 18 | } 19 | }; 20 | const callNow = immediate && !timeout; 21 | clearTimeout(timeout); 22 | timeout = setTimeout(later, wait); 23 | if (callNow) { 24 | func.apply(context, args); 25 | } 26 | }; 27 | } 28 | 29 | function getTransformProperty(node) { 30 | const properties = [ 31 | 'transform', 32 | 'WebkitTransform', 33 | 'msTransform', 34 | 'MozTransform', 35 | 'OTransform', 36 | ]; 37 | let p = properties.shift(); 38 | while (p) { 39 | if (typeof node.style[p] !== 'undefined') { 40 | return p; 41 | } 42 | p = properties.shift(); 43 | } 44 | return ''; 45 | }; 46 | 47 | export function applyTransform(element, transformString: string) { 48 | const transformProperty = getTransformProperty(element); 49 | if (transformProperty) { 50 | element.style[transformProperty] = transformString; 51 | } 52 | } 53 | 54 | // pixel-perfect downsampling 55 | export function downScaleImage(img, scale, scaler: Scaler = defaultDownScaler): Promise { 56 | const sourceCanvas = document.createElement('canvas'); 57 | sourceCanvas.width = img.width; 58 | sourceCanvas.height = img.height; 59 | const imgCtx = sourceCanvas.getContext('2d'); 60 | if (!imgCtx) { 61 | return Promise.reject('canvas error'); 62 | } 63 | imgCtx.drawImage(img, 0, 0); 64 | if (scale >= 1) { 65 | return Promise.resolve(sourceCanvas); 66 | } 67 | 68 | const targetCanvas = document.createElement('canvas'); 69 | const tw = Math.floor(img.width * scale); // target image width 70 | const th = Math.floor(img.height * scale); // target image height 71 | targetCanvas.width = tw; 72 | targetCanvas.height = th; 73 | 74 | return scaler(sourceCanvas, targetCanvas); 75 | } 76 | 77 | function defaultDownScaler(from, to) { 78 | return new Promise(resolve => { 79 | resolve(downScaleCanvas(from, to)); 80 | }); 81 | } 82 | 83 | /* tslint:disable */ 84 | 85 | function downScaleCanvas(cv, resCV):HTMLCanvasElement { 86 | const scale = resCV.width / cv.width; 87 | if (!(scale < 1) || !(scale > 0)) throw ('scale must be a positive number <1 '); 88 | var sqScale = scale * scale; // square scale = area of source pixel within target 89 | var sw = cv.width; // source image width 90 | var sh = cv.height; // source image height 91 | var tw = Math.floor(sw * scale); // target image width 92 | var th = Math.floor(sh * scale); // target image height 93 | var sx = 0, sy = 0, sIndex = 0; // source x,y, index within source array 94 | var tx = 0, ty = 0, yIndex = 0, tIndex = 0; // target x,y, x,y index within target array 95 | var tX = 0, tY = 0; // rounded tx, ty 96 | var w = 0, nw = 0, wx = 0, nwx = 0, wy = 0, nwy = 0; // weight / next weight x / y 97 | // weight is weight of current source point within target. 98 | // next weight is weight of current source point within next target's point. 99 | var crossX = false; // does scaled px cross its current px right border ? 100 | var crossY = false; // does scaled px cross its current px bottom border ? 101 | var sBuffer = cv.getContext('2d'). 102 | getImageData(0, 0, sw, sh).data; // source buffer 8 bit rgba 103 | var tBuffer = new Float32Array(3 * tw * th); // target buffer Float32 rgb 104 | var sR = 0, sG = 0, sB = 0; // source's current point r,g,b 105 | /* untested ! 106 | var sA = 0; //source alpha */ 107 | 108 | for (sy = 0; sy < sh; sy++) { 109 | ty = sy * scale; // y src position within target 110 | tY = 0 | ty; // rounded : target pixel's y 111 | yIndex = 3 * tY * tw; // line index within target array 112 | crossY = (tY != (0 | ty + scale)); 113 | if (crossY) { // if pixel is crossing botton target pixel 114 | wy = (tY + 1 - ty); // weight of point within target pixel 115 | nwy = (ty + scale - tY - 1); // ... within y+1 target pixel 116 | } 117 | for (sx = 0; sx < sw; sx++, sIndex += 4) { 118 | tx = sx * scale; // x src position within target 119 | tX = 0 | tx; // rounded : target pixel's x 120 | tIndex = yIndex + tX * 3; // target pixel index within target array 121 | crossX = (tX != (0 | tx + scale)); 122 | if (crossX) { // if pixel is crossing target pixel's right 123 | wx = (tX + 1 - tx); // weight of point within target pixel 124 | nwx = (tx + scale - tX - 1); // ... within x+1 target pixel 125 | } 126 | sR = sBuffer[sIndex ]; // retrieving r,g,b for curr src px. 127 | sG = sBuffer[sIndex + 1]; 128 | sB = sBuffer[sIndex + 2]; 129 | 130 | /* !! untested : handling alpha !! 131 | sA = sBuffer[sIndex + 3]; 132 | if (!sA) continue; 133 | if (sA != 0xFF) { 134 | sR = (sR * sA) >> 8; // or use /256 instead ?? 135 | sG = (sG * sA) >> 8; 136 | sB = (sB * sA) >> 8; 137 | } 138 | */ 139 | if (!crossX && !crossY) { // pixel does not cross 140 | // just add components weighted by squared scale. 141 | tBuffer[tIndex ] += sR * sqScale; 142 | tBuffer[tIndex + 1] += sG * sqScale; 143 | tBuffer[tIndex + 2] += sB * sqScale; 144 | } else if (crossX && !crossY) { // cross on X only 145 | w = wx * scale; 146 | // add weighted component for current px 147 | tBuffer[tIndex ] += sR * w; 148 | tBuffer[tIndex + 1] += sG * w; 149 | tBuffer[tIndex + 2] += sB * w; 150 | // add weighted component for next (tX+1) px 151 | nw = nwx * scale 152 | tBuffer[tIndex + 3] += sR * nw; 153 | tBuffer[tIndex + 4] += sG * nw; 154 | tBuffer[tIndex + 5] += sB * nw; 155 | } else if (crossY && !crossX) { // cross on Y only 156 | w = wy * scale; 157 | // add weighted component for current px 158 | tBuffer[tIndex ] += sR * w; 159 | tBuffer[tIndex + 1] += sG * w; 160 | tBuffer[tIndex + 2] += sB * w; 161 | // add weighted component for next (tY+1) px 162 | nw = nwy * scale 163 | tBuffer[tIndex + 3 * tw ] += sR * nw; 164 | tBuffer[tIndex + 3 * tw + 1] += sG * nw; 165 | tBuffer[tIndex + 3 * tw + 2] += sB * nw; 166 | } else { // crosses both x and y : four target points involved 167 | // add weighted component for current px 168 | w = wx * wy; 169 | tBuffer[tIndex ] += sR * w; 170 | tBuffer[tIndex + 1] += sG * w; 171 | tBuffer[tIndex + 2] += sB * w; 172 | // for tX + 1; tY px 173 | nw = nwx * wy; 174 | tBuffer[tIndex + 3] += sR * nw; 175 | tBuffer[tIndex + 4] += sG * nw; 176 | tBuffer[tIndex + 5] += sB * nw; 177 | // for tX ; tY + 1 px 178 | nw = wx * nwy; 179 | tBuffer[tIndex + 3 * tw ] += sR * nw; 180 | tBuffer[tIndex + 3 * tw + 1] += sG * nw; 181 | tBuffer[tIndex + 3 * tw + 2] += sB * nw; 182 | // for tX + 1 ; tY +1 px 183 | nw = nwx * nwy; 184 | tBuffer[tIndex + 3 * tw + 3] += sR * nw; 185 | tBuffer[tIndex + 3 * tw + 4] += sG * nw; 186 | tBuffer[tIndex + 3 * tw + 5] += sB * nw; 187 | } 188 | } // end for sx 189 | } // end for sy 190 | 191 | // create result canvas 192 | var resCtx = resCV.getContext('2d'); 193 | var imgRes = resCtx.getImageData(0, 0, tw, th); 194 | var tByteBuffer = imgRes.data; 195 | // convert float32 array into a UInt8Clamped Array 196 | var pxIndex = 0; // 197 | for (sIndex = 0, tIndex = 0; pxIndex < tw * th; sIndex += 3, tIndex += 4, pxIndex++) { 198 | tByteBuffer[tIndex] = Math.ceil(tBuffer[sIndex]); 199 | tByteBuffer[tIndex + 1] = Math.ceil(tBuffer[sIndex + 1]); 200 | tByteBuffer[tIndex + 2] = Math.ceil(tBuffer[sIndex + 2]); 201 | tByteBuffer[tIndex + 3] = 255; 202 | } 203 | // writing result to canvas. 204 | resCtx.putImageData(imgRes, 0, 0); 205 | return resCV; 206 | } 207 | 208 | /* tslint:enable */ 209 | 210 | export function getLocale(text, locale) { 211 | const dict = locale === 'en-US' ? enUS : zhCN; 212 | if (dict.hasOwnProperty(text)) { 213 | return dict[text]; 214 | } 215 | return text; 216 | } 217 | -------------------------------------------------------------------------------- /tests/index.js: -------------------------------------------------------------------------------- 1 | // do not add tests to this file, add tests to other .spec.js 2 | const req = require.context('.', false, /\.spec\.js$/); 3 | req.keys().forEach(req); 4 | -------------------------------------------------------------------------------- /tests/setup.js: -------------------------------------------------------------------------------- 1 | global.requestAnimationFrame = global.requestAnimationFrame || function requestAnimationFrame(cb) { 2 | return setTimeout(cb, 0); 3 | }; 4 | 5 | const Enzyme = require('enzyme'); 6 | const Adapter = require('enzyme-adapter-react-16'); 7 | 8 | Enzyme.configure({ adapter: new Adapter() }); 9 | -------------------------------------------------------------------------------- /tests/usage.spec.js: -------------------------------------------------------------------------------- 1 | // add spec here! 2 | import React from 'react'; 3 | import Dialog from 'rc-dialog'; 4 | import CropViewer from '../src'; 5 | import { mount } from 'enzyme'; 6 | 7 | describe('Cropper', () => { 8 | it('should mount', () => { 9 | const cropper = mount( loading... } 11 | renderModal={() => } 12 | locale="zh-CN" 13 | circle 14 | />); 15 | 16 | expect(cropper).not.toBe(null); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noUnusedParameters": true, 4 | "noUnusedLocals": true, 5 | "strictNullChecks": true, 6 | "allowSyntheticDefaultImports": true, 7 | "declaration": true, 8 | "target": "ES6", 9 | "experimentalDecorators": true, 10 | "sourceMap": true, 11 | "moduleResolution": "node", 12 | "isolatedModules": false, 13 | "jsx": "react" 14 | } 15 | } --------------------------------------------------------------------------------