Last active
April 25, 2025 18:15
-
-
Save ArrayIterator/beb21f398546a20f74d21fe62bf5469d to your computer and use it in GitHub Desktop.
React + Tailwind -> Puzle Captcha Image - Based on Sliding Verification & Tolerance
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { ReactNode, useEffect, useRef, useState } from 'react'; | |
export type PlacementResultProperties = { | |
valid: boolean; | |
tolerance: number; | |
puzzle: { | |
x: number; | |
y: number; | |
}, | |
piece: { | |
x: number; | |
y: number; | |
}, | |
}; | |
export type SlideCaptchaProperties = { | |
/** | |
* Callback when a result succeeds | |
* @param {Readonly<PlacementResultProperties>} result | |
*/ | |
onSuccess: (result: Readonly<PlacementResultProperties>) => void; | |
/** | |
* Callback when a result failed | |
* @param {Readonly<PlacementResultProperties>} result | |
*/ | |
onFail?: (result: Readonly<PlacementResultProperties>) => void; | |
/** | |
* Callback when button sliding | |
* @param {Readonly<PlacementResultProperties>} result | |
*/ | |
onSlide?: (result: Readonly<PlacementResultProperties>) => void; | |
/** | |
* onStart callback, when the event started drag | |
* @param {React.MouseEvent<HTMLDivElement>|React.TouchEvent<HTMLDivElement>} e m | |
*/ | |
onStart?: (e: React.MouseEvent<HTMLDivElement>) => void; | |
/** | |
* The image url (optional) - better to leave empty | |
* Default used: https://picsum.photos/${width}/${height}?_=${Math.random() | |
*/ | |
imageUrl?: string; | |
/** | |
* The tolerance between x position and canvas | |
* default: 4, allowed is: 2-20 | |
*/ | |
tolerance?: number; | |
/** | |
* ReactNode will shown when succeed | |
*/ | |
children?: ReactNode; | |
} | |
type CanvasResultProperties<T extends HTMLCanvasElement = HTMLCanvasElement> = { | |
percent: number; | |
canvas: T; | |
piece: HTMLCanvasElement, | |
pieceSize: number; | |
lineWidth: number; | |
strokeColor: string; | |
fillColor: string; | |
startX: number; | |
endX: number; | |
pieceX: number; | |
pieceY: number; | |
x: number; | |
y: number | |
} | |
const redraw = <T extends HTMLCanvasElement = HTMLCanvasElement>( | |
canvas: T, | |
piece: HTMLCanvasElement, | |
offsetStartX: number, | |
offsetEndX: number, | |
imageURL?: string | |
): Promise<CanvasResultProperties<T> | null> => { | |
const computed = window.getComputedStyle(canvas.parentNode as HTMLDivElement); | |
const width = Number(computed.width.replace('px', '')); | |
const height = Number(computed.height.replace('px', '')); | |
const image = imageURL || `https://picsum.photos/${width}/${height}?_=${Math.random()}`; | |
return new Promise((resolve: (value: null | CanvasResultProperties<T>) => void, reject: (reason: Error) => void) => { | |
const canvasCtx = canvas.getContext('2d', { willReadFrequently: true }); | |
const pieceCtx = piece.getContext('2d', { willReadFrequently: true }); | |
if (!canvasCtx || !pieceCtx) { | |
resolve(null); | |
return; | |
} | |
const img = new Image(); | |
img.onerror = () => reject(new Error('Unable to load image')); | |
img.crossOrigin = 'anonymous'; // Enable CORS | |
img.onload = () => { | |
// Set canvas dimensions | |
canvas.width = width; | |
canvas.height = height; | |
// Puzzle piece dimensions | |
const toSubtract = width > height ? height : width; | |
// range from 3.2 to 4 | |
const start = 3.2; | |
const end = 4; | |
const subTractRange = Math.round(Math.random() * (end - start) + start); | |
const pieceSize = toSubtract / subTractRange; | |
let pieceX = Math.random() * (offsetEndX - offsetStartX - pieceSize) + offsetStartX; | |
if (pieceX + pieceSize > offsetEndX) { | |
pieceX = offsetEndX - pieceSize; | |
} | |
if (pieceX <= (offsetStartX + pieceSize)) { | |
pieceX = offsetStartX + pieceSize + 10; | |
} | |
const pieceY = Math.random() * (height - pieceSize * 2) + pieceSize; | |
// const commonImageColor = getMostCommonColor(img); | |
// let color = { | |
// r: 255, g: 255, b: 255 | |
// }; | |
// if (commonImageColor) { | |
// color = getColorContrast(commonImageColor.r, commonImageColor.g, commonImageColor.b); | |
// } | |
// const { r, g, b } = color; | |
// const baseColor = `${r}, ${g}, ${b}`; | |
const lineWidth = 2; | |
// const strokeColor = `rgba(${baseColor}, 1)`; | |
// const fillColor = `rgba(${baseColor}, 0.9)`; | |
const strokeColor = `rgba(255, 255, 255, 0.7)`; | |
const fillColor = `rgba(255, 255, 255, 0.7)`; | |
piece.height = height; | |
piece.width = width; | |
const drawPath = ( | |
{ | |
ctx, | |
x, | |
y, | |
operation | |
}: { | |
ctx: CanvasRenderingContext2D, | |
x: number, | |
y: number, | |
operation: 'fill' | 'clip' | |
}) => { | |
const l = pieceSize; // Length of the square side | |
const r = pieceSize / 4; // Radius for arcs | |
const PI = Math.PI; | |
ctx.beginPath(); | |
ctx.moveTo(x, y); | |
ctx.arc(x + l / 2, y - r + 2, r, 0.72 * PI, 2.26 * PI); // Top arc | |
ctx.lineTo(x + l, y); | |
ctx.arc(x + l + r - 2, y + l / 2, r, 1.21 * PI, 2.78 * PI); // Right arc | |
ctx.lineTo(x + l, y + l); | |
ctx.lineTo(x, y + l); | |
ctx.arc(x + r - 2, y + l / 2, r + 0.4, 2.76 * PI, 1.24 * PI, true); // Left arc | |
ctx.lineTo(x, y); | |
ctx.lineWidth = lineWidth; | |
ctx.strokeStyle = strokeColor; | |
ctx.fillStyle = fillColor; | |
ctx.stroke(); | |
ctx.globalCompositeOperation = 'destination-over'; | |
if (operation === 'fill') { | |
ctx.fill(); | |
} else { | |
ctx.clip(); // Apply clipping | |
} | |
}; | |
drawPath({ | |
ctx: canvasCtx, | |
x: pieceX, | |
y: pieceY, | |
operation: 'fill' | |
}); | |
drawPath({ | |
ctx: pieceCtx, | |
x: pieceX, | |
y: pieceY, | |
operation: 'clip' | |
}); | |
canvasCtx.drawImage(img, 0, 0, width, height); | |
pieceCtx.drawImage(img, 0, 0, width, height); | |
const y1 = pieceY - pieceSize / 2 - offsetStartX; | |
const sw = pieceSize * 2; | |
const ImageData = pieceCtx.getImageData(pieceX, y1, sw, height); | |
piece.width = sw; | |
pieceCtx.putImageData(ImageData, offsetStartX / 2, y1); | |
pieceCtx.save(); | |
resolve({ | |
canvas: canvas as T, | |
piece, | |
fillColor, | |
strokeColor, | |
lineWidth, | |
startX: offsetStartX, | |
endX: offsetEndX, | |
percent: 0, | |
x: 0, | |
y: pieceY, | |
pieceX, | |
pieceY, | |
pieceSize | |
}); | |
}; | |
img.src = image; | |
}); | |
}; | |
export function SlideCaptcha(props: SlideCaptchaProperties) { | |
const { imageUrl, onSuccess, onFail, onSlide, onStart, children } = props; | |
const tolerance = typeof props.tolerance === 'number' ? (Math.min(20, Math.max(props.tolerance, 2))) : 4; | |
// ref | |
const refFill = useRef<HTMLCanvasElement>(null); | |
const refClip = useRef<HTMLCanvasElement>(null); | |
const refSlider = useRef<HTMLDivElement>(null); | |
// states | |
const [error, setError] = useState<Error | null>(null); | |
const [drawableCanvas, setDrawableCanvas] = useState<CanvasResultProperties | null>(null); | |
const [offsetXStartEnd, setOffsetXStartEnd] = useState<{ startX: number; endX: number; } | null>(null); | |
const [validated, setValidated] = useState<boolean>(false); | |
const [placements, setPlacements] = useState<PlacementResultProperties>({ | |
valid: false, | |
tolerance, | |
puzzle: { | |
x: 0, | |
y: 0 | |
}, | |
piece: { | |
x: 0, | |
y: 0 | |
} | |
}); | |
const { valid } = placements; | |
useEffect(() => { | |
if (!refFill.current || !refSlider.current) { | |
return; | |
} | |
const canvasWidth = refFill.current.offsetWidth; | |
const parent = refSlider.current.parentNode as HTMLDivElement; | |
const parentWidth = parent.offsetWidth; | |
const currentWidth = refSlider.current.offsetWidth; | |
const maxLeft = parentWidth - currentWidth; | |
// calculate minXon canvas and maxX on canvas | |
const minX = (canvasWidth - maxLeft) / 2; | |
const maxX = canvasWidth - minX; | |
setOffsetXStartEnd({ startX: minX, endX: maxX }); | |
}, [refSlider, refFill]); | |
useEffect(() => { | |
if (!refFill.current | |
|| !refClip.current | |
|| drawableCanvas | |
|| !offsetXStartEnd | |
|| validated | |
|| error | |
) { | |
return; | |
} | |
const ref = refSlider.current; | |
if (ref) { | |
const timer = 200; | |
ref.style.transition = `transform ease-in-out ${timer}ms`; | |
ref.style.transform = 'translateX(0)'; | |
setTimeout(() => ref.style.removeProperty('transition'), timer + 5); | |
} | |
setValidated(false); | |
setPlacements({ | |
valid: false, | |
tolerance, | |
puzzle: { | |
x: 0, | |
y: 0 | |
}, | |
piece: { | |
x: 0, | |
y: 0 | |
} | |
}); | |
const clip = refClip.current; | |
const fill = refFill.current; | |
redraw(fill, clip, offsetXStartEnd['startX'], offsetXStartEnd['endX'], imageUrl) | |
.then((res) => { | |
setDrawableCanvas(res); | |
}) | |
.catch((reason: Error) => { | |
// setError | |
setDrawableCanvas(null); | |
setValidated(false); | |
setError(reason); | |
}) | |
.finally(() => { | |
// refSlider.current!.style.transition = 'transform linear .2s'; | |
}); | |
}, [refFill, imageUrl, drawableCanvas, offsetXStartEnd, tolerance, validated, error]); | |
useEffect(() => { | |
if (!drawableCanvas || !refClip.current) { | |
return; | |
} | |
const { x, y, startX, pieceX } = drawableCanvas; | |
const currentRealX = pieceX - startX / 2; | |
refClip.current.style.transform = `translateX(${x}px)`; | |
const valid = x > currentRealX ? ( | |
x - currentRealX < tolerance | |
) : ( | |
currentRealX - x < tolerance | |
); | |
const result: PlacementResultProperties = { | |
valid, | |
tolerance: tolerance, | |
puzzle: { | |
x: x, | |
y: y | |
}, | |
piece: { | |
x: currentRealX, | |
y: y | |
} | |
}; | |
setPlacements(result); | |
if (onSlide) { | |
onSlide(result); | |
} | |
}, [drawableCanvas, onSlide, tolerance]); | |
useEffect(() => { | |
if (!validated) { | |
return; | |
} | |
const { valid } = placements; | |
if (valid) { | |
if (onSuccess) { | |
onSuccess(placements); | |
} | |
} else { | |
if (onFail) { | |
onFail(placements); | |
} | |
} | |
}, [validated, placements, onSuccess, onFail]); | |
if (validated && valid) { | |
return children; | |
} | |
return ( | |
<div | |
className="captcha swipe-captcha flex flex-col relative rounded-sm shadow-sm overflow-hidden bg-neutral-200 dark:bg-gray-500"> | |
<div className={'relative flex flex-col max-w-full'}> | |
<div className="relative flex flex-col w-96 h-44 max-w-full"> | |
<div | |
className={'captcha-svg-image items-center justify-center relative w-full h-36 flex flex-col p-0 m-0 basis-auto grow shrink overflow-hidden group' + | |
''}> | |
<canvas | |
data-canvas={'fill'} | |
ref={refFill} | |
className={'w-full h-full z-20' + (!drawableCanvas ? 'invisible' : '')} | |
/> | |
<canvas | |
data-canvas={'clip'} | |
ref={refClip} | |
className={'absolute h-full left-0 z-30' + (!drawableCanvas ? 'invisible' : '')} | |
/> | |
{!drawableCanvas ? ( | |
<div | |
className={'z-40 items-center justify-center absolute t-0 l-0 w-full h-full bg-neutral-200 dark:bg-gray-500'}> | |
{!error | |
? <div | |
className={'absolute animate-spin w-10 h-10 rounded-full border-3 border-gray-500 border-t-transparent dark:border-neutral-200 dark:border-t-transparent border-opacity-25 top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2'} /> | |
: ( | |
<div | |
className={'flex flex-col h-full justify-center items-center text-center text-sm'}> | |
<p>{error.message}</p> | |
<p>Click <span | |
className={'cursor-pointer font-bold'} | |
onClick={() => { | |
setDrawableCanvas(null); | |
setValidated(false); | |
setError(null); | |
}} | |
>Here</span> to retry</p> | |
</div> | |
) | |
} | |
</div> | |
) : (!validated || !valid ? ( | |
<div | |
onClick={() => { | |
setDrawableCanvas(null); | |
setValidated(false); | |
}} | |
className={'captcha-refresh z-40 absolute top-2 right-2 bg-neutral-200 dark:bg-gray-500 opacity-10 rounded-full p-1 cursor-pointer flex items-center justify-center hover:opacity-100 transition-opacity duration-200 group-hover:opacity-100'}> | |
<svg | |
width="24" | |
height="24" | |
viewBox="0 0 24 24" | |
fill="none" | |
stroke="currentColor" | |
strokeWidth="2" | |
strokeLinecap="round" | |
strokeLinejoin="round" | |
className="w-4 h-4" | |
> | |
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /> | |
<path d="M21 3v5h-5" /> | |
</svg> | |
</div> | |
) : null)} | |
</div> | |
<div | |
className="captcha__slider flex flex-col justify-center relative basis-auto h-8 m-2 rounded-full bg-neutral-300 dark:bg-gray-600"> | |
<div className="captcha__slider__bar w-full h-8 flex items-center"> | |
<div className="captcha__slider_bar_content w-full h-full flex items-center mx-1 p-0"> | |
<div | |
ref={refSlider} | |
className={'captcha__slider__handle rounded-full w-6 h-6 bg-gray-500 dark:bg-neutral-200 relative ' | |
+ (!drawableCanvas ? 'cursor-wait pointer-events-none touch-none ' : ( | |
valid ? ('bg-green-500' + (validated ? ' cursor-default' : ' cursor-grab')) : ( | |
validated && !valid ? 'bg-red-500 cursor-not-allowed' : 'cursor-grab' | |
) | |
))} | |
onMouseDown={(e: React.MouseEvent<HTMLDivElement>) => { | |
e.preventDefault(); | |
if (validated) { | |
return; | |
} | |
const target = e.currentTarget as HTMLDivElement; | |
if (!drawableCanvas) { | |
target.style.transform = `translateX(0)`; | |
return; | |
} | |
if (onStart) { | |
onStart(e); | |
} | |
const parent = target.parentNode as HTMLDivElement; | |
const xParent = parent.getBoundingClientRect().left; | |
const onMouseMove = (moveEvent: MouseEvent) => { | |
target.setAttribute('data-sliding', 'true'); | |
target.style.removeProperty('transition'); | |
const parentWidth = parent.offsetWidth; | |
const currentWidth = target.offsetWidth; | |
const maxLeft = parentWidth - currentWidth; | |
const x = Math.min( | |
maxLeft, | |
Math.max(moveEvent.clientX - xParent - currentWidth / 2, 0) | |
); | |
target.style.transform = `translateX(${x}px)`; | |
const percent = (x / maxLeft) * 100; | |
setDrawableCanvas({ ...drawableCanvas, percent, x }); | |
}; | |
const onMouseUp = () => { | |
document.removeEventListener('mousemove', onMouseMove); | |
document.removeEventListener('mouseup', onMouseUp); | |
target.removeAttribute('data-sliding'); | |
setValidated(true); | |
}; | |
document.addEventListener('mousemove', onMouseMove); | |
document.addEventListener('mouseup', onMouseUp); | |
}} | |
onTouchStart={(e) => { | |
e.stopPropagation(); | |
const target = e.currentTarget as HTMLDivElement; | |
if (!drawableCanvas) { | |
target.style.transform = `translateX(0)`; | |
return; | |
} | |
const parent = target.parentNode as HTMLDivElement; | |
const xParent = parent.getBoundingClientRect().left; | |
const onTouchMove = (moveEvent: TouchEvent) => { | |
moveEvent.preventDefault(); | |
moveEvent.stopPropagation(); | |
target.setAttribute('data-sliding', 'true'); | |
const touchMove = moveEvent.touches[0]; | |
const parentWidth = parent.offsetWidth; | |
const currentWidth = target.offsetWidth; | |
const maxLeft = parentWidth - currentWidth; | |
const x = Math.min( | |
maxLeft, | |
Math.max(touchMove.clientX - xParent - currentWidth / 2, 0) | |
); | |
target.style.transform = `translateX(${x}px)`; | |
const percent = (x / maxLeft) * 100; | |
setDrawableCanvas({ ...drawableCanvas, percent, x }); | |
}; | |
const onTouchEnd = () => { | |
document.removeEventListener('touchmove', onTouchMove); | |
document.removeEventListener('touchend', onTouchEnd); | |
target.removeAttribute('data-sliding'); | |
setValidated(true); | |
}; | |
document.addEventListener('touchmove', onTouchMove, { passive: false }); | |
document.addEventListener('touchend', onTouchEnd, { passive: false }); | |
}} | |
/> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment