├── .babelrc ├── .gitignore ├── README.md ├── dist └── index.js ├── example └── src │ ├── card.jpg │ ├── index.html │ └── index.js ├── package.json ├── src └── index.js ├── webpack.config.js ├── webpack.loaders.js └── webpack.production.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "react", 4 | "es2015" 5 | ], 6 | "plugins": [ 7 | "react-hot-loader/babel" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /example/dist 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-scratchcard 2 | 3 | A react component for displaying scratch card in your web app. 4 | 5 | ## Demo 6 | 7 | http://allu.io/react-scratchcard 8 | 9 | ## Installation 10 | 11 | ``` 12 | $ npm install react-scratchcard 13 | ``` 14 | 15 | ## Example 16 | 17 | ```javascript 18 | import React from 'react'; 19 | import ScratchCard from 'react-scratchcard'; 20 | 21 | const settings = { 22 | width: 640, 23 | height: 480, 24 | image: 'image.jpg', 25 | finishPercent: 50, 26 | onComplete: () => console.log('The card is now clear!') 27 | }; 28 | 29 | const Example = () => 30 | 31 | Congratulations! You WON! 32 | ; 33 | ``` 34 | 35 | ## Credits 36 | 37 | Heavily inspired by [this pen by André Ruffert](https://codepen.io/andreruffert/pen/pvqly). 38 | 39 | Image used in the example by [webtreats](https://www.flickr.com/photos/webtreatsetc/). 40 | -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 8 | 9 | var _react = require('react'); 10 | 11 | var _react2 = _interopRequireDefault(_react); 12 | 13 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 14 | 15 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 16 | 17 | function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } 18 | 19 | function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } 20 | 21 | var ScratchCard = function (_Component) { 22 | _inherits(ScratchCard, _Component); 23 | 24 | function ScratchCard(props) { 25 | _classCallCheck(this, ScratchCard); 26 | 27 | var _this = _possibleConstructorReturn(this, (ScratchCard.__proto__ || Object.getPrototypeOf(ScratchCard)).call(this, props)); 28 | 29 | _this.state = { loaded: false }; 30 | return _this; 31 | } 32 | 33 | _createClass(ScratchCard, [{ 34 | key: 'componentDidMount', 35 | value: function componentDidMount() { 36 | var _this2 = this; 37 | 38 | this.isDrawing = false; 39 | this.lastPoint = null; 40 | this.ctx = this.canvas.getContext('2d'); 41 | 42 | var image = new Image(); 43 | image.crossOrigin = "Anonymous"; 44 | image.onload = function () { 45 | _this2.ctx.drawImage(image, 0, 0); 46 | _this2.setState({ loaded: true }); 47 | }; 48 | image.src = this.props.image; 49 | } 50 | }, { 51 | key: 'getFilledInPixels', 52 | value: function getFilledInPixels(stride) { 53 | if (!stride || stride < 1) { 54 | stride = 1; 55 | } 56 | 57 | var pixels = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height); 58 | var total = pixels.data.length / stride; 59 | var count = 0; 60 | 61 | for (var i = 0; i < pixels.data.length; i += stride) { 62 | if (parseInt(pixels.data[i], 10) === 0) { 63 | count++; 64 | } 65 | } 66 | 67 | return Math.round(count / total * 100); 68 | } 69 | }, { 70 | key: 'getMouse', 71 | value: function getMouse(e, canvas) { 72 | var _canvas$getBoundingCl = canvas.getBoundingClientRect(), 73 | top = _canvas$getBoundingCl.top, 74 | left = _canvas$getBoundingCl.left; 75 | 76 | var scrollTop = window.pageYOffset || document.documentElement.scrollTop; 77 | var scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; 78 | 79 | return { 80 | x: (e.pageX || e.touches[0].clientX) - left - scrollLeft, 81 | y: (e.pageY || e.touches[0].clientY) - top - scrollTop 82 | }; 83 | } 84 | }, { 85 | key: 'distanceBetween', 86 | value: function distanceBetween(point1, point2) { 87 | return Math.sqrt(Math.pow(point2.x - point1.x, 2) + Math.pow(point2.y - point1.y, 2)); 88 | } 89 | }, { 90 | key: 'angleBetween', 91 | value: function angleBetween(point1, point2) { 92 | return Math.atan2(point2.x - point1.x, point2.y - point1.y); 93 | } 94 | }, { 95 | key: 'handlePercentage', 96 | value: function handlePercentage() { 97 | var filledInPixels = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0; 98 | 99 | if (filledInPixels > this.props.finishPercent) { 100 | this.canvas.parentNode.removeChild(this.canvas); 101 | this.setState({ finished: true }); 102 | if (this.props.onComplete) { 103 | this.props.onComplete(); 104 | } 105 | } 106 | } 107 | }, { 108 | key: 'handleMouseDown', 109 | value: function handleMouseDown(e) { 110 | this.isDrawing = true; 111 | this.lastPoint = this.getMouse(e, this.canvas); 112 | } 113 | }, { 114 | key: 'handleMouseMove', 115 | value: function handleMouseMove(e) { 116 | if (!this.isDrawing) { 117 | return; 118 | } 119 | 120 | e.preventDefault(); 121 | 122 | var currentPoint = this.getMouse(e, this.canvas); 123 | var distance = this.distanceBetween(this.lastPoint, currentPoint); 124 | var angle = this.angleBetween(this.lastPoint, currentPoint); 125 | 126 | var x = void 0, 127 | y = void 0; 128 | 129 | for (var i = 0; i < distance; i++) { 130 | x = this.lastPoint.x + Math.sin(angle) * i; 131 | y = this.lastPoint.y + Math.cos(angle) * i; 132 | this.ctx.globalCompositeOperation = 'destination-out'; 133 | this.ctx.beginPath(); 134 | this.ctx.arc(x, y, 25, 0, 2 * Math.PI, false); 135 | this.ctx.fill(); 136 | } 137 | 138 | this.lastPoint = currentPoint; 139 | this.handlePercentage(this.getFilledInPixels(32)); 140 | } 141 | }, { 142 | key: 'handleMouseUp', 143 | value: function handleMouseUp() { 144 | this.isDrawing = false; 145 | } 146 | }, { 147 | key: 'render', 148 | value: function render() { 149 | var _this3 = this; 150 | 151 | var containerStyle = { 152 | width: this.props.width + 'px', 153 | height: this.props.height + 'px', 154 | position: 'relative', 155 | WebkitUserSelect: 'none', 156 | MozUserSelect: 'none', 157 | msUserSelect: 'none', 158 | userSelect: 'none' 159 | }; 160 | 161 | var canvasStyle = { 162 | position: 'absolute', 163 | top: 0, 164 | zIndex: 1 165 | }; 166 | 167 | var resultStyle = { 168 | visibility: this.state.loaded ? 'visible' : 'hidden' 169 | }; 170 | 171 | var canvasProps = { 172 | ref: function ref(_ref) { 173 | return _this3.canvas = _ref; 174 | }, 175 | className: 'ScratchCard__Canvas', 176 | style: canvasStyle, 177 | width: this.props.width, 178 | height: this.props.height, 179 | onMouseDown: this.handleMouseDown.bind(this), 180 | onTouchStart: this.handleMouseDown.bind(this), 181 | onMouseMove: this.handleMouseMove.bind(this), 182 | onTouchMove: this.handleMouseMove.bind(this), 183 | onMouseUp: this.handleMouseUp.bind(this), 184 | onTouchEnd: this.handleMouseUp.bind(this) 185 | }; 186 | 187 | return _react2.default.createElement( 188 | 'div', 189 | { className: 'ScratchCard__Container', style: containerStyle }, 190 | _react2.default.createElement('canvas', canvasProps), 191 | _react2.default.createElement( 192 | 'div', 193 | { className: 'ScratchCard__Result', style: resultStyle }, 194 | this.props.children 195 | ) 196 | ); 197 | } 198 | }]); 199 | 200 | return ScratchCard; 201 | }(_react.Component); 202 | 203 | ScratchCard.propTypes = { 204 | image: _react2.default.PropTypes.string.isRequired, 205 | width: _react2.default.PropTypes.number.isRequired, 206 | height: _react2.default.PropTypes.number.isRequired, 207 | finishPercent: _react2.default.PropTypes.number.isRequired, 208 | onComplete: _react2.default.PropTypes.func 209 | }; 210 | 211 | var _default = ScratchCard; 212 | exports.default = _default; 213 | ; 214 | 215 | var _temp = function () { 216 | if (typeof __REACT_HOT_LOADER__ === 'undefined') { 217 | return; 218 | } 219 | 220 | __REACT_HOT_LOADER__.register(ScratchCard, 'ScratchCard', 'src/index.js'); 221 | 222 | __REACT_HOT_LOADER__.register(_default, 'default', 'src/index.js'); 223 | }(); 224 | 225 | ; -------------------------------------------------------------------------------- /example/src/card.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aleksik/react-scratchcard/a17d3dde75d2d85ac3551d4f49c9887b963eb693/example/src/card.jpg -------------------------------------------------------------------------------- /example/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= htmlWebpackPlugin.options.title %> 6 | 7 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /example/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | 4 | import ScratchCard from '../../src'; 5 | import cardImage from './card.jpg'; 6 | 7 | const settings = { 8 | width: 640, 9 | height: 480, 10 | image: cardImage, 11 | finishPercent: 50, 12 | onComplete: () => console.log('The card is now clear!') 13 | }; 14 | 15 | const Example = () => 16 | 17 | Congratulations! You WON! 18 | ; 19 | 20 | render(, document.getElementById('root')); 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-scratchcard", 3 | "version": "1.1.2", 4 | "description": "React component for displaying scratch card in your web app.", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "babel src -d dist", 8 | "start-example": "webpack-dev-server --progress --profile --colors", 9 | "build-example": "webpack --config webpack.production.config.js --progress --profile --colors" 10 | }, 11 | "keywords": [ 12 | "React", 13 | "Scratchcard" 14 | ], 15 | "author": "Aleksi Kaistinen (http://allu.io)", 16 | "license": "MIT", 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/aleksik/react-scratchcard.git" 20 | }, 21 | "dependencies": { 22 | "react": "^15.0.0" 23 | }, 24 | "devDependencies": { 25 | "babel-cli": "^6.16.0", 26 | "babel-core": "^6.17.0", 27 | "babel-loader": "^6.2.5", 28 | "babel-plugin-transform-class-properties": "^6.16.0", 29 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 30 | "babel-plugin-transform-runtime": "^6.15.0", 31 | "babel-preset-es2015": "^6.16.0", 32 | "babel-preset-react": "^6.16.0", 33 | "css-loader": "^0.25.0", 34 | "extract-text-webpack-plugin": "^1.0.1", 35 | "file-loader": "^0.9.0", 36 | "html-webpack-plugin": "^2.22.0", 37 | "react-hot-loader": "^3.0.0-beta.5", 38 | "style-loader": "^0.13.1", 39 | "url-loader": "^0.5.7", 40 | "webpack": "^1.13.2", 41 | "webpack-cleanup-plugin": "^0.4.0", 42 | "webpack-dev-server": "^1.16.1", 43 | "react-dom": "^15.0.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | class ScratchCard extends Component { 4 | 5 | constructor(props) { 6 | super(props); 7 | this.state = { loaded: false } 8 | } 9 | 10 | componentDidMount() { 11 | this.isDrawing = false; 12 | this.lastPoint = null; 13 | this.ctx = this.canvas.getContext('2d'); 14 | 15 | const image = new Image(); 16 | image.crossOrigin = "Anonymous"; 17 | image.onload = () => { 18 | this.ctx.drawImage(image, 0, 0); 19 | this.setState({ loaded: true }); 20 | } 21 | image.src = this.props.image; 22 | } 23 | 24 | getFilledInPixels(stride) { 25 | if (!stride || stride < 1) { 26 | stride = 1; 27 | } 28 | 29 | const pixels = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height); 30 | const total = pixels.data.length / stride; 31 | let count = 0; 32 | 33 | for (let i = 0; i < pixels.data.length; i += stride) { 34 | if (parseInt(pixels.data[i], 10) === 0) { 35 | count++; 36 | } 37 | } 38 | 39 | return Math.round((count / total) * 100); 40 | } 41 | 42 | getMouse(e, canvas) { 43 | const {top, left} = canvas.getBoundingClientRect(); 44 | const scrollTop = window.pageYOffset || document.documentElement.scrollTop; 45 | const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; 46 | 47 | return { 48 | x: (e.pageX || e.touches[0].clientX) - left - scrollLeft, 49 | y: (e.pageY || e.touches[0].clientY) - top - scrollTop 50 | } 51 | } 52 | 53 | distanceBetween(point1, point2) { 54 | return Math.sqrt( 55 | Math.pow(point2.x - point1.x, 2) + Math.pow(point2.y - point1.y, 2) 56 | ); 57 | } 58 | 59 | angleBetween(point1, point2) { 60 | return Math.atan2(point2.x - point1.x, point2.y - point1.y); 61 | } 62 | 63 | handlePercentage(filledInPixels = 0) { 64 | if (filledInPixels > this.props.finishPercent) { 65 | this.canvas.parentNode.removeChild(this.canvas); 66 | this.setState({ finished: true }); 67 | if (this.props.onComplete) { 68 | this.props.onComplete(); 69 | } 70 | } 71 | } 72 | 73 | handleMouseDown(e) { 74 | this.isDrawing = true; 75 | this.lastPoint = this.getMouse(e, this.canvas); 76 | } 77 | 78 | handleMouseMove(e) { 79 | if (!this.isDrawing) { 80 | return; 81 | } 82 | 83 | e.preventDefault(); 84 | 85 | const currentPoint = this.getMouse(e, this.canvas); 86 | const distance = this.distanceBetween(this.lastPoint, currentPoint); 87 | const angle = this.angleBetween(this.lastPoint, currentPoint); 88 | 89 | let x, y; 90 | 91 | for (let i = 0; i < distance; i++) { 92 | x = this.lastPoint.x + (Math.sin(angle) * i); 93 | y = this.lastPoint.y + (Math.cos(angle) * i); 94 | this.ctx.globalCompositeOperation = 'destination-out'; 95 | this.ctx.beginPath(); 96 | this.ctx.arc(x, y, 25, 0, 2 * Math.PI, false); 97 | this.ctx.fill(); 98 | } 99 | 100 | this.lastPoint = currentPoint; 101 | this.handlePercentage(this.getFilledInPixels(32)); 102 | 103 | } 104 | 105 | handleMouseUp() { 106 | this.isDrawing = false; 107 | } 108 | 109 | render() { 110 | 111 | const containerStyle = { 112 | width: this.props.width + 'px', 113 | height: this.props.height + 'px', 114 | position: 'relative', 115 | WebkitUserSelect: 'none', 116 | MozUserSelect: 'none', 117 | msUserSelect: 'none', 118 | userSelect: 'none' 119 | } 120 | 121 | const canvasStyle = { 122 | position: 'absolute', 123 | top: 0, 124 | zIndex: 1 125 | } 126 | 127 | const resultStyle = { 128 | visibility: this.state.loaded ? 'visible' : 'hidden' 129 | } 130 | 131 | const canvasProps = { 132 | ref: (ref) => this.canvas = ref, 133 | className: 'ScratchCard__Canvas', 134 | style: canvasStyle, 135 | width: this.props.width, 136 | height: this.props.height, 137 | onMouseDown: this.handleMouseDown.bind(this), 138 | onTouchStart: this.handleMouseDown.bind(this), 139 | onMouseMove: this.handleMouseMove.bind(this), 140 | onTouchMove: this.handleMouseMove.bind(this), 141 | onMouseUp: this.handleMouseUp.bind(this), 142 | onTouchEnd: this.handleMouseUp.bind(this) 143 | } 144 | 145 | return ( 146 |
147 | 148 |
149 | {this.props.children} 150 |
151 |
152 | ); 153 | } 154 | 155 | } 156 | 157 | ScratchCard.propTypes = { 158 | image: React.PropTypes.string.isRequired, 159 | width: React.PropTypes.number.isRequired, 160 | height: React.PropTypes.number.isRequired, 161 | finishPercent: React.PropTypes.number.isRequired, 162 | onComplete: React.PropTypes.func 163 | } 164 | 165 | export default ScratchCard; 166 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var webpack = require('webpack'); 4 | var path = require('path'); 5 | var loaders = require('./webpack.loaders'); 6 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 7 | 8 | const HOST = process.env.HOST || '127.0.0.1'; 9 | const PORT = process.env.PORT || '8888'; 10 | 11 | loaders.push({ 12 | test: /[\/\\]src[\/\\].*\.css/, 13 | exclude: /(node_modules|public)/, 14 | loaders: [ 15 | 'style?sourceMap', 16 | 'css?modules&importLoaders=1&localIdentName=[path]___[name]__[local]___[hash:base64:5]' 17 | ] 18 | }); 19 | 20 | module.exports = { 21 | entry: [ 22 | `webpack-dev-server/client?http://${HOST}:${PORT}`, 23 | `webpack/hot/only-dev-server`, 24 | `./example/src/index.js` 25 | ], 26 | devtool: process.env.WEBPACK_DEVTOOL || 'cheap-module-source-map', 27 | output: { 28 | path: path.join(__dirname, 'example/dist'), 29 | filename: 'bundle.js' 30 | }, 31 | resolve: { 32 | extensions: ['', '.js'] 33 | }, 34 | module: { 35 | loaders 36 | }, 37 | devServer: { 38 | contentBase: './example/dist', 39 | noInfo: true, 40 | hot: true, 41 | inline: true, 42 | historyApiFallback: true, 43 | port: PORT, 44 | host: HOST 45 | }, 46 | plugins: [ 47 | new webpack.NoErrorsPlugin(), 48 | new webpack.HotModuleReplacementPlugin(), 49 | new HtmlWebpackPlugin({ 50 | template: './example/src/index.html', 51 | title: 'react-scratchcard example' 52 | }) 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /webpack.loaders.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | test: /\.js?$/, 4 | exclude: /(node_modules|bower_components|public)/, 5 | loader: 'babel', 6 | query: { 7 | presets: ['es2015', 'react'], 8 | plugins: ['transform-runtime', 'transform-decorators-legacy', 'transform-class-properties'], 9 | } 10 | }, 11 | { 12 | test: /\.jpg/, 13 | exclude: /(node_modules|bower_components)/, 14 | loader: "url-loader?limit=10000&mimetype=image/jpg" 15 | } 16 | ] 17 | -------------------------------------------------------------------------------- /webpack.production.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var path = require('path'); 3 | var loaders = require('./webpack.loaders'); 4 | var ExtractTextPlugin = require('extract-text-webpack-plugin'); 5 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | var WebpackCleanupPlugin = require('webpack-cleanup-plugin'); 7 | 8 | loaders.push({ 9 | test: /[\/\\]src[\/\\].*\.css/, 10 | exclude: /(node_modules|bower_components|public)/, 11 | loader: ExtractTextPlugin.extract('style', 'css?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]') 12 | }); 13 | 14 | 15 | module.exports = { 16 | entry: [ 17 | './example/src/index.js' 18 | ], 19 | output: { 20 | path: path.join(__dirname, 'example/dist'), 21 | filename: '[chunkhash].js' 22 | }, 23 | resolve: { 24 | extensions: ['', '.js'] 25 | }, 26 | module: { 27 | loaders 28 | }, 29 | plugins: [ 30 | new WebpackCleanupPlugin(), 31 | new webpack.DefinePlugin({ 32 | 'process.env': { 33 | NODE_ENV: '"production"' 34 | } 35 | }), 36 | new webpack.optimize.UglifyJsPlugin({ 37 | compress: { 38 | warnings: false, 39 | screw_ie8: true, 40 | drop_console: true, 41 | drop_debugger: true 42 | } 43 | }), 44 | new webpack.optimize.OccurenceOrderPlugin(), 45 | new ExtractTextPlugin('[contenthash].css', { 46 | allChunks: true 47 | }), 48 | new HtmlWebpackPlugin({ 49 | template: './example/src/index.html', 50 | title: 'react-scratchcard example' 51 | }), 52 | new webpack.optimize.DedupePlugin() 53 | ] 54 | }; 55 | --------------------------------------------------------------------------------