483

usePaint

Hook that allows you to draw in a specific area

elementslowtest coverage
import type { Line } from '@siberiacancode/reactuse';

import { useHotkeys, usePaint } from '@siberiacancode/reactuse';
import { Redo2Icon, Undo2Icon } from 'lucide-react';

import { cn } from '@/utils/lib';

const SIZES = [
  { label: 'S', value: 4 },
  { label: 'M', value: 10 },
  { label: 'L', value: 18 }
];

const LINES_KEY = 'paint-lines';
const getStoredLines = () => {
  if (typeof localStorage === 'undefined') return '[]' as unknown as Line[];
  JSON.parse(localStorage.getItem(LINES_KEY) ?? '[]') as Line[];
};

const Demo = () => {
  const paint = usePaint(
    { color: '#3b82f6', radius: 10, lines: getStoredLines() },
    {
      smooth: true,
      onMouseUp: (_event, instance) =>
        localStorage.setItem(LINES_KEY, JSON.stringify(instance.lines))
    }
  );

  const onUndo = () => {
    paint.undo();
    localStorage.setItem(LINES_KEY, JSON.stringify(paint.lines));
  };

  const onRedo = () => {
    paint.redo();
    localStorage.setItem(LINES_KEY, JSON.stringify(paint.lines));
  };

  const onClear = () => {
    paint.clear();
    localStorage.removeItem(LINES_KEY);
  };

  useHotkeys('control+keyz', (event) => {
    event.preventDefault();
    if (event.shiftKey) return onRedo();
    onUndo();
  });

  useHotkeys('meta+keyz', (event) => {
    event.preventDefault();
    if (event.shiftKey) return onRedo();
    onUndo();
  });

  return (
    <section className='flex w-full max-w-md flex-col gap-3 p-4'>
      <div className='flex items-center justify-between gap-3'>
        <div className='flex items-center gap-2'>
          <label
            className='border-border relative flex size-5 cursor-pointer items-center justify-center overflow-hidden rounded-full! border'
            style={{ backgroundColor: paint.color }}
          >
            <input
              className='absolute inset-0 cursor-pointer p-0! opacity-0'
              type='color'
              value={paint.color}
              onChange={(event) => paint.changeColor(event.target.value)}
            />
          </label>

          <div className='bg-muted flex items-center gap-0.5 rounded-lg p-0.5'>
            {SIZES.map((size) => (
              <button
                key={size.value}
                className={cn(
                  'flex h-7! items-center justify-center rounded-md! p-2!',
                  paint.radius === size.value ? 'bg-background' : 'text-muted-foreground'
                )}
                data-variant='ghost'
                type='button'
                onClick={() => paint.changeRadius(size.value)}
              >
                {size.label}
              </button>
            ))}
          </div>
        </div>

        <div className='flex items-center gap-1'>
          <button
            aria-label='Undo'
            data-size='icon-sm'
            data-variant='outline'
            disabled={!paint.canUndo}
            type='button'
            onClick={onUndo}
          >
            <Undo2Icon className='size-4' />
          </button>
          <button
            aria-label='Redo'
            data-size='icon-sm'
            data-variant='outline'
            disabled={!paint.canRedo}
            type='button'
            onClick={onRedo}
          >
            <Redo2Icon className='size-4' />
          </button>
          <button data-size='sm' data-variant='outline' type='button' onClick={onClear}>
            Clear
          </button>
        </div>
      </div>

      <div className='border-border bg-card relative overflow-hidden rounded-xl border'>
        <canvas ref={paint.ref} className='h-[300px] w-full cursor-crosshair touch-none' />

        {!paint.lines.length && !paint.drawing && (
          <div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
            <span className='text-muted-foreground text-sm'>Draw something here</span>
          </div>
        )}
      </div>
    </section>
  );
};

export default Demo;

Installation

pnpm add @siberiacancode/reactuse

Usage

const paint = usePaint(canvasRef, { color: 'red', radius: 10 });
// or
const { ref, draw, clear, undo, redo, changeColor } = usePaint({ color: 'red', radius: 10 }, { smooth: true });

Type Declarations

import type { HookTarget } from '@/utils/helpers';

import type { StateRef } from '../useRefState/useRefState';

export interface Point {
  x: number;
  y: number;
}

export interface Line {
  color: string;
  opacity: number;
  points: Point[];
  radius: number;
}

export interface UsePaintInitialValue {
  /** The initial brush color */
  color?: string;
  /** The initial lines */
  lines?: Line[];
  /** The initial brush opacity */
  opacity?: number;
  /** The initial brush radius */
  radius?: number;
}

export interface UsePaintOptions {
  /** Whether the brush movement is smooth */
  smooth?: boolean;
  /** The callback when the mouse is down */
  onMouseDown?: (event: MouseEvent, paint: Paint) => void;
  /** The callback when the mouse is moved */
  onMouseMove?: (event: MouseEvent, paint: Paint) => void;
  /** The callback when the mouse is up */
  onMouseUp?: (event: MouseEvent, paint: Paint) => void;
}

export interface UsePaintReturn {
  /** Whether redo is available */
  canRedo: boolean;
  /** Whether undo is available */
  canUndo: boolean;
  /** The current brush color */
  color: string;
  /** Whether the user is drawing */
  drawing: boolean;
  /** The current lines */
  lines: Line[];
  /** The current brush opacity */
  opacity: number;
  /** The current brush radius */
  radius: number;
  /** Changes the brush color */
  changeColor: (color: string) => void;
  /** Changes the brush opacity */
  changeOpacity: (opacity: number) => void;
  /** Changes the brush radius */
  changeRadius: (radius: number) => void;
  /** Clears the canvas */
  clear: () => void;
  /** Draws a line */
  draw: (line: Line) => void;
  /** Redoes the last undone line */
  redo: () => void;
  /** Undoes the last line */
  undo: () => void;
}

export interface UsePaint {
  (
    target: HookTarget,
    initialValue?: UsePaintInitialValue,
    options?: UsePaintOptions
  ): UsePaintReturn;

  <Target extends HTMLCanvasElement>(
    initialValue?: UsePaintInitialValue,
    options?: UsePaintOptions
  ): UsePaintReturn & { ref: StateRef<Target> };
}

API

Parameters

NameTypeDefaultNote
targetHookTarget-The target element to be painted
initialValueUsePaintInitialValue-The initial value of the paint
optionsUsePaintOptions-The options to be used

Returns

UsePaintReturn

Parameters

NameTypeDefaultNote
initialValueUsePaintInitialValue-The initial value of the paint
optionsUsePaintOptions-The options to be used

Returns

UsePaintReturn & { ref: StateRef<HTMLCanvasElement> }

Contributors

ddebabinhhywaxVVladPorvin

Last updated on