Skip to content

Commit 3c607cc

Browse files
author
ws-wangjg
committed
feat: move checker
1 parent 8474a34 commit 3c607cc

4 files changed

Lines changed: 319 additions & 0 deletions

File tree

src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,4 @@ export { default as WordRotate } from './stateless/WordRotate'
152152
export { default as ScrollLayout } from './stateless/ScrollLayout'
153153
export { default as SliderCaptcha } from './stateless/SliderCaptcha'
154154
export { default as MdxEditor } from './stateless/MdxEditor'
155+
export { default as MoveChecker } from './stateless/MoveChecker'
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
.box {
2+
position: relative;
3+
width: 400px;
4+
height: 40px;
5+
background-color: #ddd;
6+
border-radius: 20px;
7+
}
8+
9+
.bgColor {
10+
position: absolute;
11+
left: 0;
12+
top: 0;
13+
height: 40px;
14+
border-radius: 20px;
15+
background: linear-gradient(to right, #68cd0f, #35cca1);
16+
transition: width 0.5s linear;
17+
}
18+
19+
.bgColorS {
20+
background: linear-gradient(to right, #68cd0f, #66e92d);
21+
}
22+
23+
.txt {
24+
position: absolute;
25+
width: 100%;
26+
height: 40px;
27+
line-height: 40px;
28+
font-size: 14px;
29+
text-align: center;
30+
transition: color 0.3s;
31+
}
32+
33+
.slider {
34+
position: absolute;
35+
left: 0;
36+
top: 0;
37+
width: 50px;
38+
height: 40px;
39+
background: linear-gradient(to right, #b8b6b5, #949393);
40+
text-align: center;
41+
border-radius: 20px;
42+
cursor: move;
43+
display: flex;
44+
justify-content: center;
45+
align-items: center;
46+
transition: left 0.5s linear;
47+
}
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
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

src/pages/home/index.jsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import GradualSpacing from '@stateless/GradualSpacing'
6363
import MultiDirectionSlide from '@stateless/MultiDirectionSlide'
6464
import InViewBasicMultiple from '@stateless/AnimInViewBasic'
6565
import DottedStepper from '@stateless/DottedStepper'
66+
import MoveChecker from '@stateless/MoveChecker'
6667
import FlipWords from '@stateless/FlipWords'
6768
import BorderBeam from '@stateless/BorderBeam'
6869
import AutoSlider from '@stateless/AutoSlider'
@@ -374,6 +375,7 @@ const Home = () => {
374375
}
375376
}
376377

378+
const mcRef = useRef(null)
377379
return (
378380
<FixTabPanel ref={scrollRef}>
379381
<section className={styles.avatar} style={{ margin: '10px 0', fontSize: 24 }}>
@@ -431,6 +433,22 @@ const Home = () => {
431433
)}
432434
</section>
433435
</section>
436+
<section style={{ margin: '10px 0', display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
437+
<MoveChecker
438+
ref={mcRef}
439+
onResult={(r) => console.log('result', r)}
440+
initialText="请按住滑块,拖动到最后"
441+
successText="验证通过"
442+
boxBg="#ddd"
443+
bgColor="linear-gradient(to right,#68cd0f,#35cca1)"
444+
bgColorS="linear-gradient(to right,#68cd0f,#66e92d)"
445+
sliderBg="#fff"
446+
sliderSuccessBg="#ddd"
447+
/>
448+
<Button type="primary" style={{ margin: '10px' }} onClick={() => mcRef.current && mcRef.current.reset()}>
449+
重置验证
450+
</Button>
451+
</section>
434452
<section style={{ marginBottom: 15, fontSize: 20 }}>
435453
I love <span className={styles.circledHighlight}>coding</span> in{' '}
436454
<AlternatingText alternateText={['JavaScript', 'TypeScript', 'React', 'Vue', 'Remix', 'Node.js']} />.
@@ -662,6 +680,7 @@ const Home = () => {
662680
<IsometricCard text="Lorem ipsum dolor sit amet consectetur adipisicing elit. Corrupti repellat, consequuntur doloribus voluptate esse iure?" />
663681
</section>
664682
<LineBordered text="A line bordered text." />
683+
665684
<section
666685
style={{
667686
display: 'flex',

0 commit comments

Comments
 (0)