UNPKG

puzzle-react

Version:

a puzzle verify component for react

299 lines (266 loc) 9.39 kB
import React from "react"; import propTypes from "prop-types"; import "./index.css"; class PuzzleReact extends React.Component { constructor(props) { super(props); this.state = { backgroundContext: null, slideContext: null, goalContext: null, offsetX: 0, offsetY: 0, isMousedown: false, mouseClientX: 0, slideOffsetX: 10, distance: 0, result: "default", }; } componentDidMount() { this.initMoveWatcher(); this.initCanvasContext(); this.drawBackgroundImage(); } componentWillUnmount() { this.removeMoveWatcher(); } /** * @description 计算属性 */ goalOffsetComputed = () => ({ left: `${this.state.offsetX}px`, top: `${this.state.offsetY}px`, }); slideOffsetComputed = () => ({ left: `${this.state.slideOffsetX + this.state.distance + 10}px`, top: `${this.state.offsetY}px`, }); puzzleButtonComputed = () => ({ left: `${this.state.distance}px`, }); puzzleResultClassComputed = () => { const classes = [ "puzzle-result", (this.state.result == "success" || this.state.result == "fail") && `puzzle-result-${this.state.result}`, ] return classes.join(" "); }; puzzleResultTextComputed = () => { const TEXT_ENMU = { success: this.props.successText, fail: this.props.failText, }; return TEXT_ENMU[this.state.result]; }; /** * @methods */ initMoveWatcher = () => { const body = document.body; body.addEventListener("mouseleave", this.onMouseLeave); body.addEventListener("mousemove", this.onMouseMove); body.addEventListener("mouseup", this.onMouseUp); }; removeMoveWatcher = () => { const body = document.body; body.removeEventListener("mouseleave", this.onMouseLeave); body.removeEventListener("mousemove", this.onMouseMove); body.removeEventListener("mouseup", this.onMouseUp); }; initCanvasContext = () => { this.setState({ backgroundContext: this.getCanvasContext("#puzzle-background"), slideContext: this.getCanvasContext("#puzzle-slide"), goalContext: this.getCanvasContext("#puzzle-goal"), }); }; getCanvasContext(selector) { const canvas = document.querySelector(selector); return canvas.getContext("2d"); } drawBackgroundImage = async () => { const img = await this.getImage(); if (img) { const scale = this.props.width / this.props.height; this.state.backgroundContext.drawImage( img, 0, 0, img.width, img.width / scale, 0, 0, this.props.width, this.props.height ); this.drawSlideImage(); } }; getImage() { return new Promise((resolve) => { const img = document.getElementById("puzzle-img"); img.onload = () => { resolve(img || false); }; }); } getBackgroundRandomArea = () => { const x = ~~(Math.random() * 80 + 140); const y = ~~((this.props.height - 50) / 2); const imgData = this.state.backgroundContext.getImageData(x, y, 50, 50); // this.state.offsetX = 10 + x; // this.state.offsetY = 10 + y; this.setState({ offsetX: 10 + x, offsetY: 10 + y, }); return imgData; }; drawSlideImage = () => { const imgData = this.getBackgroundRandomArea(); this.state.slideContext.putImageData(imgData, 0, 0); }; onMouseDown = ({ clientX }) => { this.setState({ isMousedown: true, mouseClientX: clientX, }); }; onMouseUp = () => { this.resultVerify(); this.setState({ isMousedown: false, mouseClientX: null, }); }; onMouseMove = ({ clientX }) => { if (this.state.isMousedown) { if (clientX - this.state.mouseClientX > 0) { this.setState({ distance: clientX - this.state.mouseClientX, }); } } }; onMouseLeave = () => { this.onMouseUp(); }; /** * @description * GAP 代表SLIDE 距离 GOAL 的偏差,其中 offsetX是生成GOAL的style left距离, * 注意:是距离最外层父元素的距离而不是BACKGROUND的距离,减去10是因为BACKGROUND * 距离最外层父元素有10间距偏差,然后this.slideOffsetX + this.distance是SLIDE * 真正距离BACKGROUND的距离,因为拖动按钮是从BACKGROUND对齐的. * 再次设置isMousedown = false,是因为校验完成之后不允许再次拖动,应该手动调用reset方法恢复出厂设置重新进行验证; */ resultVerify = () => { var { isMousedown, offsetX, distance, result, slideOffsetX } = this.state; if (isMousedown) { const GAP = Math.abs(offsetX - 10 - (slideOffsetX + distance)); result = GAP <= this.props.offsetDistance ? "success" : "fail"; isMousedown = false; if (this.props.errorRetry && result == "fail") { setTimeout(this.reset, 1000); } this.props.complete && this.props.complete(result); this.setState({ isMousedown, offsetX, distance, result, }); } }; reset = () => { this.resetState(); this.drawSlideImage(); this.drawBackgroundImage(); }; resetState = () => { this.setState({ isMousedown: false, mouseClientX: 0, distance: 0, result: "default", }); }; render() { const { img, width, height } = this.props; return ( <div className="puzzle"> <img crossOrigin="" hidden src={img} alt="" id="puzzle-img" /> <canvas id="puzzle-background" width={width} height={height} style={{ background: "transparent" }} ></canvas> <canvas style={this.slideOffsetComputed()} width="50" height="50" id="puzzle-slide" ></canvas> <canvas style={this.goalOffsetComputed()} width="50" height="50" id="puzzle-goal" ></canvas> <div className="puzzle-box"> <div className="puzzle-handle"> <div style={this.puzzleButtonComputed()} onMouseDown={this.onMouseDown} className="puzzle-button" > <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" > <path d="M10 17l5-5l-5-5v10z" fill="currentColor" ></path> </svg> </div> </div> </div> <div className={this.puzzleResultClassComputed()}> {this.puzzleResultTextComputed()} </div> <div className="puzzle-refresh" onClick={this.reset}> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <path d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" fill="currentColor" ></path> </svg> </div> </div> ); } } export default PuzzleReact; PuzzleReact.propTypes = { width: propTypes.number, height: propTypes.number, offsetDistance: propTypes.number, successText: propTypes.string, failText: propTypes.string, img: propTypes.string, errorRetry: propTypes.bool, complete: propTypes.func, }; PuzzleReact.defaultProps = { width: 300, height: 150, offsetDistance: 10, successText: "校验成功", failText: "校验失败", img: "https://i.loli.net/2021/08/30/lj1ciV3An8JDo6u.jpg", errorRetry: true, };