|
| 1 | +import React, { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react' |
| 2 | +import styles from './index.module.less' |
| 3 | +import { ArrowRight, CheckCircle } from 'lucide-react' |
| 4 | + |
| 5 | +type ColorObject = { type?: string; colors: string[] } |
| 6 | +type ColorProp = string | ColorObject |
| 7 | + |
| 8 | +export interface MoveCheckerHandle { |
| 9 | + reset: () => void |
| 10 | +} |
| 11 | + |
| 12 | +export interface MoveCheckerProps { |
| 13 | + onResult?: (result: boolean) => void |
| 14 | + initialText?: string |
| 15 | + successText?: string |
| 16 | + initialTextColor?: string |
| 17 | + successTextColor?: string |
| 18 | + boxBg?: string |
| 19 | + bgColor?: ColorProp |
| 20 | + bgColorS?: ColorProp |
| 21 | + sliderBg?: ColorProp |
| 22 | + sliderSuccessBg?: ColorProp |
| 23 | +} |
| 24 | + |
| 25 | +const MoveChecker = forwardRef<MoveCheckerHandle, MoveCheckerProps>( |
| 26 | + ( |
| 27 | + { |
| 28 | + onResult = undefined, |
| 29 | + initialText = '请按住滑块,拖动到最后', |
| 30 | + successText = '验证通过', |
| 31 | + initialTextColor = '#777', |
| 32 | + successTextColor = '#fff', |
| 33 | + boxBg = '#ddd', |
| 34 | + bgColor = 'linear-gradient(to right,#68cd0f,#35cca1)', |
| 35 | + bgColorS = 'linear-gradient(to right,#68cd0f,#66e92d)', |
| 36 | + sliderBg = '#fff', |
| 37 | + sliderSuccessBg = '#fff', |
| 38 | + }, |
| 39 | + ref |
| 40 | + ) => { |
| 41 | + const [isSuccess, setIsSuccess] = useState(false) |
| 42 | + const [isShow, setIsShow] = useState(false) |
| 43 | + const [bgWidth, setBgWidth] = useState(40) |
| 44 | + const [sliderLeft, setSliderLeft] = useState(0) |
| 45 | + const [txt, setTxt] = useState(initialText) |
| 46 | + const [txtColor, setTxtColor] = useState(initialTextColor) |
| 47 | + |
| 48 | + const boxRef = useRef<HTMLDivElement | null>(null) |
| 49 | + const bgColorRef = useRef<HTMLDivElement | null>(null) |
| 50 | + const sliderRef = useRef<HTMLDivElement | null>(null) |
| 51 | + const successMoveDistance = useRef(0) |
| 52 | + const downX = useRef(0) |
| 53 | + const isDragging = useRef(false) |
| 54 | + const isSuccessRef = useRef(false) |
| 55 | + const rafId = useRef<number | null>(null) |
| 56 | + const latestOffset = useRef(0) |
| 57 | + |
| 58 | + useEffect(() => { |
| 59 | + const calc = () => { |
| 60 | + if (boxRef.current && sliderRef.current) { |
| 61 | + successMoveDistance.current = boxRef.current.offsetWidth - sliderRef.current.offsetWidth |
| 62 | + } |
| 63 | + } |
| 64 | + calc() |
| 65 | + window.addEventListener('resize', calc) |
| 66 | + return () => { |
| 67 | + window.removeEventListener('resize', calc) |
| 68 | + } |
| 69 | + }, []) |
| 70 | + |
| 71 | + const getOffsetX = (offset: number, min: number, max: number) => { |
| 72 | + if (offset < min) return min |
| 73 | + if (offset > max) return max |
| 74 | + return offset |
| 75 | + } |
| 76 | + |
| 77 | + const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => { |
| 78 | + e.preventDefault() |
| 79 | + downX.current = e.clientX |
| 80 | + isDragging.current = true |
| 81 | + e.currentTarget.setPointerCapture((e as any).pointerId) |
| 82 | + |
| 83 | + latestOffset.current = 0 |
| 84 | + |
| 85 | + const performUpdate = () => { |
| 86 | + rafId.current = null |
| 87 | + const offset = latestOffset.current |
| 88 | + if (bgColorRef.current) bgColorRef.current.style.width = `${offset + 40}px` |
| 89 | + if (sliderRef.current) sliderRef.current.style.left = `${offset}px` |
| 90 | + } |
| 91 | + |
| 92 | + const handlePointerMove = (ev: PointerEvent) => { |
| 93 | + if (!isDragging.current || isSuccessRef.current) return |
| 94 | + const moveX = ev.clientX |
| 95 | + const offsetX = getOffsetX(moveX - downX.current, 0, successMoveDistance.current) |
| 96 | + latestOffset.current = offsetX |
| 97 | + if (!rafId.current) rafId.current = requestAnimationFrame(performUpdate) as any |
| 98 | + |
| 99 | + if (offsetX === successMoveDistance.current) { |
| 100 | + handleSuccess() |
| 101 | + } |
| 102 | + } |
| 103 | + |
| 104 | + const handlePointerUp = () => { |
| 105 | + if (!isSuccessRef.current) { |
| 106 | + if (bgColorRef.current) bgColorRef.current.style.width = `40px` |
| 107 | + if (sliderRef.current) sliderRef.current.style.left = `0px` |
| 108 | + setBgWidth(40) |
| 109 | + setSliderLeft(0) |
| 110 | + } else { |
| 111 | + if (boxRef.current) setBgWidth(boxRef.current.offsetWidth) |
| 112 | + setSliderLeft(successMoveDistance.current) |
| 113 | + } |
| 114 | + isDragging.current = false |
| 115 | + if (rafId.current) { |
| 116 | + cancelAnimationFrame(rafId.current) |
| 117 | + rafId.current = null |
| 118 | + } |
| 119 | + document.removeEventListener('pointermove', handlePointerMove) |
| 120 | + document.removeEventListener('pointerup', handlePointerUp) |
| 121 | + } |
| 122 | + |
| 123 | + document.addEventListener('pointermove', handlePointerMove) |
| 124 | + document.addEventListener('pointerup', handlePointerUp) |
| 125 | + } |
| 126 | + |
| 127 | + const handleSuccess = () => { |
| 128 | + setIsSuccess(true) |
| 129 | + isSuccessRef.current = true |
| 130 | + setIsShow(true) |
| 131 | + setTxt(successText) |
| 132 | + setTxtColor(successTextColor) |
| 133 | + setTimeout(() => setIsShow(false), 200) |
| 134 | + if (boxRef.current) setBgWidth(boxRef.current.offsetWidth) |
| 135 | + setSliderLeft(successMoveDistance.current) |
| 136 | + onResult && onResult(true) |
| 137 | + } |
| 138 | + |
| 139 | + const reset = () => { |
| 140 | + setIsSuccess(false) |
| 141 | + setIsShow(false) |
| 142 | + setBgWidth(40) |
| 143 | + setSliderLeft(0) |
| 144 | + isSuccessRef.current = false |
| 145 | + setTxt(initialText) |
| 146 | + setTxtColor(initialTextColor) |
| 147 | + onResult && onResult(false) |
| 148 | + } |
| 149 | + |
| 150 | + useImperativeHandle(ref, () => ({ reset })) |
| 151 | + |
| 152 | + const boxStyle = { backgroundColor: boxBg } |
| 153 | + |
| 154 | + const resolveColorProp = (bg: ColorProp): string => { |
| 155 | + if (!bg) return '' |
| 156 | + if (typeof bg === 'string') return bg |
| 157 | + if (typeof bg === 'object' && Array.isArray(bg.colors)) { |
| 158 | + if (bg.colors.length === 1) return bg.colors[0] |
| 159 | + return `linear-gradient(to right, ${bg.colors.join(',')})` |
| 160 | + } |
| 161 | + return '' |
| 162 | + } |
| 163 | + |
| 164 | + const progressStyle: React.CSSProperties = { |
| 165 | + width: `${bgWidth}px`, |
| 166 | + background: resolveColorProp(isShow ? bgColorS : bgColor), |
| 167 | + } |
| 168 | + |
| 169 | + const sliderStyle: React.CSSProperties = { |
| 170 | + left: `${sliderLeft}px`, |
| 171 | + background: resolveColorProp(isSuccess ? sliderSuccessBg : sliderBg), |
| 172 | + } |
| 173 | + |
| 174 | + const parseColorsFromString = (str: string | undefined) => { |
| 175 | + if (!str || typeof str !== 'string') return [] as string[] |
| 176 | + const results: string[] = [] |
| 177 | + const hexRe = /#([0-9a-fA-F]{3,6})/g |
| 178 | + let m: RegExpExecArray | null |
| 179 | + while ((m = hexRe.exec(str)) !== null) { |
| 180 | + results.push(m[0]) |
| 181 | + } |
| 182 | + const rgbRe = /rgba?\(([^)]+)\)/g |
| 183 | + while ((m = rgbRe.exec(str)) !== null) { |
| 184 | + results.push(`rgb(${m[1]})`) |
| 185 | + } |
| 186 | + return results |
| 187 | + } |
| 188 | + |
| 189 | + const colorStringToRgb = (color: string | undefined) => { |
| 190 | + if (!color) return null |
| 191 | + if (color.startsWith('#')) { |
| 192 | + let h = color.slice(1) |
| 193 | + if (h.length === 3) |
| 194 | + h = h |
| 195 | + .split('') |
| 196 | + .map((c) => c + c) |
| 197 | + .join('') |
| 198 | + const r = parseInt(h.substr(0, 2), 16) |
| 199 | + const g = parseInt(h.substr(2, 2), 16) |
| 200 | + const b = parseInt(h.substr(4, 2), 16) |
| 201 | + return { r, g, b } |
| 202 | + } |
| 203 | + if (color.startsWith('rgb')) { |
| 204 | + const nums = color |
| 205 | + .replace(/rgba?\(|\)/g, '') |
| 206 | + .split(',') |
| 207 | + .map((s) => parseFloat(s.trim())) |
| 208 | + return { r: nums[0], g: nums[1], b: nums[2] } |
| 209 | + } |
| 210 | + return null |
| 211 | + } |
| 212 | + |
| 213 | + const luminanceOf = ({ r, g, b }: { r: number; g: number; b: number }) => 0.2126 * r + 0.7152 * g + 0.0722 * b |
| 214 | + |
| 215 | + const extractRgbList = (bg: ColorProp) => { |
| 216 | + if (!bg) return [] as { r: number; g: number; b: number }[] |
| 217 | + let colorStrs: string[] = [] |
| 218 | + if (typeof bg === 'string') { |
| 219 | + colorStrs = parseColorsFromString(bg) |
| 220 | + } else if (typeof bg === 'object' && Array.isArray(bg.colors)) { |
| 221 | + colorStrs = bg.colors |
| 222 | + } |
| 223 | + const rgbs = colorStrs.map(colorStringToRgb).filter(Boolean) as { r: number; g: number; b: number }[] |
| 224 | + return rgbs |
| 225 | + } |
| 226 | + |
| 227 | + const deriveIconColor = (bg: ColorProp) => { |
| 228 | + const rgbs = extractRgbList(bg) |
| 229 | + if (rgbs.length === 0) return '#fff' |
| 230 | + const lums = rgbs.map(luminanceOf) |
| 231 | + const avgLum = lums.reduce((a, b) => a + b, 0) / lums.length |
| 232 | + return avgLum > 160 ? '#222' : '#fff' |
| 233 | + } |
| 234 | + |
| 235 | + const sliderIconColor = isSuccess ? deriveIconColor(sliderSuccessBg) : deriveIconColor(sliderBg) |
| 236 | + |
| 237 | + return ( |
| 238 | + <div ref={boxRef} className={styles.box} style={boxStyle}> |
| 239 | + <div ref={bgColorRef} className={styles.bgColor} style={progressStyle} /> |
| 240 | + <div className={styles.txt} style={{ color: txtColor }}> |
| 241 | + {txt} |
| 242 | + </div> |
| 243 | + <div ref={sliderRef} className={styles.slider} onPointerDown={handlePointerDown} style={sliderStyle}> |
| 244 | + {!isSuccess && <ArrowRight size={20} color={sliderIconColor} aria-hidden />} |
| 245 | + {isSuccess && <CheckCircle size={22} color={sliderIconColor} aria-hidden />} |
| 246 | + </div> |
| 247 | + </div> |
| 248 | + ) |
| 249 | + } |
| 250 | +) |
| 251 | + |
| 252 | +export default MoveChecker |
0 commit comments