UNPKG

react-color-palette

Version:

🎨 Lightweight Color Picker component for React.

484 lines (465 loc) • 17.5 kB
"use client" // src/components/color-picker/color-picker.component.tsx import React6, { memo as memo6 } from "react"; // src/utils/is-field-hide/is-field-hide.util.ts function isFieldHide(hideInput, field) { return Array.isArray(hideInput) ? hideInput.includes(field) : hideInput; } // src/components/alpha/alpha.component.tsx import React2, { memo as memo2, useCallback as useCallback3, useMemo } from "react"; // src/hooks/use-bounding-client-rect/use-bounding-client-rect.hook.ts import { useCallback, useLayoutEffect, useRef, useState } from "react"; var getElementDimensions = (element) => { const rect = element.getBoundingClientRect(); return { width: rect.width, height: rect.height }; }; function useBoundingClientRect() { const ref = useRef(null); const [size, setSize] = useState({ width: 1, height: 1 }); useLayoutEffect(() => { const onWindowResize = () => { if (!ref.current) return; setSize(getElementDimensions(ref.current)); }; const onElementResize = ([{ contentBoxSize }]) => { setSize({ height: contentBoxSize[0].blockSize, width: contentBoxSize[0].inlineSize }); }; window.addEventListener("resize", onWindowResize, false); const observer = new ResizeObserver(onElementResize); if (ref.current) observer.observe(ref.current); return () => { window.removeEventListener("resize", onWindowResize, false); observer.disconnect(); }; }, []); const getPosition = useCallback(() => { const { left = 1, right = 1, top = 1, bottom = 1 } = ref.current?.getBoundingClientRect() ?? {}; return { left, right, top, bottom }; }, []); return [ref, size, getPosition]; } // src/utils/clamp/clamp.util.ts function clamp(value, min, max) { return value < min ? min : value > max ? max : value; } // src/services/color/color.service.ts var ColorServiceStatic = class { convert(model, color) { let hex = this.toHex("#000000"); let rgb = this.hex2rgb(hex); let hsv = this.rgb2hsv(rgb); if (model === "hex") { const value = color; hex = this.toHex(value); rgb = this.hex2rgb(hex); if (hex.startsWith("rgba")) { rgb = this.toRgb(hex); hex = this.rgb2hex(rgb); } hsv = this.rgb2hsv(rgb); } else if (model === "rgb") { const value = color; rgb = value; hex = this.rgb2hex(rgb); hsv = this.rgb2hsv(rgb); } else if (model === "hsv") { const value = color; hsv = value; rgb = this.hsv2rgb(hsv); hex = this.rgb2hex(rgb); } return { hex, rgb, hsv }; } toHex(value) { if (!value.startsWith("#")) { const ctx = document.createElement("canvas").getContext("2d"); if (!ctx) throw new Error("2d context not supported or canvas already initialized"); ctx.fillStyle = value; return ctx.fillStyle; } else if (value.length === 4 || value.length === 5) { value = value.split("").map((v, i) => i ? i < 4 ? v + v : v === "f" ? void 0 : v + v : "#").join(""); return value; } else if (value.length === 7) { return value; } else if (value.length === 9) { return value.endsWith("ff") ? value.slice(0, 7) : value; } return "#000000"; } toRgb(value) { const rgb = value.match(/\d+(\.\d+)?/gu) ?? []; const [r, g, b, a] = Array.from({ length: 4 }).map( (_, i) => clamp(+(rgb[i] ?? (i < 3 ? 0 : 1)), 0, i < 3 ? 255 : 1) ); return { r, g, b, a }; } toHsv(value) { const hsv = value.match(/\d+(\.\d+)?/gu) ?? []; const [h, s, v, a] = Array.from({ length: 4 }).map( (_, i) => clamp(+(hsv[i] ?? (i < 3 ? 0 : 1)), 0, i ? i < 3 ? 100 : 1 : 360) ); return { h, s, v, a }; } hex2rgb(hex) { hex = hex.slice(1); let [r, g, b, a] = Array.from({ length: 4 }).map((_, i) => parseInt(hex.slice(i * 2, i * 2 + 2), 16)); a = Number.isNaN(a) ? 1 : a / 255; return { r, g, b, a }; } rgb2hsv({ r, g, b, a }) { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b); const d = max - Math.min(r, g, b); const h = d ? (max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? 2 + (b - r) / d : 4 + (r - g) / d) * 60 : 0; const s = max ? d / max * 100 : 0; const v = max * 100; return { h, s, v, a }; } hsv2rgb({ h, s, v, a }) { s /= 100; v /= 100; const i = ~~(h / 60); const f = h / 60 - i; const p = v * (1 - s); const q = v * (1 - s * f); const t = v * (1 - s * (1 - f)); const index = i % 6; const r = [v, q, p, p, t, v][index] * 255; const g = [t, v, v, q, p, p][index] * 255; const b = [p, p, t, v, v, q][index] * 255; return { r, g, b, a }; } rgb2hex({ r, g, b, a }) { const [rr, gg, bb, aa] = [r, g, b, a].map( (v, i) => Math.round(i < 3 ? v : v * 255).toString(16).padStart(2, "0") ); return ["#", rr, gg, bb, aa === "ff" ? "" : aa].join(""); } }; var ColorService = new ColorServiceStatic(); // src/components/interactive/interactive.component.tsx import React, { memo, useCallback as useCallback2 } from "react"; // src/utils/is-touch/is-touch.util.ts function isTouch(event) { return "touches" in event; } // src/components/interactive/interactive.component.tsx var Interactive = memo(({ onCoordinateChange, children, disabled }) => { const [interactiveRef, { width, height }, getPosition] = useBoundingClientRect(); const move = useCallback2( (event, final = false) => { const { left, top } = getPosition(); const x = clamp(event.clientX - left, 0, width); const y = clamp(event.clientY - top, 0, height); onCoordinateChange(final, x, y); }, [width, height, getPosition, onCoordinateChange] ); const onStart = useCallback2( (event) => { if (!isTouch(event) && event.button !== 0) return; const onMove = (event2) => { move(isTouch(event2) ? event2.touches[0] : event2); }; const onEnd = (event2) => { move(isTouch(event2) ? event2.changedTouches[0] : event2, true); document.removeEventListener(isTouch(event2) ? "touchmove" : "mousemove", onMove, false); document.removeEventListener(isTouch(event2) ? "touchend" : "mouseup", onEnd, false); }; onMove(event); document.addEventListener(isTouch(event) ? "touchmove" : "mousemove", onMove, false); document.addEventListener(isTouch(event) ? "touchend" : "mouseup", onEnd, false); }, [move] ); return /* @__PURE__ */ React.createElement( "div", { ref: interactiveRef, className: "rcp-interactive", onMouseDown: onStart, onTouchStart: onStart, "aria-disabled": disabled }, children ); }); // src/components/alpha/alpha.component.tsx var Alpha = memo2(({ color, disabled, onChange, onChangeComplete }) => { const [alphaRef, { width }] = useBoundingClientRect(); const position = useMemo(() => { const x = color.hsv.a * width; return { x }; }, [color.hsv.a, width]); const updateColor = useCallback3( (final, x) => { const nextColor = ColorService.convert("hsv", { ...color.hsv, a: x / width }); onChange(nextColor); if (final) onChangeComplete?.(nextColor); }, [color.hsv, width, onChange, onChangeComplete] ); const rgb = useMemo(() => [color.rgb.r, color.rgb.g, color.rgb.b].join(" "), [color.rgb.r, color.rgb.g, color.rgb.b]); const rgba = useMemo(() => [rgb, color.rgb.a].join(" / "), [rgb, color.rgb.a]); return /* @__PURE__ */ React2.createElement(Interactive, { disabled, onCoordinateChange: updateColor }, /* @__PURE__ */ React2.createElement( "div", { ref: alphaRef, style: { background: `linear-gradient(to right, rgb(${rgb} / 0), rgb(${rgb} / 1)) top left / auto auto, conic-gradient(#666 0.25turn, #999 0.25turn 0.5turn, #666 0.5turn 0.75turn, #999 0.75turn) top left / 12px 12px repeat` }, className: "rcp-alpha" }, /* @__PURE__ */ React2.createElement( "div", { style: { left: position.x, background: `linear-gradient(to right, rgb(${rgba}), rgb(${rgba})) top left / auto auto, conic-gradient(#666 0.25turn, #999 0.25turn 0.5turn, #666 0.5turn 0.75turn, #999 0.75turn) ${-position.x - 4}px 2px / 12px 12px repeat` }, className: "rcp-alpha-cursor" } ) )); }); // src/components/fields/fields.component.tsx import React3, { memo as memo3, useCallback as useCallback4, useEffect, useState as useState2 } from "react"; // src/utils/float/float.util.ts function float(value, decimalPlaces) { return Math.round(value * 10 ** decimalPlaces) / 10 ** decimalPlaces; } // src/utils/format/format.util.ts function formatRgb({ r, g, b, a }) { const rgb = [Math.round(r), Math.round(g), Math.round(b)]; const alpha = float(a, 3); if (alpha < 1) rgb.push(alpha); return rgb.join(", "); } function formatHsv({ h, s, v, a }) { const hsv = [`${Math.round(h)}\xB0`, `${Math.round(s)}%`, `${Math.round(v)}%`]; const alpha = float(a, 3); if (alpha < 1) hsv.push(alpha); return hsv.join(", "); } // src/components/fields/fields.component.tsx var Fields = memo3(({ hideInput, color, disabled, onChange, onChangeComplete }) => { const [fields, setFields] = useState2({ hex: { value: color.hex, inputted: false }, rgb: { value: formatRgb(color.rgb), inputted: false }, hsv: { value: formatHsv(color.hsv), inputted: false } }); useEffect(() => { if (!fields.hex.inputted) { setFields((fields2) => ({ ...fields2, hex: { ...fields2.hex, value: color.hex } })); } }, [fields.hex.inputted, color.hex]); useEffect(() => { if (!fields.rgb.inputted) { setFields((fields2) => ({ ...fields2, rgb: { ...fields2.rgb, value: formatRgb(color.rgb) } })); } }, [fields.rgb.inputted, color.rgb]); useEffect(() => { if (!fields.hsv.inputted) { setFields((fields2) => ({ ...fields2, hsv: { ...fields2.hsv, value: formatHsv(color.hsv) } })); } }, [fields.hsv.inputted, color.hsv]); const onInputChange = useCallback4( (field) => (event) => { const { value } = event.target; setFields((fields2) => ({ ...fields2, [field]: { ...fields2[field], value } })); if (field === "hsv") onChange(ColorService.convert("hsv", ColorService.toHsv(value))); else if (field === "rgb") onChange(ColorService.convert("rgb", ColorService.toRgb(value))); else onChange(ColorService.convert("hex", value)); }, [onChange] ); const onInputFocus = useCallback4( (field) => () => { setFields((fields2) => ({ ...fields2, [field]: { ...fields2[field], inputted: true } })); }, [] ); const onInputBlur = useCallback4( (field) => (event) => { const { value } = event.target; setFields((fields2) => ({ ...fields2, [field]: { ...fields2[field], inputted: false } })); if (field === "hsv") onChangeComplete?.(ColorService.convert("hsv", ColorService.toHsv(value))); else if (field === "rgb") onChangeComplete?.(ColorService.convert("rgb", ColorService.toRgb(value))); else onChangeComplete?.(ColorService.convert("hex", value)); }, [onChangeComplete] ); return /* @__PURE__ */ React3.createElement("div", { className: "rcp-fields" }, !isFieldHide(hideInput, "hex") && /* @__PURE__ */ React3.createElement("div", { className: "rcp-fields-floor" }, /* @__PURE__ */ React3.createElement("div", { className: "rcp-field" }, /* @__PURE__ */ React3.createElement( "input", { id: "hex", className: "rcp-field-input", readOnly: disabled, value: fields.hex.value, onChange: onInputChange("hex"), onFocus: onInputFocus("hex"), onBlur: onInputBlur("hex") } ), /* @__PURE__ */ React3.createElement("label", { htmlFor: "hex", className: "rcp-field-label" }, "HEX"))), (!isFieldHide(hideInput, "rgb") || !isFieldHide(hideInput, "hsv")) && /* @__PURE__ */ React3.createElement("div", { className: "rcp-fields-floor" }, !isFieldHide(hideInput, "rgb") && /* @__PURE__ */ React3.createElement("div", { className: "rcp-field" }, /* @__PURE__ */ React3.createElement( "input", { id: "rgb", className: "rcp-field-input", readOnly: disabled, value: fields.rgb.value, onChange: onInputChange("rgb"), onFocus: onInputFocus("rgb"), onBlur: onInputBlur("rgb") } ), /* @__PURE__ */ React3.createElement("label", { htmlFor: "rgb", className: "rcp-field-label" }, "RGB")), !isFieldHide(hideInput, "hsv") && /* @__PURE__ */ React3.createElement("div", { className: "rcp-field" }, /* @__PURE__ */ React3.createElement( "input", { id: "hsv", className: "rcp-field-input", readOnly: disabled, value: fields.hsv.value, onChange: onInputChange("hsv"), onFocus: onInputFocus("hsv"), onBlur: onInputBlur("hsv") } ), /* @__PURE__ */ React3.createElement("label", { htmlFor: "hsv", className: "rcp-field-label" }, "HSV")))); }); // src/components/hue/hue.component.tsx import React4, { memo as memo4, useCallback as useCallback5, useMemo as useMemo2 } from "react"; var Hue = memo4(({ color, disabled, onChange, onChangeComplete }) => { const [hueRef, { width }] = useBoundingClientRect(); const position = useMemo2(() => { const x = color.hsv.h / 360 * width; return { x }; }, [color.hsv.h, width]); const updateColor = useCallback5( (final, x) => { const nextColor = ColorService.convert("hsv", { ...color.hsv, h: x / width * 360 }); onChange(nextColor); if (final) onChangeComplete?.(nextColor); }, [color.hsv, width, onChange, onChangeComplete] ); const hsl = useMemo2(() => [color.hsv.h, "100%", "50%"].join(" "), [color.hsv.h]); return /* @__PURE__ */ React4.createElement(Interactive, { disabled, onCoordinateChange: updateColor }, /* @__PURE__ */ React4.createElement("div", { ref: hueRef, className: "rcp-hue" }, /* @__PURE__ */ React4.createElement("div", { style: { left: position.x, backgroundColor: `hsl(${hsl})` }, className: "rcp-hue-cursor" }))); }); // src/components/saturation/saturation.component.tsx import React5, { memo as memo5, useCallback as useCallback6, useMemo as useMemo3 } from "react"; var Saturation = memo5(({ height, color, disabled, onChange, onChangeComplete }) => { const [saturationRef, { width }] = useBoundingClientRect(); const position = useMemo3(() => { const x = color.hsv.s / 100 * width; const y = (100 - color.hsv.v) / 100 * height; return { x, y }; }, [color.hsv.s, color.hsv.v, width, height]); const updateColor = useCallback6( (final, x, y) => { const nextColor = ColorService.convert("hsv", { ...color.hsv, s: x / width * 100, v: 100 - y / height * 100 }); onChange(nextColor); if (final) onChangeComplete?.(nextColor); }, [color.hsv, width, height, onChange, onChangeComplete] ); const hsl = useMemo3(() => [color.hsv.h, "100%", "50%"].join(" "), [color.hsv.h]); const rgb = useMemo3(() => [color.rgb.r, color.rgb.g, color.rgb.b].join(" "), [color.rgb.r, color.rgb.g, color.rgb.b]); return /* @__PURE__ */ React5.createElement(Interactive, { disabled, onCoordinateChange: updateColor }, /* @__PURE__ */ React5.createElement("div", { ref: saturationRef, style: { height, backgroundColor: `hsl(${hsl})` }, className: "rcp-saturation" }, /* @__PURE__ */ React5.createElement( "div", { style: { left: position.x, top: position.y, backgroundColor: `rgb(${rgb})` }, className: "rcp-saturation-cursor" } ))); }); // src/components/color-picker/color-picker.component.tsx var ColorPicker = memo6( ({ height = 200, hideAlpha = false, hideInput = false, color, disabled = false, onChange, onChangeComplete }) => /* @__PURE__ */ React6.createElement("div", { className: "rcp-root rcp" }, /* @__PURE__ */ React6.createElement( Saturation, { height, color, disabled, onChange, onChangeComplete } ), /* @__PURE__ */ React6.createElement("div", { className: "rcp-body" }, /* @__PURE__ */ React6.createElement("section", { className: "rcp-section" }, /* @__PURE__ */ React6.createElement(Hue, { color, disabled, onChange, onChangeComplete }), !hideAlpha && /* @__PURE__ */ React6.createElement(Alpha, { color, disabled, onChange, onChangeComplete })), (!isFieldHide(hideInput, "hex") || !isFieldHide(hideInput, "rgb") || !isFieldHide(hideInput, "hsv")) && /* @__PURE__ */ React6.createElement("section", { className: "rcp-section" }, /* @__PURE__ */ React6.createElement( Fields, { hideInput, color, disabled, onChange, onChangeComplete } )))) ); // src/hooks/use-color/use-color.hook.ts import { useEffect as useEffect2, useState as useState3 } from "react"; function useColor(initialColor) { const [color, setColor] = useState3(ColorService.convert("hex", initialColor)); useEffect2(() => { setColor(ColorService.convert("hex", initialColor)); }, [initialColor]); return [color, setColor]; } export { Alpha, ColorPicker, ColorService, Hue, Saturation, useColor };