Skip to content

Instantly share code, notes, and snippets.

@ArrayIterator
Last active April 25, 2025 18:15
Show Gist options
  • Save ArrayIterator/beb21f398546a20f74d21fe62bf5469d to your computer and use it in GitHub Desktop.
Save ArrayIterator/beb21f398546a20f74d21fe62bf5469d to your computer and use it in GitHub Desktop.

Revisions

  1. ArrayIterator revised this gist Apr 25, 2025. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion SlideCaptcha.tsx
    Original file line number Diff line number Diff line change
    @@ -23,7 +23,7 @@ export type SlideCaptchaProperties = {
    * Callback when a result failed
    * @param {Readonly<PlacementResultProperties>} result
    */
    onFail: (result: Readonly<PlacementResultProperties>) => void;
    onFail?: (result: Readonly<PlacementResultProperties>) => void;
    /**
    * Callback when button sliding
    * @param {Readonly<PlacementResultProperties>} result
  2. ArrayIterator created this gist Apr 25, 2025.
    512 changes: 512 additions & 0 deletions SlideCaptcha.tsx
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,512 @@
    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>
    );
    }