react-colorful 뜯어보기

React 색상 선택 라이브러리 react-colorful은 어떻게 구현되어 있을까?


Table Of Contents


react-colorful이란?


프로젝트를 진행하다 색상 선택기 라이브러리를 이용한 적이 있다.

  • react-color라는 라이브러리가 가장 유명한 것 같으나, 이 라이브러리는 무겁다🥲 링크 들어가보면 진짜로 무겁게 생겼다. 굉장히 많은 모드를 지원해서 다양하게 쓸 수 있지만 나는 많은 기능이 필요하지 않으니까 더 가벼운 라이브러리를 찾아봤다.
  • react-colorful이라는 라이브러리는 가벼운 색상 선택기라는 컨셉을 밀고 나가는 듯 하다. react-color보다 무려 13배나 빠르다고 한다. UI도 가볍고 사용하기 편리해서 프로젝트에서 쓰기 좋다고 판단했다.
    • 2023년 8월 16일 기준 GitHub star 2751개를 받은 라이브러리이지만
    • 마지막 업데이트가 2022년 8월 18일이다.
    • 풀 리퀘스트가 6개, 이슈가 23개 있지만 관리되고 있지 않는 듯 하다…
    • 라이센스는 MIT로, 자유롭게 이용하기 좋다!

색상 선택기 이미지| 200 프로젝트 진행 도중 이 색상 선택기는 어떻게 작동하는지 궁금해져서 코드를 한 번 봐야겠다는 생각이 들었다. react-colorful은 내가 어떤 색상을 선택했는가를 어떻게 알아내고 있을까?

처음에는 256x256개의 픽셀을 전부 그려주고 있는 걸까라고 생각했는데 그런 짓을 하고 있을 것 같지는 않다.

궁금해서 개발자 도구로 열어보니 background-image: linear-gradient(0deg,#000,transparent),linear-gradient(90deg,#fff,hsla(0,0%,100%,0))처럼 그라데이션 속성을 넣어 준 것이었다.

그렇다면 xy 좌표로 내가 선택한 색상을 받아올 것 같은데 맞는지 확인해보자😎

HexColorPicker의 작동 원리 살펴보기


/src/index.ts

홈페이지에 안내되어 있는 대로 깃허브에 들어가보자. 당연히 /src 내부에 코드가 있겠지? 하고 /src로 들어가 보니 index.ts가 보인다.

// Color picker components export { HexColorPicker } from "./components/HexColorPicker"; export { HexAlphaColorPicker } from "./components/HexAlphaColorPicker"; export { HslaColorPicker } from "./components/HslaColorPicker"; ...

이렇게 색상 모드에 따라서 서로 다른 선택기를 내보내고 있다. 아래는 잘랐지만 타입 또한 export해주고 있다.

HexColorPicker가 가장 위에 있고, 프로젝트에서 사용해본 적이 있으므로 이 파일을 열어보겠다.

src/components/HexColorPicker

import React from "react"; import { ColorPicker } from "./common/ColorPicker"; import { ColorModel, ColorPickerBaseProps } from "../types"; import { equalHex } from "../utils/compare"; import { hexToHsva, hsvaToHex } from "../utils/convert"; const colorModel: ColorModel<string> = { defaultColor: "000", toHsva: hexToHsva, fromHsva: ({ h, s, v }) => hsvaToHex({ h, s, v, a: 1 }), equal: equalHex, }; export const HexColorPicker = (props: Partial<ColorPickerBaseProps<string>>): JSX.Element => ( <ColorPicker {...props} colorModel={colorModel} /> );

colorModel 내부에 toHsvafromHsva라는 함수가 있는 것으로 보아, 색상은 기본적으로 hsva로 관리되고 있는 듯 하다.

const colorModel: ColorModel<RgbColor> = { defaultColor: { r: 0, g: 0, b: 0 }, toHsva: ({ r, g, b }) => rgbaToHsva({ r, g, b, a: 1 }), fromHsva: (hsva) => rgbaToRgb(hsvaToRgba(hsva)), equal: equalColorObjects, };

RGB 모드 색상 선택기를 보니 여기에서도 hsva와 변환하는 함수가 있다.

const colorModel: ColorModel<HsvaColor> = { defaultColor: { h: 0, s: 0, v: 0, a: 1 }, toHsva: (hsva) => hsva, fromHsva: roundHsva, equal: equalColorObjects, };

src/components/HsvaColorPicker.tsx를 열어보니 이렇게 되어 있었다. fromHsvaroundHsva인데, 왜 그렇게 했는지 궁금하지만 우선은 가던 길을 가겠다.

위에서는 <ColorPicker {...props} colorModel={colorModel} />처럼 색상 선택기를 불러왔지만, <AlphaColorPicker {...props} colorModel={colorModel} />처럼 알파값을 추가로 선택할 수 있는 선택기가 따로 존재하는 것 같다.

우선은 ColorPicker 컴포넌트를 열어서 어떻게 구현되어 있는지 살펴보자.

src/components/common/ColorPicker.tsx

export const ColorPicker = <T extends AnyColor>({ className, colorModel, color = colorModel.defaultColor, onChange, ...rest }: Props<T>): JSX.Element => { const nodeRef = useRef<HTMLDivElement>(null); useStyleSheet(nodeRef); const [hsva, updateHsva] = useColorManipulation<T>(colorModel, color, onChange); const nodeClassName = formatClassName(["react-colorful", className]); return ( <div {...rest} ref={nodeRef} className={nodeClassName}> <Saturation hsva={hsva} onChange={updateHsva} /> <Hue hue={hsva.h} onChange={updateHsva} className="react-colorful__last-control" /> </div> ); };

<div {...rest} ref={nodeRef} className={nodeClassName}>

우선 nodeClassName이 어떤 스타일을 주는지부터 알아보고 싶다.

nodeClassName = formatClassName(["react-colorful", className]);이라고 선언되어 있는데, formatClassName이 어떤 함수인지 몰라서 해당 함수가 선언된 곳으로 가보자.

  • src/utils/format.ts

    export const formatClassName = (names: unknown[]): string => names.filter(Boolean).join(" ");

    파일을 열어 보니 그냥 className들을 빈칸으으로 연결시켜 주는 함수인 것 같다.

    실제로 어떤 스타일을 적용하고 있는지를 알아보려면 useStyleSheet(nodeRef);에서 useStyleSheet의 선언을 살펴보아야 할 것 같다.

  • src/hooks/useStyleSheet.ts

    import styles from "../css/styles.css";

    처럼 스타일을 불러 와서 html에 css를 넣어 주는 역할을 하는 코드인 것 같다.

  • src/css/styles.css

    .react-colorful { position: relative; display: flex; flex-direction: column; width: 200px; height: 200px; user-select: none; cursor: default; }

    선택기의 기본적인 스타일이 어떻게 선언되어 있는지 확인할 수 있었다.

Saturation 컴포넌트

Saturation 컴포넌트 영역 | 200

Saturation 컴포넌트는 채도를 선택할 수 있는 박스를 그린다.

Hue 컴포넌트

Hue 컴포넌트는 색상을 선택할 수 있는 박스를 그린다.

Hue 컴포넌트 영역 | 200

Alpha 컴포넌트

ColorPicker가 아닌 AlphaColorPicker를 호출한 경우, 마지막에 <Alpha hsva={hsva} onChange={updateHsva} />가 추가된다. Alpha 컴포넌트는 투명도를 선택할 수 있는 박스를 그린다.

className="react-colorful__last-control" 속성은 Hue 또는 Alpha 컴포넌트에 붙어서 아래 모서리를 둥글게 한다.

위 세 컴포넌트들은

<div className="{nodeClassName}">   <Interactive     onMove="{handleMove}"     onKey="{handleKey}"     aria-label="Hue"     aria-valuenow="{round(hue)}"     aria-valuemax="360"     aria-valuemin="0"   >     <Pointer className="react-colorful__hue-pointer" left={hue / 360}     color={hsvaToHslString({ h: hue, s: 100, v: 100, a: 1 })} />   </Interactive> </div>

에서처럼 Interactive 컴포넌트와 그 안의 Pointer 컴포넌트로 이루어져 있다. Interactive가 포인터가 움직일 수 있는 사각형일 것이고, Pointer가 선택된 색상의 위치를 보여주는 동그란 컴포넌트일 것이다.

src/components/common/Interactive.tsx

실제로 Interactive 컴포넌트를 뜯어보자.

export interface Interaction { left: number; top: number; }

Interaction 타입 선언이 이렇게 되어 있는 걸로 보아서 왼쪽 위에서 얼마나 떨어져 있는지를 기준으로 포인터를 움직이는 것 같다.

const getRelativePosition = ( node: HTMLDivElement, event: MouseEvent | TouchEvent, touchId: null | number ): Interaction => { const rect = node.getBoundingClientRect(); // Get user's pointer position from `touches` array if it's a `TouchEvent` const pointer = isTouch(event) ? getTouchPoint(event.touches, touchId) : (event as MouseEvent); return { left: clamp((pointer.pageX - (rect.left + getParentWindow(node).pageXOffset)) / rect.width), top: clamp((pointer.pageY - (rect.top + getParentWindow(node).pageYOffset)) / rect.height), }; };

화면상의 위치를 고려해서 left, top값을 받아오는 함수이다.

💥 좌표값에 최대/최소 지정하기

clamp 함수가 어떻게 작동하는지 궁금해서 src/utils/clamp.ts 파일을 열어봤더니

// Clamps a value between an upper and lower bound. // We use ternary operators because it makes the minified code // 2 times shorter then `Math.min(Math.max(a,b),c)` export const clamp = (number: number, min = 0, max = 1): number => { return number > max ? max : number < min ? min : number; };

정확히 이렇게 선언되어 있었다. 음... 삼항연산자를 2개 중첩하니 읽기가 매우 어렵고, 최근에는 삼항연산자를 되도록 자제하라는 조언을 많이 들어서 나였으면 차라리

export const clamp = (number: number, min = 0, max = 1): number => { if (number > max) return max; if (number < min) return min; return number; };

라고 작성했을 것 같다.


✨ 키보드로 이동 기능

const handleKeyDown = (event: React.KeyboardEvent) => { const keyCode = event.which || event.keyCode; // Ignore all keys except arrow ones if (keyCode < 37 || keyCode > 40) return; // Do not scroll page by arrow keys when document is focused on the element event.preventDefault(); // Send relative offset to the parent component. // We use codes (37←, 38↑, 39→, 40↓) instead of keys ('ArrowRight', 'ArrowDown', etc) // to reduce the size of the library onKeyCallback({ left: keyCode === 39 ? 0.05 : keyCode === 37 ? -0.05 : 0, top: keyCode === 40 ? 0.05 : keyCode === 38 ? -0.05 : 0, }); };

아래로 더 내려보니 흥미로운 부분도 있었다. 화살표 키를 이용해서 포인터를 움직일 수도 있는 것 같은데, 메인 페이지에는 안 쓰여 있어서 전혀 몰랐다. 하지만 코드를 보면 알겠지만, 완전히 세밀한 조절은 불가능하고 0.05씩 움직이는 것만 가능하다. 실제로 사용하면 끊기는 느낌이 난다. 여기에서 실험해보자


📝 div role="slider"

<div {...rest} onTouchStart={handleMoveStart} onMouseDown={handleMoveStart} className="react-colorful__interactive" ref={container} onKeyDown={handleKeyDown} tabIndex={0} role="slider" />

나는 div 태그에 role을 지정할 수 있다는 걸 몰랐다😮 mdn docs를 찾아 보니, HTML에 기본적으로 존재하지 않거나 존재하지만 아직 브라우저에서 완전히 지원하지 않는 요소들을 role로 지정해서 의미를 전달할 수 있는 기능인 것 같다. role="role type"처럼 HTML에 넣어줄 수 있고, toolbar, math, scrollbar, searchbox 등등 많은 role이 있는 것 같다. 더 많은 role은 여기에서 확인할 수 있다.

여기에서는 slider라는 role을 사용했으니, slider에 대해서 조금만 더 알아보자.

The slider role defines an input where the user selects a value from within a given range. slider는 사용자가 주어진 범위 내의 값을 선택하는 입력이다.

mdn docx에 따르면 이렇게 말하고 있다. 그러니까 slider는 주어진 최소값과 최대값 사이의 범위를 입력하는 위젯이다. react-colorful에서는 주어진 숫자 범위 사이에서(색상이니까 아마 0~255) 값을 고르게 되므로 role을 잘 사용했다고 볼 수 있댜.

문서가 아래로도 굉장히 긴데, 나중에 여유가 생긴다면 번역해 보고 싶다.


left, top에서 Saturation, Hue, Alpha로 변환

그래서 left, top에서 어떻게 색상값을 추출하지? 어디서 놓쳤는지 다시 길을 돌아봤다.

const handleMove = (interaction: Interaction) => { onChange({ h: 360 * interaction.left }); }; const handleKey = (offset: Interaction) => { // Hue measured in degrees of the color circle ranging from 0 to 360 onChange({ h: clamp(hue + offset.left * 360, 0, 360), }); };

Saturation 컴포넌트는 x축과 y축 두 방향으로 움직일 테니까, Hue 컴포넌트를 먼저 열어봤다. Hue 컴포넌트는 top값은 이용하지 않고, left값만을 이용해서 좌우로 움직인다. interaction이 인자로 주어지면, 이에 따라서 0 ~360에 맞게 매핑하고 있다.

const handleMove = (interaction: Interaction) => { onChange({ s: interaction.left * 100, v: 100 - interaction.top * 100, }); }; const handleKey = (offset: Interaction) => { // Saturation and brightness always fit into [0, 100] range onChange({ s: clamp(hsva.s + offset.left * 100, 0, 100), v: clamp(hsva.v - offset.top * 100, 0, 100), }); };

그렇다면 Saturation 컴포넌트도 살펴보자. s, v값을 Saturation 컴포넌트에서 결정하고 있었다. HSV 컬러 모델에서 Value는 검은색이 0, 흰색, 빨간색 등을 100%로 두는데, react-colorful을 보면 검은색이 아래에 있기 때문에 검은색 쪽으로 이동할수록 top값은 점점 커지게 된다. 따라서 100 - interaction.top을 해 주고 있는 것이다.

HSV에서 다시 HSVA로

까먹을 뻔 했는데, 결국 모든 색상은 HSVA로 저장하고 있으므로 색상이 변할 때마다 HSVA로 바꾸는 과정을 거치고 있다. 해당 함수들은 src/utils/convert.ts와 각 색상 선택기 컴포넌트에 정의되어 있으니 더 자세히 보기를 원한다면 해당 파일을 참고하자.

텍스트로도 색상 선택이 가능함


슬라이더 형식의 색상 선택기가 필요해서 react-colorful을 선택했지만, react-colorful은 text input에서도 색상을 입력할 수 있도록 처리를 해 놓았다. 색상 코드를 파싱하는 함수들이 있다는 뜻이다..! 여기까지 온 김에 한번 찾아보자.

ColorInput이라는 컴포넌트가 존재하는데, 이 컴포넌트는 src/components/HexColorInput.tsx에서만 한 번 호출된다. 아마 RGB 컬러 모델 등에서는 text input을 이용할 수 없는 것 같다.

사실 프로젝트를 할 때는 text input이 가능한 줄 몰라서 hex code가 valid한 지 아닌지를 확인하는 함수를 내가 짰었다. 코드 참고

여기에서는 어떻게 작성했는지 보러갔다.

const matcher = /^#?([0-9A-F]{3,8})$/i; export const validHex = (value: string, alpha?: boolean): boolean => { const match = matcher.exec(value); const length = match ? match[1].length : 0; return ( length === 3 || // '#rgb' format length === 6 || // '#rrggbb' format (!!alpha && length === 4) || // '#rgba' format (!!alpha && length === 8) // '#rrggbbaa' format ); };
  • 우선 09까지의 숫자와 AF까지의 문자로 이루어진 3자리에서 8자리 문자열인지를 체크한다.
  • 그리고 length가 3이나 6이면 valid한 형식이다. true를 반환한다.
  • alpha값을 사용하면서, length가 4나 8이면 valid한 형식이다. true를 반환한다.
  • 이외에는 false를 반환하게 된다.

ℹ️ Double NOT(!!) 연산

! 연산자는 not 연산을 수행하는데, 왜 ! 연산을 두 번 수행해서 원래 값으로 돌리는지 궁금했다. 조금 생각해 보니 null이나 undefined같은 값을 처리하기 위함이라는 것은 알겠는데, 더 확실한 대답이 듣고 싶었다.

역시 mdn docs에 Double NOT(!!)이라는 항목이 존재했다. 임의의 값을 확실하게 boolean 형식으로 변환할 수 있다는 것 같다. 몇 가지 예시도 있다.

!!true; // !!truthy returns true !!{}; // !!truthy returns true: any object is truthy... !!new Boolean(false); // ...even Boolean objects with a false .valueOf()! !!false; // !!falsy returns false !!""; // !!falsy returns false !!Boolean(false); // !!falsy returns false

마무리


이렇게 react-colorful이 어떻게 동작하는지를 알아보았다. 지금은 관리가 잘 되고 있지 않지만, 디자인이 심플하고 용량이 매우 작기 때문에 아직까지는 프로젝트에 계속 쓸 의향이 있다.