react-colorful 뜯어보기
React 색상 선택 라이브러리 react-colorful은 어떻게 구현되어 있을까?
Table Of Contents
- react-colorful이란?
- HexColorPicker의 작동 원리 살펴보기
- 텍스트로도 색상 선택이 가능함
- 마무리
react-colorful이란?
프로젝트를 진행하다 색상 선택기 라이브러리를 이용한 적이 있다.
- react-color라는 라이브러리가 가장 유명한 것 같으나, 이 라이브러리는 무겁다🥲 링크 들어가보면 진짜로 무겁게 생겼다. 굉장히 많은 모드를 지원해서 다양하게 쓸 수 있지만 나는 많은 기능이 필요하지 않으니까 더 가벼운 라이브러리를 찾아봤다.
- react-colorful이라는 라이브러리는 가벼운 색상 선택기라는 컨셉을 밀고 나가는 듯 하다.
react-color
보다 무려 13배나 빠르다고 한다. UI도 가볍고 사용하기 편리해서 프로젝트에서 쓰기 좋다고 판단했다.- 2023년 8월 16일 기준 GitHub star 2751개를 받은 라이브러리이지만
- 마지막 업데이트가 2022년 8월 18일이다.
- 풀 리퀘스트가 6개, 이슈가 23개 있지만 관리되고 있지 않는 듯 하다…
- 라이센스는 MIT로, 자유롭게 이용하기 좋다!
프로젝트 진행 도중 이 색상 선택기는 어떻게 작동하는지 궁금해져서 코드를 한 번 봐야겠다는 생각이 들었다. 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
내부에 toHsva
와 fromHsva
라는 함수가 있는 것으로 보아, 색상은 기본적으로 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
를 열어보니 이렇게 되어 있었다. fromHsva
가 roundHsva
인데, 왜 그렇게 했는지 궁금하지만 우선은 가던 길을 가겠다.
위에서는 <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 컴포넌트는 채도를 선택할 수 있는 박스를 그린다.
Hue 컴포넌트
Hue 컴포넌트는 색상을 선택할 수 있는 박스를 그린다.
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-colorfu
l을 보면 검은색이 아래에 있기 때문에 검은색 쪽으로 이동할수록 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 ); };
- 우선 0
9까지의 숫자와 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
이 어떻게 동작하는지를 알아보았다.
지금은 관리가 잘 되고 있지 않지만, 디자인이 심플하고 용량이 매우 작기 때문에 아직까지는 프로젝트에 계속 쓸 의향이 있다.