0
0

More than 1 year has passed since last update.

ReactでClip Studio PaintやSAIのようなカラーピッカー

Last updated at Posted at 2022-07-17

SAIやクリップスタジオペイントなどの和製ペイントソフトにありがちなカラーピッカーが直感的で好きなんですが、いかがでしょうか。
Win/Chromeで動作確認しています。
技術要素的にはIE以外では動きそう。
React初心者です。

color-picker.png

adialColorPicker.tsx
import React, { useEffect, useRef, useState } from 'react';
import './RadialColorPicker.scss';

function rgbToHsv(rgb) {
  let { r, g, b } = rgb;

  r /= 255;
  g /= 255;
  b /= 255;

  const max = Math.max(r, g, b),
    min = Math.min(r, g, b);
  let h,
    s,
    v = max;

  const d = max - min;
  s = max == 0 ? 0 : d / max;

  if (max == min) {
    h = 0; // achromatic
  } else {
    switch (max) {
      case r:
        h = (g - b) / d + (g < b ? 6 : 0);
        break;
      case g:
        h = (b - r) / d + 2;
        break;
      case b:
        h = (r - g) / d + 4;
        break;
    }

    h /= 6;
  }

  return { h, s, v };
}

function hsvToRgb(hsv) {
  let { h, s, v } = hsv;
  let r, g, b;

  const i = Math.floor(h * 6);
  const f = h * 6 - i;
  const p = v * (1 - s);
  const q = v * (1 - f * s);
  const t = v * (1 - (1 - f) * s);

  switch (i % 6) {
    case 0:
      (r = v), (g = t), (b = p);
      break;
    case 1:
      (r = q), (g = v), (b = p);
      break;
    case 2:
      (r = p), (g = v), (b = t);
      break;
    case 3:
      (r = p), (g = q), (b = v);
      break;
    case 4:
      (r = t), (g = p), (b = v);
      break;
    case 5:
      (r = v), (g = p), (b = q);
      break;
  }

  r *= 255;
  g *= 255;
  b *= 255;

  return { r, g, b };
}

function sanitize(val) {
  return Math.min(1, Math.max(0, val));
}

function sanitizeHue(val) {
  return val - Math.floor(val);
}

const colorPattern = /^#[0-9a-fA-F]{6}$/;

const RadialColorPicker: React.FC<{ value: string; onChange: (hash: string) => void }> = ({
  value,
  onChange
}) => {
  const [hsv, setHsv] = useState({ h: 0, s: 0, v: 100 });
  const [hash, setHash] = useState(`#ffffff`);

  useEffect(() => {
    if (value !== hash && colorPattern.test(value)) {
      const hex = value?.replace('#', '');
      const hsv = rgbToHsv({
        r: parseInt(hex.substring(0, 2), 16),
        g: parseInt(hex.substring(2, 4), 16),
        b: parseInt(hex.substring(4, 6), 16)
      });
      setHsv(hsv);
    }
  }, [value]);

  const [hueMouseDown, setHueMouseDown] = useState(false);
  const [satValMouseDown, setSatValMouseDown] = useState(false);

  const circleRef = useRef<HTMLDivElement>();
  const rectRef = useRef<HTMLDivElement>();

  useEffect(() => {
    const handleDocumentMouseUp = () => {
      setHueMouseDown(false);
      setSatValMouseDown(false);
    };

    const handleDocumentMouseMove = (event) => {
      if (event.changedTouches) {
        if (hueMouseDown) onClickHue(event.changedTouches[0]);
        else if (satValMouseDown) onClickValSat(event.changedTouches[0]);
      } else {
        if (hueMouseDown) onClickHue(event);
        else if (satValMouseDown) onClickValSat(event);
      }
    };

    document.addEventListener('mouseup', handleDocumentMouseUp);
    document.addEventListener('touchend', handleDocumentMouseUp);
    document.addEventListener('touchcancel', handleDocumentMouseUp);
    document.addEventListener('mousemove', handleDocumentMouseMove);
    document.addEventListener('touchmove', handleDocumentMouseMove);
    return () => {
      document.removeEventListener('mouseup', handleDocumentMouseUp);
      document.removeEventListener('touchend', handleDocumentMouseUp);
      document.removeEventListener('touchcancel', handleDocumentMouseUp);
      document.removeEventListener('mousemove', handleDocumentMouseMove);
      document.removeEventListener('touchmove', handleDocumentMouseMove);
    };
  }, [hueMouseDown, satValMouseDown]);

  useEffect(() => {
    const { r, g, b } = hsvToRgb(hsv);
    setHash(
      '#' +
        Math.round(r).toString(16).padStart(2, '0') +
        Math.round(g).toString(16).padStart(2, '0') +
        Math.round(b).toString(16).padStart(2, '0')
    );
  }, [hsv]);

  useEffect(() => {
    onChange(hash);
  }, [hash]);

  const onClickHue = (ev: { clientX: number; clientY: number }) => {
    const rect = circleRef.current.getBoundingClientRect();
    setHsv({
      h: sanitizeHue(
        Math.atan2(
          -(ev.clientX - rect.left - rect.width / 2),
          ev.clientY - rect.top - rect.height / 2
        ) /
          (Math.PI * 2) +
          0.625
      ),
      s: hsv.s,
      v: hsv.v
    });
  };

  const onClickValSat = (ev: { clientX: number; clientY: number }) => {
    const rect = rectRef.current.getBoundingClientRect();
    setHsv({
      h: hsv.h,
      s: sanitize((ev.clientX - rect.left) / rect.width),
      v: sanitize(1 - (ev.clientY - rect.top) / rect.height)
    });
  };

  const cursorSize = 16;
  const size = 200;

  return (
    <div
      className="wrapper"
      style={
        {
          '--size': size + 'px',
          '--cursor-size': cursorSize + 'px'
        } as React.CSSProperties
      }>
      <div
        className="hue-circle"
        onClick={onClickHue}
        onMouseDown={() => setHueMouseDown(true)}
        onTouchStart={() => setHueMouseDown(true)}
        ref={circleRef}>
        <div
          className="hue-cursor"
          style={{
            top: ((size - cursorSize) / 2) * (1 - Math.sin((hsv.h + 0.125) * Math.PI * 2)) + 'px',
            left: ((size - cursorSize) / 2) * (1 - Math.cos((hsv.h + 0.125) * Math.PI * 2)) + 'px'
          }}
        />
      </div>
      <div
        className="val-sat-rect"
        onClick={onClickValSat}
        onMouseDown={() => setSatValMouseDown(true)}
        onTouchStart={() => setSatValMouseDown(true)}
        ref={rectRef}
        style={
          {
            '--hue': Math.round(hsv.h * 360)
          } as React.CSSProperties
        }>
        <div
          className="val-sat-cursor"
          style={{
            top: (size / Math.sqrt(2) - cursorSize * 2) * (1 - hsv.v) - cursorSize / 2 + 'px',
            left: (size / Math.sqrt(2) - cursorSize * 2) * hsv.s - cursorSize / 2 + 'px'
          }}
        />
      </div>
    </div>
  );
};

export default RadialColorPicker;
RadialColorPicker.scss
.wrapper {
  $sqrt2: 1.414;

  position: relative;
  width: var(--size);
  background: #eee;
  touch-action: none;

  .hue-cursor,
  .val-sat-cursor {
    position: absolute;
    border: 3px solid white;
    width: var(--cursor-size);
    height: var(--cursor-size);
    border-radius: 100%;
    z-index: 2;
  }

  .hue-circle {
    position: relative;
    width: 100%;
    border-radius: 100%;
    mask-image: radial-gradient(
      transparent calc(100% / $sqrt2 - var(--cursor-size)),
      white calc(100% / $sqrt2 - var(--cursor-size))
    );

    &::before {
      content: '';
      display: block;
      width: 100%;
      padding-top: 100%;
      overflow: hidden;
      background: conic-gradient(
        hsl(45, 100%, 50%),
        hsl(90, 100%, 50%),
        hsl(135, 100%, 50%),
        hsl(180, 100%, 50%),
        hsl(225, 100%, 50%),
        hsl(270, 100%, 50%),
        hsl(315, 100%, 50%),
        hsl(360, 100%, 50%),
        hsl(45, 100%, 50%)
      );
    }
  }

  .val-sat-rect {
    position: absolute;
    width: calc(100% / $sqrt2 - var(--cursor-size) * 2);
    height: calc(100% / $sqrt2 - var(--cursor-size) * 2);
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    background-blend-mode: multiply;
    background: linear-gradient(to right, hsl(var(--hue), 0%, 100%), hsl(var(--hue), 100%, 50%)),
      linear-gradient(to bottom, hsl(var(--hue), 0%, 100%), hsl(var(--hue), 0%, 0%)), white;
    z-index: 1;
  }
}
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0