diff --git a/packages/color-picker/src/components/Alpha/index.tsx b/packages/color-picker/src/components/Alpha/index.tsx new file mode 100644 index 00000000..57052674 --- /dev/null +++ b/packages/color-picker/src/components/Alpha/index.tsx @@ -0,0 +1,75 @@ +import type { FC } from 'react'; +import React, { memo, useEffect, useMemo, useRef } from 'react'; + +import isEqual from 'lodash/isEqual'; +import { concatMap, map, takeUntil } from 'rxjs/operators'; + +import Checkboard from '../common/Checkboard'; +import { colorSelector, useStore } from '../../store'; + +import styles from './style.less'; +import { fromEvent, Subject } from 'rxjs'; + +const Alpha: FC = memo(() => { + const { rgb } = useStore(colorSelector, isEqual); + const updateAlpha = useStore((s) => s.updateAlpha); + + const ctnRef = useRef(null); + + const mouseDown$ = useMemo(() => new Subject(), []); + + const bindingStart = (e) => { + mouseDown$.next(e); + }; + + useEffect(() => { + mouseDown$ + .pipe( + concatMap(() => + fromEvent(window, 'mousemove').pipe(takeUntil(fromEvent(window, 'mouseup'))), + ), + map((e: any) => (typeof e.pageX === 'number' ? e.pageX : e.touches[0].pageX) as number), + ) + .subscribe((value) => { + const container = ctnRef.current; + const containerWidth = container.clientWidth; + + const left = value - (container.getBoundingClientRect().left + window.pageXOffset); + + updateAlpha(Math.round((left * 100) / containerWidth)); + }); + }, []); + + return ( +
+
+ +
+
+
+
+
+
+
+
+ ); +}); + +export default Alpha; diff --git a/packages/color-picker/src/components/Alpha/style.less b/packages/color-picker/src/components/Alpha/style.less new file mode 100644 index 00000000..0e11e520 --- /dev/null +++ b/packages/color-picker/src/components/Alpha/style.less @@ -0,0 +1,39 @@ +.abs { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.wrapper { + .abs; + border-radius: 2px; +} + +.checkboard { + .abs; + overflow: hidden; + border-radius: 2px; +} +.background { + .abs; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15), inset 0 0 4px rgba(0, 0, 0, 0.25); + border-radius: 2px; +} + +.container { + position: relative; + height: 100%; + margin: 0 3px; +} + +.slider { + width: 4px; + height: 8px; + margin-top: 1px; + background: #fff; + border-radius: 1px; + box-shadow: 0 0 2px rgba(0, 0, 0, 0.6); + transform: translateX(-2px); +} diff --git a/packages/color-picker/src/components/EditableInput/index.less b/packages/color-picker/src/components/EditableInput/index.less index c32cf79b..6f0cb9de 100644 --- a/packages/color-picker/src/components/EditableInput/index.less +++ b/packages/color-picker/src/components/EditableInput/index.less @@ -1,8 +1,16 @@ .avx-color-fields-input { width: 100%; padding: 2px 3px; + color: rgba(0, 0, 0, 0.85); font-size: 12px; + background: #f0f0f0; border: none; border-radius: 4px; - box-shadow: inset 0 0 0 1px #ccc; + box-shadow: inset 0 0 0 1px #ebebeb; + + transition: all 100ms ease-in-out; + &:hover, + &:focus-visible { + background: #fafafa; + } } diff --git a/packages/color-picker/src/components/EditableInput/index.tsx b/packages/color-picker/src/components/EditableInput/index.tsx index 83df17b1..eccb409d 100644 --- a/packages/color-picker/src/components/EditableInput/index.tsx +++ b/packages/color-picker/src/components/EditableInput/index.tsx @@ -1,5 +1,5 @@ import type { CSSProperties, FC } from 'react'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import cls from 'classnames'; import './index.less'; @@ -13,14 +13,23 @@ export interface EditableInputProps { } const EditableInput: FC = ({ value, style, className, onChange }) => { + const [internalValue, setValue] = useState(); + + useEffect(() => { + setValue(value); + }, [value]); + + const handleBlur = () => { + onChange(internalValue); + }; + return ( setValue(e.target.value)} + onBlur={handleBlur} spellCheck="false" style={style} /> diff --git a/packages/color-picker/src/components/Hue/index.tsx b/packages/color-picker/src/components/Hue/index.tsx new file mode 100644 index 00000000..cd7e79fb --- /dev/null +++ b/packages/color-picker/src/components/Hue/index.tsx @@ -0,0 +1,97 @@ +import type { FC } from 'react'; +import React, { memo, useEffect, useMemo, useRef } from 'react'; +import reactCSS from 'reactcss'; + +import { fromEvent, Subject } from 'rxjs'; +import { concatMap, map, takeUntil } from 'rxjs/operators'; + +import { useStore } from '../../store'; + +import './style.less'; + +const Hue: FC = memo(() => { + const hue = useStore((s) => s.hue); + + const updateHue = useStore((s) => s.updateHue); + + const ctnRef = useRef(null); + + const mouseDown$ = useMemo(() => new Subject(), []); + + const bindingStart = (e) => { + mouseDown$.next(e); + }; + + useEffect(() => { + mouseDown$ + .pipe( + concatMap(() => + fromEvent(window, 'mousemove').pipe(takeUntil(fromEvent(window, 'mouseup'))), + ), + map((e: any) => (typeof e.pageX === 'number' ? e.pageX : e.touches[0].pageX) as number), + ) + .subscribe((value) => { + const container = ctnRef.current; + const containerWidth = container.clientWidth; + + const left = value - (container.getBoundingClientRect().left + window.pageXOffset); + + const percent = (left * 100) / containerWidth; + + updateHue(Math.round((percent / 100) * 360)); + }); + }, []); + + const styles = reactCSS({ + default: { + hue: { + // @ts-ignore + absolute: '0px 0px 0px 0px', + borderRadius: 2, + boxShadow: 'inset 0 0 0 1px rgba(0,0,0,.15), inset 0 0 4px rgba(0,0,0,.25)', + }, + container: { + padding: '0 2px', + position: 'relative', + height: '100%', + borderRadius: 2, + }, + pointer: { + position: 'absolute', + left: `${(hue / 360) * 100}%`, + }, + slider: { + marginTop: '1px', + width: '4px', + borderRadius: '1px', + height: '8px', + boxShadow: '0 0 2px rgba(0, 0, 0, .6)', + background: '#fff', + transform: 'translateX(-2px)', + }, + }, + }); + + return ( +
+
+
+
+
+
+
+ ); +}); + +export default Hue; diff --git a/packages/color-picker/src/components/Hue/style.less b/packages/color-picker/src/components/Hue/style.less new file mode 100644 index 00000000..5113fed5 --- /dev/null +++ b/packages/color-picker/src/components/Hue/style.less @@ -0,0 +1,22 @@ +.hue-horizontal { + background: linear-gradient( + to right, + #f00 0%, + #ff0 17%, + #0f0 33%, + #0ff 50%, + #00f 67%, + #f0f 83%, + #f00 100% + ); + background: -webkit-linear-gradient( + to right, + #f00 0%, + #ff0 17%, + #0f0 33%, + #0ff 50%, + #00f 67%, + #f0f 83%, + #f00 100% + ); +} diff --git a/packages/color-picker/src/components/Saturation/index.tsx b/packages/color-picker/src/components/Saturation/index.tsx new file mode 100644 index 00000000..48b24e9c --- /dev/null +++ b/packages/color-picker/src/components/Saturation/index.tsx @@ -0,0 +1,132 @@ +import type { FC } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; +import reactCSS from 'reactcss'; + +import { fromEvent, Subject } from 'rxjs'; +import { concatMap, map, takeUntil, throttleTime } from 'rxjs/operators'; +import { colorSelector, useStore } from '@arvinxu/color-picker/store'; + +export const Saturation: FC = () => { + const hue = useStore((s) => s.hue); + + const updateBySV = useStore((s) => s.updateBySV); + + const { hsv } = useStore(colorSelector); + + const ctnRef = useRef(null); + + const getContainerRenderWindow = () => { + const container = ctnRef.current; + let renderWindow = window; + + while (!renderWindow.document.contains(container) && renderWindow.parent !== renderWindow) { + // @ts-ignore + renderWindow = renderWindow.parent; + } + return renderWindow; + }; + + const mouseDown$ = useMemo(() => new Subject(), []); + + const bindingStart = (e) => { + mouseDown$.next(e); + }; + + useEffect(() => { + mouseDown$ + .pipe( + concatMap(() => + fromEvent(getContainerRenderWindow(), 'mousemove').pipe( + takeUntil(fromEvent(getContainerRenderWindow(), 'mouseup')), + ), + ), + map((e: any) => ({ + x: typeof e.pageX === 'number' ? e.pageX : e.touches[0].pageX, + y: typeof e.pageY === 'number' ? e.pageY : e.touches[0].pageY, + })), + throttleTime(16), + ) + .subscribe((point) => { + const container = ctnRef.current; + const { width: containerWidth, height: containerHeight } = + container.getBoundingClientRect(); + + const left = point.x - (container.getBoundingClientRect().left + window.pageXOffset); + const top = point.y - (container.getBoundingClientRect().top + window.pageYOffset); + + const saturation = left / containerWidth; + const bright = 1 - top / containerHeight; + + updateBySV(saturation, bright); + }); + }, []); + + const styles = reactCSS({ + default: { + color: { + // @ts-ignore + absolute: '0px 0px 0px 0px', + background: `hsl(${hue},100%, 50%)`, + borderRadius: 4, + }, + white: { + // @ts-ignore + absolute: '0px 0px 0px 0px', + borderRadius: 4, + }, + black: { + // @ts-ignore + absolute: '0px 0px 0px 0px', + boxShadow: 'inset 0 0 0 1px rgba(0,0,0,.09), inset 0 0 4px rgba(0,0,0,.15)', + borderRadius: 4, + }, + pointer: { + position: 'absolute', + top: `${-(hsv.v * 100) + 100}%`, + left: `${hsv.s * 100}%`, + cursor: 'default', + }, + circle: { + width: '4px', + height: '4px', + boxShadow: `0 0 0 1.5px #fff, inset 0 0 1px 1px rgba(0,0,0,.3), + 0 0 1px 2px rgba(0,0,0,.4)`, + borderRadius: '50%', + cursor: 'hand', + transform: 'translate(-2px, -2px)', + }, + }, + }); + + return ( +
+ +
+
+
+
+
+
+
+ ); +}; + +export default Saturation; diff --git a/packages/color-picker/src/components/SketchFields/index.less b/packages/color-picker/src/components/SketchFields/index.less index da715975..2a702a69 100644 --- a/packages/color-picker/src/components/SketchFields/index.less +++ b/packages/color-picker/src/components/SketchFields/index.less @@ -60,14 +60,14 @@ } &-label { + &-alpha, &-hex { - width: 48px; - padding-left: 8px; + padding: 2px 0; } - &-alpha, &-hex { - padding: 2px 0; + width: 48px; + padding-left: 8px; } } } diff --git a/packages/color-picker/src/components/SketchFields/index.tsx b/packages/color-picker/src/components/SketchFields/index.tsx index 18f0efb6..9b3842f6 100644 --- a/packages/color-picker/src/components/SketchFields/index.tsx +++ b/packages/color-picker/src/components/SketchFields/index.tsx @@ -3,8 +3,10 @@ import React, { memo } from 'react'; import isEqual from 'lodash/isEqual'; import { Flexbox } from '@arvinxu/layout-kit'; import cls from 'classnames'; +import copy from 'copy-to-clipboard'; +import shallow from 'zustand/shallow'; -import type { ColorMode, ColorObj } from '../../store'; +import type { ColorMode, ColorObj, ColorPickerStore } from '../../store'; import { colorSpaceSelector, useStore } from '../../store'; import { isValidHex } from '../../helpers/color'; import DraggableLabel from '../DraggableLabel'; @@ -12,6 +14,13 @@ import EditableInput from '../EditableInput'; import './index.less'; +const selector = (s: ColorPickerStore) => ({ + updateAlpha: s.updateAlpha, + updateByHex: s.updateByHex, + updateColorMode: s.updateColorMode, + updateByColorSpace: s.updateByColorSpace, +}); + export const SketchFields: FC = memo(() => { const mode: ColorMode = useStore((s) => s.colorMode); @@ -20,8 +29,10 @@ export const SketchFields: FC = memo(() => { const modeValue = useStore(colorSpaceSelector, isEqual) as ColorObj; - const { updateAlpha, updateByHex, disableAlpha, updateColorMode, updateByColorSpace } = - useStore(); + const { updateAlpha, updateByHex, updateColorMode, updateByColorSpace } = useStore( + selector, + shallow, + ); const prefixCls = 'avx-color-fields'; @@ -30,20 +41,20 @@ export const SketchFields: FC = memo(() => {
{ if (isValidHex(str)) { updateByHex(str); } }} - style={{ width: 56 }} + style={{ width: 56, fontFamily: 'monospace' }} />
{mode.split('').map((dim) => { return (
{ updateByColorSpace(dim, value); }} @@ -58,8 +69,12 @@ export const SketchFields: FC = memo(() => {
{ + copy(hex); + }} > hex
@@ -76,6 +91,7 @@ export const SketchFields: FC = memo(() => { onChange={(e) => { updateColorMode(e.target.value as ColorMode); }} + value={mode} > @@ -97,7 +113,7 @@ export const SketchFields: FC = memo(() => { className={`${prefixCls}-label-alpha`} text={'Alpha'} value={alpha} - style={{ display: disableAlpha ? 'none' : undefined, marginLeft: 4 }} + style={{ marginLeft: 4 }} onChange={updateAlpha} />
diff --git a/packages/color-picker/src/components/SketchPresetColors.tsx b/packages/color-picker/src/components/SketchPresetColors.tsx index 5e608af5..98e70836 100644 --- a/packages/color-picker/src/components/SketchPresetColors.tsx +++ b/packages/color-picker/src/components/SketchPresetColors.tsx @@ -1,14 +1,18 @@ -import type { ChangeEvent } from 'react'; -import React from 'react'; +import type { FC } from 'react'; +import React, { memo } from 'react'; +import shallow from 'zustand/shallow'; -import type { ColorChangeHandler, ColorResult, PresetColor } from '../types'; import { Swatch } from './common'; +import type { ColorPickerStore } from '../store'; +import { useStore } from '../store'; -export const SketchPresetColors: React.FC<{ - colors?: PresetColor[] | undefined; - onClick?: ColorChangeHandler | undefined; - onSwatchHover?: (color: ColorResult, event: MouseEvent) => void; -}> = ({ colors, onClick = () => {}, onSwatchHover }) => { +const selector = (s: ColorPickerStore) => ({ + presetColors: s.presetColors, + updateByHex: s.updateByHex, + onSwatchHover: s.onSwatchHover, +}); + +export const SketchPresetColors: FC = memo(() => { const styles = { colors: { margin: '0 -10px', @@ -33,15 +37,7 @@ export const SketchPresetColors: React.FC<{ }, } as const; - const handleClick = (hex: string, e: ChangeEvent) => { - onClick?.( - { - hex, - source: 'hex', - } as any, - e, - ); - }; + const { presetColors: colors, updateByHex, onSwatchHover } = useStore(selector, shallow); return (
@@ -56,7 +52,9 @@ export const SketchPresetColors: React.FC<{ { + updateByHex(hex); + }} onHover={onSwatchHover} focusStyle={{ boxShadow: `inset 0 0 0 1px rgba(0,0,0,.15), 0 0 4px ${c.color}`, @@ -67,6 +65,6 @@ export const SketchPresetColors: React.FC<{ })}
); -}; +}); export default SketchPresetColors; diff --git a/packages/color-picker/src/components/common/Alpha.js b/packages/color-picker/src/components/common/Alpha.js deleted file mode 100644 index f89bc1e6..00000000 --- a/packages/color-picker/src/components/common/Alpha.js +++ /dev/null @@ -1,124 +0,0 @@ -import React, { Component, PureComponent } from 'react'; -import reactCSS from 'reactcss'; -import * as alpha from '../../helpers/alpha'; - -import Checkboard from './Checkboard'; - -export class Alpha extends (PureComponent || Component) { - componentWillUnmount() { - this.unbindEventListeners(); - } - - handleChange = (e) => { - const change = alpha.calculateChange( - e, - this.props.hsl, - this.props.direction, - this.props.a, - this.container, - ); - change && typeof this.props.onChange === 'function' && this.props.onChange(change, e); - }; - - handleMouseDown = (e) => { - this.handleChange(e); - window.addEventListener('mousemove', this.handleChange); - window.addEventListener('mouseup', this.handleMouseUp); - }; - - handleMouseUp = () => { - this.unbindEventListeners(); - }; - - unbindEventListeners = () => { - window.removeEventListener('mousemove', this.handleChange); - window.removeEventListener('mouseup', this.handleMouseUp); - }; - - render() { - const rgb = this.props.rgb; - const styles = reactCSS( - { - default: { - alpha: { - absolute: '0px 0px 0px 0px', - borderRadius: this.props.radius, - }, - checkboard: { - absolute: '0px 0px 0px 0px', - overflow: 'hidden', - borderRadius: this.props.radius, - }, - gradient: { - absolute: '0px 0px 0px 0px', - background: `linear-gradient(to right, rgba(${rgb.r},${rgb.g},${rgb.b}, 0) 0%, - rgba(${rgb.r},${rgb.g},${rgb.b}, 1) 100%)`, - boxShadow: this.props.shadow, - borderRadius: this.props.radius, - }, - container: { - position: 'relative', - height: '100%', - margin: '0 3px', - }, - pointer: { - position: 'absolute', - left: `${rgb.a * 100}%`, - }, - slider: { - width: '4px', - borderRadius: '1px', - height: '8px', - boxShadow: '0 0 2px rgba(0, 0, 0, .6)', - background: '#fff', - marginTop: '1px', - transform: 'translateX(-2px)', - }, - }, - vertical: { - gradient: { - background: `linear-gradient(to bottom, rgba(${rgb.r},${rgb.g},${rgb.b}, 0) 0%, - rgba(${rgb.r},${rgb.g},${rgb.b}, 1) 100%)`, - }, - pointer: { - left: 0, - top: `${rgb.a * 100}%`, - }, - }, - overwrite: { - ...this.props.style, - }, - }, - { - vertical: this.props.direction === 'vertical', - overwrite: true, - }, - ); - - return ( -
-
- -
-
-
(this.container = container)} - onMouseDown={this.handleMouseDown} - onTouchMove={this.handleChange} - onTouchStart={this.handleChange} - > -
- {this.props.pointer ? ( - - ) : ( -
- )} -
-
-
- ); - } -} - -export default Alpha; diff --git a/packages/color-picker/src/components/common/Hue.js b/packages/color-picker/src/components/common/Hue.js deleted file mode 100644 index 44680409..00000000 --- a/packages/color-picker/src/components/common/Hue.js +++ /dev/null @@ -1,109 +0,0 @@ -import React, { Component, PureComponent } from 'react'; -import reactCSS from 'reactcss'; -import * as hue from '../../helpers/hue'; - -export class Hue extends (PureComponent || Component) { - componentWillUnmount() { - this.unbindEventListeners(); - } - - handleChange = (e) => { - const change = hue.calculateChange(e, this.props.direction, this.props.hsl, this.container); - change && typeof this.props.onChange === 'function' && this.props.onChange(change, e); - }; - - handleMouseDown = (e) => { - this.handleChange(e); - window.addEventListener('mousemove', this.handleChange); - window.addEventListener('mouseup', this.handleMouseUp); - }; - - handleMouseUp = () => { - this.unbindEventListeners(); - }; - - unbindEventListeners() { - window.removeEventListener('mousemove', this.handleChange); - window.removeEventListener('mouseup', this.handleMouseUp); - } - - render() { - const { direction = 'horizontal' } = this.props; - - const styles = reactCSS( - { - default: { - hue: { - absolute: '0px 0px 0px 0px', - borderRadius: this.props.radius, - boxShadow: this.props.shadow, - }, - container: { - padding: '0 2px', - position: 'relative', - height: '100%', - borderRadius: this.props.radius, - }, - pointer: { - position: 'absolute', - left: `${(this.props.hsl.h * 100) / 360}%`, - }, - slider: { - marginTop: '1px', - width: '4px', - borderRadius: '1px', - height: '8px', - boxShadow: '0 0 2px rgba(0, 0, 0, .6)', - background: '#fff', - transform: 'translateX(-2px)', - }, - }, - vertical: { - pointer: { - left: '0px', - top: `${-((this.props.hsl.h * 100) / 360) + 100}%`, - }, - }, - }, - { vertical: direction === 'vertical' }, - ); - - return ( -
-
(this.container = container)} - onMouseDown={this.handleMouseDown} - onTouchMove={this.handleChange} - onTouchStart={this.handleChange} - > - -
- {this.props.pointer ? ( - - ) : ( -
- )} -
-
-
- ); - } -} - -export default Hue; diff --git a/packages/color-picker/src/components/common/Saturation.js b/packages/color-picker/src/components/common/Saturation.js deleted file mode 100644 index b87b8a31..00000000 --- a/packages/color-picker/src/components/common/Saturation.js +++ /dev/null @@ -1,134 +0,0 @@ -import React, { Component, PureComponent } from 'react'; -import reactCSS from 'reactcss'; -import throttle from 'lodash/throttle'; -import * as saturation from '../../helpers/saturation'; - -export class Saturation extends (PureComponent || Component) { - constructor(props) { - super(props); - - this.throttle = throttle((fn, data, e) => { - fn(data, e); - }, 50); - } - - componentWillUnmount() { - this.throttle.cancel(); - this.unbindEventListeners(); - } - - getContainerRenderWindow() { - const { container } = this; - let renderWindow = window; - while (!renderWindow.document.contains(container) && renderWindow.parent !== renderWindow) { - renderWindow = renderWindow.parent; - } - return renderWindow; - } - - handleChange = (e) => { - typeof this.props.onChange === 'function' && - this.throttle( - this.props.onChange, - saturation.calculateChange(e, this.props.hsl, this.container), - e, - ); - }; - - handleMouseDown = (e) => { - this.handleChange(e); - const renderWindow = this.getContainerRenderWindow(); - renderWindow.addEventListener('mousemove', this.handleChange); - renderWindow.addEventListener('mouseup', this.handleMouseUp); - }; - - handleMouseUp = () => { - this.unbindEventListeners(); - }; - - unbindEventListeners() { - const renderWindow = this.getContainerRenderWindow(); - renderWindow.removeEventListener('mousemove', this.handleChange); - renderWindow.removeEventListener('mouseup', this.handleMouseUp); - } - - render() { - const { color, white, black, pointer, circle } = this.props.style || {}; - const styles = reactCSS( - { - default: { - color: { - absolute: '0px 0px 0px 0px', - background: `hsl(${this.props.hsl.h},100%, 50%)`, - borderRadius: this.props.radius, - }, - white: { - absolute: '0px 0px 0px 0px', - borderRadius: this.props.radius, - }, - black: { - absolute: '0px 0px 0px 0px', - boxShadow: this.props.shadow, - borderRadius: this.props.radius, - }, - pointer: { - position: 'absolute', - top: `${-(this.props.hsv.v * 100) + 100}%`, - left: `${this.props.hsv.s * 100}%`, - cursor: 'default', - }, - circle: { - width: '4px', - height: '4px', - boxShadow: `0 0 0 1.5px #fff, inset 0 0 1px 1px rgba(0,0,0,.3), - 0 0 1px 2px rgba(0,0,0,.4)`, - borderRadius: '50%', - cursor: 'hand', - transform: 'translate(-2px, -2px)', - }, - }, - custom: { - color, - white, - black, - pointer, - circle, - }, - }, - { custom: !!this.props.style }, - ); - - return ( -
(this.container = container)} - onMouseDown={this.handleMouseDown} - onTouchMove={this.handleChange} - onTouchStart={this.handleChange} - > - -
-
-
- {this.props.pointer ? ( - - ) : ( -
- )} -
-
-
- ); - } -} - -export default Saturation; diff --git a/packages/color-picker/src/components/common/Swatch.js b/packages/color-picker/src/components/common/Swatch.js index 871889d1..2888f8e8 100644 --- a/packages/color-picker/src/components/common/Swatch.js +++ b/packages/color-picker/src/components/common/Swatch.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { memo } from 'react'; import reactCSS from 'reactcss'; import { handleFocus } from '../../helpers/interaction'; @@ -6,59 +6,61 @@ import Checkboard from './Checkboard'; const ENTER = 13; -export const Swatch = ({ - color, - style, - onClick = () => {}, - onHover, - title = color, - children, - focus, - focusStyle = {}, -}) => { - const transparent = color === 'transparent'; - const styles = reactCSS({ - default: { - swatch: { - background: color, - height: '100%', - width: '100%', - cursor: 'pointer', - position: 'relative', - outline: 'none', - ...style, - ...(focus ? focusStyle : {}), +export const Swatch = memo( + ({ + color, + style, + onClick = () => {}, + onHover, + title = color, + children, + focus, + focusStyle = {}, + }) => { + const transparent = color === 'transparent'; + const styles = reactCSS({ + default: { + swatch: { + background: color, + height: '100%', + width: '100%', + cursor: 'pointer', + position: 'relative', + outline: 'none', + ...style, + ...(focus ? focusStyle : {}), + }, }, - }, - }); + }); - const handleClick = (e) => onClick(color, e); - const handleKeyDown = (e) => e.keyCode === ENTER && onClick(color, e); - const handleHover = (e) => onHover(color, e); + const handleClick = (e) => onClick(color, e); + const handleKeyDown = (e) => e.keyCode === ENTER && onClick(color, e); + const handleHover = (e) => onHover(color, e); - const optionalEvents = {}; - if (onHover) { - optionalEvents.onMouseOver = handleHover; - } + const optionalEvents = {}; + if (onHover) { + optionalEvents.onMouseOver = handleHover; + } - return ( -
- {children} - {transparent && ( - - )} -
- ); -}; + return ( +
+ {children} + {transparent && ( + + )} +
+ ); + }, +); export default handleFocus(Swatch); diff --git a/packages/color-picker/src/components/common/index.ts b/packages/color-picker/src/components/common/index.ts index a4f14e9e..56541fde 100644 --- a/packages/color-picker/src/components/common/index.ts +++ b/packages/color-picker/src/components/common/index.ts @@ -1,8 +1,5 @@ // @ts-nocheck -export { default as Alpha } from './Alpha'; export { default as Checkboard } from './Checkboard'; -export { default as Hue } from './Hue'; -export { default as Saturation } from './Saturation'; export { default as EditableInput } from './EditableInput'; export { default as Swatch } from './Swatch'; diff --git a/packages/color-picker/src/container/Sketch.tsx b/packages/color-picker/src/container/Sketch.tsx index 26606a69..3f22e18f 100644 --- a/packages/color-picker/src/container/Sketch.tsx +++ b/packages/color-picker/src/container/Sketch.tsx @@ -1,148 +1,87 @@ import type { FC } from 'react'; import React, { memo } from 'react'; import reactCSS from 'reactcss'; -import merge from 'lodash/merge'; import isEqual from 'lodash/isEqual'; import cls from 'classnames'; -import { Saturation, Hue, Alpha, Checkboard } from '../components/common'; +import { Checkboard } from '../components/common'; import SketchFields from '../components/SketchFields'; +import Alpha from '../components/Alpha'; +import Hue from '../components/Hue'; +import Saturation from '../components/Saturation'; import SketchPresetColors from '../components/SketchPresetColors'; -import { colorSelector, useStore } from '../store'; +import { useStore } from '../store'; import type { ColorPickerProps } from '../types'; -export const Sketch: FC = memo(({ onSwatchHover, className }) => { - const { - presetColors, - styles: passedStyles, - width, - disableAlpha, - updateAlpha, - updateByHex, - updateHue, - updateByHsv, - } = useStore(); +export const Sketch: FC = memo(({ className }) => { + const rgba = useStore((s) => s.colorModel.rgba(), isEqual); - const { rgb, hsl, hsv } = useStore(colorSelector, isEqual); - - const styles: any = reactCSS( - //@ts-ignore - merge( - { - default: { - picker: { - width, - padding: '10px 10px 0', - boxSizing: 'initial', - background: '#fff', - borderRadius: '4px', - // boxShadow: '0 0 0 1px rgba(0,0,0,.15), 0 8px 16px rgba(0,0,0,.15)', - boxShadow: '0 0 0 1px rgba(0,0,0,.15)', - }, - saturation: { - width: '100%', - paddingBottom: '75%', - position: 'relative', - overflow: 'hidden', - }, - Saturation: { - radius: '3px', - shadow: 'inset 0 0 0 1px rgba(0,0,0,.15), inset 0 0 4px rgba(0,0,0,.25)', - }, - controls: { - display: 'flex', - }, - sliders: { - padding: '4px 0', - flex: '1', - }, - color: { - width: '24px', - height: '24px', - position: 'relative', - marginTop: '4px', - marginLeft: '4px', - borderRadius: '50%', - overflow: 'hidden', - }, - activeColor: { - absolute: '0px 0px 0px 0px', - background: `rgba(${rgb!.r},${rgb!.g},${rgb!.b},${rgb!.a})`, - boxShadow: 'inset 0 0 0 1px rgba(0,0,0,.15), inset 0 0 4px rgba(0,0,0,.25)', - }, - hue: { - position: 'relative', - height: '10px', - overflow: 'hidden', - }, - Hue: { - radius: '2px', - shadow: 'inset 0 0 0 1px rgba(0,0,0,.15), inset 0 0 4px rgba(0,0,0,.25)', - }, + const styles: any = reactCSS({ + default: { + picker: { + width: 200, + padding: '10px 10px 0', + boxSizing: 'initial', + background: '#fff', + borderRadius: '4px', + // boxShadow: '0 0 0 1px rgba(0,0,0,.15), 0 8px 16px rgba(0,0,0,.15)', + boxShadow: '0 0 0 1px rgba(0,0,0,.15)', + }, + saturation: { + width: '100%', + paddingBottom: '75%', + position: 'relative', + overflow: 'hidden', + }, + controls: { + display: 'flex', + }, + sliders: { + padding: '4px 0', + flex: '1', + }, + color: { + width: '24px', + height: '24px', + position: 'relative', + marginTop: '4px', + marginLeft: '4px', + borderRadius: '50%', + overflow: 'hidden', + }, + activeColor: { + absolute: '0px 0px 0px 0px', + background: `rgba(${rgba.join(',')})`, + boxShadow: 'inset 0 0 0 1px rgba(0,0,0,.15), inset 0 0 4px rgba(0,0,0,.25)', + }, + hue: { + position: 'relative', + height: '10px', + overflow: 'hidden', + }, - alpha: { - position: 'relative', - height: '10px', - marginTop: '4px', - overflow: 'hidden', - }, - Alpha: { - radius: '2px', - shadow: 'inset 0 0 0 1px rgba(0,0,0,.15), inset 0 0 4px rgba(0,0,0,.25)', - }, - ...passedStyles, - }, - disableAlpha: { - color: { - height: '10px', - }, - hue: { - height: '10px', - }, - alpha: { - display: 'none', - }, - }, + alpha: { + position: 'relative', + height: '10px', + marginTop: '4px', + overflow: 'hidden', }, - passedStyles, - ), - { disableAlpha }, - ); + }, + }); return (
- +
- { - updateHue(h); - }} - radius={4} - /> +
- { - updateAlpha(a * 100); - }} - radius={4} - /> +
@@ -152,13 +91,7 @@ export const Sketch: FC = memo(({ onSwatchHover, className })
- { - updateByHex(e.hex); - }} - onSwatchHover={onSwatchHover} - /> +
); }); diff --git a/packages/color-picker/src/helpers/alpha.js b/packages/color-picker/src/helpers/alpha.js deleted file mode 100644 index be79f3ca..00000000 --- a/packages/color-picker/src/helpers/alpha.js +++ /dev/null @@ -1,49 +0,0 @@ -export const calculateChange = (e, hsl, direction, initialA, container) => { - const containerWidth = container.clientWidth; - const containerHeight = container.clientHeight; - const x = typeof e.pageX === 'number' ? e.pageX : e.touches[0].pageX; - const y = typeof e.pageY === 'number' ? e.pageY : e.touches[0].pageY; - const left = x - (container.getBoundingClientRect().left + window.pageXOffset); - const top = y - (container.getBoundingClientRect().top + window.pageYOffset); - - if (direction === 'vertical') { - let a; - if (top < 0) { - a = 0; - } else if (top > containerHeight) { - a = 1; - } else { - a = Math.round((top * 100) / containerHeight) / 100; - } - - if (hsl.a !== a) { - return { - h: hsl.h, - s: hsl.s, - l: hsl.l, - a, - source: 'rgb', - }; - } - } else { - let a; - if (left < 0) { - a = 0; - } else if (left > containerWidth) { - a = 1; - } else { - a = Math.round((left * 100) / containerWidth) / 100; - } - - if (initialA !== a) { - return { - h: hsl.h, - s: hsl.s, - l: hsl.l, - a, - source: 'rgb', - }; - } - } - return null; -}; diff --git a/packages/color-picker/src/helpers/color.test.ts b/packages/color-picker/src/helpers/color.test.ts new file mode 100644 index 00000000..84f8afa6 --- /dev/null +++ b/packages/color-picker/src/helpers/color.test.ts @@ -0,0 +1,55 @@ +import { isValidHex } from './color'; + +describe('helpers/color', () => { + describe('isValidHex', () => { + test('allows strings of length 3 or 6', () => { + expect(isValidHex('f')).toBeFalsy(); + expect(isValidHex('ff')).toBeFalsy(); + expect(isValidHex('fff')).toBeTruthy(); + expect(isValidHex('ffff')).toBeFalsy(); + expect(isValidHex('fffff')).toBeFalsy(); + expect(isValidHex('ffffff')).toBeTruthy(); + expect(isValidHex('fffffff')).toBeFalsy(); + expect(isValidHex('ffffffff')).toBeFalsy(); + expect(isValidHex('fffffffff')).toBeFalsy(); + expect(isValidHex('ffffffffff')).toBeFalsy(); + expect(isValidHex('fffffffffff')).toBeFalsy(); + expect(isValidHex('ffffffffffff')).toBeFalsy(); + }); + + test('allows strings without leading hash', () => { + // Check a sample of possible colors - doing all takes too long. + for (let i = 0; i <= 0xffffff; i += 0x010101) { + const hex = `000000${i.toString(16)}`.slice(-6); + expect(isValidHex(hex)).toBeTruthy(); + } + }); + + test('allows strings with leading hash', () => { + // Check a sample of possible colors - doing all takes too long. + for (let i = 0; i <= 0xffffff; i += 0x010101) { + const hex = `000000${i.toString(16)}`.slice(-6); + expect(isValidHex(`#${hex}`)).toBeTruthy(); + } + }); + + test('is case-insensitive', () => { + expect(isValidHex('ffffff')).toBeTruthy(); + expect(isValidHex('FfFffF')).toBeTruthy(); + expect(isValidHex('FFFFFF')).toBeTruthy(); + }); + + test('allow transparent color', () => { + expect(isValidHex('transparent')).toBeTruthy(); + }); + + test('does not allow non-hex characters', () => { + expect(isValidHex('gggggg')).toBeFalsy(); + }); + + test('does not allow numbers', () => { + // @ts-ignore + expect(isValidHex(0xffffff)).toBeFalsy(); + }); + }); +}); diff --git a/packages/color-picker/src/helpers/color.ts b/packages/color-picker/src/helpers/color.ts index 2fc2393e..79be6ec0 100644 --- a/packages/color-picker/src/helpers/color.ts +++ b/packages/color-picker/src/helpers/color.ts @@ -1,51 +1,5 @@ -import each from 'lodash/each'; import tinycolor from 'tinycolor2'; -export const simpleCheckForValidColor = (data: { [x: string]: string }) => { - const keysToCheck = ['r', 'g', 'b', 'a', 'h', 's', 'l', 'v']; - let checked = 0; - let passed = 0; - each(keysToCheck, (letter: string) => { - if (data[letter]) { - checked += 1; - // @ts-ignore - if (!isNaN(data[letter])) { - passed += 1; - } - if (letter === 's' || letter === 'l') { - const percentPatt = /^\d+%$/; - if (percentPatt.test(data[letter])) { - passed += 1; - } - } - } - }); - return checked === passed ? data : false; -}; - -export const toState = (data: { hex: any; h: any; source: any }, oldHue?: number | undefined) => { - // @ts-ignore - const color = data.hex ? tinycolor(data.hex) : tinycolor(data); - const hsl = color.toHsl(); - const hsv = color.toHsv(); - const rgb = color.toRgb(); - const hex = color.toHex(); - if (hsl.s === 0) { - hsl.h = oldHue || 0; - hsv.h = oldHue || 0; - } - const transparent = hex === '000000' && rgb.a === 0; - - return { - hsl, - hex: transparent ? 'transparent' : `#${hex}`, - rgb, - hsv, - oldHue: data.h || oldHue || hsl.h, - source: data.source, - }; -}; - export const isValidHex = (hex: string | any[]) => { if (hex === 'transparent') { return true; @@ -56,27 +10,9 @@ export const isValidHex = (hex: string | any[]) => { return hex.length !== 4 + lh && hex.length < 7 + lh && tinycolor(hex).isValid(); }; -export const getContrastingColor = (data: { hex: any; h: any; source: any }) => { - if (!data) { - return '#fff'; - } - const col = toState(data); - if (col.hex === 'transparent') { - return 'rgba(0,0,0,0.4)'; - } - const yiq = (col.rgb.r * 299 + col.rgb.g * 587 + col.rgb.b * 114) / 1000; - return yiq >= 128 ? '#000' : '#fff'; -}; - export const red = { hsl: { a: 1, h: 0, l: 0.5, s: 1 }, hex: '#ff0000', rgb: { r: 255, g: 0, b: 0, a: 1 }, hsv: { h: 0, s: 1, v: 1, a: 1 }, }; - -export const isvalidColorString = (string: string, type: any) => { - const stringWithoutDegree = string.replace('°', ''); - // @ts-ignore - return tinycolor(`${type} (${stringWithoutDegree})`)._ok; -}; diff --git a/packages/color-picker/src/helpers/hue.js b/packages/color-picker/src/helpers/hue.js deleted file mode 100644 index e14a5164..00000000 --- a/packages/color-picker/src/helpers/hue.js +++ /dev/null @@ -1,51 +0,0 @@ -export const calculateChange = (e, direction, hsl, container) => { - const containerWidth = container.clientWidth; - const containerHeight = container.clientHeight; - const x = typeof e.pageX === 'number' ? e.pageX : e.touches[0].pageX; - const y = typeof e.pageY === 'number' ? e.pageY : e.touches[0].pageY; - const left = x - (container.getBoundingClientRect().left + window.pageXOffset); - const top = y - (container.getBoundingClientRect().top + window.pageYOffset); - - if (direction === 'vertical') { - let h; - if (top < 0) { - h = 359; - } else if (top > containerHeight) { - h = 0; - } else { - const percent = -((top * 100) / containerHeight) + 100; - h = (360 * percent) / 100; - } - - if (hsl.h !== h) { - return { - h, - s: hsl.s, - l: hsl.l, - a: hsl.a, - source: 'hsl', - }; - } - } else { - let h; - if (left < 0) { - h = 0; - } else if (left > containerWidth) { - h = 359; - } else { - const percent = (left * 100) / containerWidth; - h = (360 * percent) / 100; - } - - if (hsl.h !== h) { - return { - h, - s: hsl.s, - l: hsl.l, - a: hsl.a, - source: 'hsl', - }; - } - } - return null; -}; diff --git a/packages/color-picker/src/helpers/saturation.js b/packages/color-picker/src/helpers/saturation.js deleted file mode 100644 index 934dd093..00000000 --- a/packages/color-picker/src/helpers/saturation.js +++ /dev/null @@ -1,30 +0,0 @@ -export const calculateChange = (e, hsl, container) => { - const { width: containerWidth, height: containerHeight } = container.getBoundingClientRect(); - const x = typeof e.pageX === 'number' ? e.pageX : e.touches[0].pageX; - const y = typeof e.pageY === 'number' ? e.pageY : e.touches[0].pageY; - let left = x - (container.getBoundingClientRect().left + window.pageXOffset); - let top = y - (container.getBoundingClientRect().top + window.pageYOffset); - - if (left < 0) { - left = 0; - } else if (left > containerWidth) { - left = containerWidth; - } - - if (top < 0) { - top = 0; - } else if (top > containerHeight) { - top = containerHeight; - } - - const saturation = left / containerWidth; - const bright = 1 - top / containerHeight; - - return { - h: hsl.h, - s: saturation, - v: bright, - a: hsl.a, - source: 'hsv', - }; -}; diff --git a/packages/color-picker/src/helpers/spec.js b/packages/color-picker/src/helpers/spec.js deleted file mode 100644 index 0c27b675..00000000 --- a/packages/color-picker/src/helpers/spec.js +++ /dev/null @@ -1,189 +0,0 @@ -/* global test, expect, describe */ - -import * as color from './color'; - -describe('helpers/color', () => { - describe('simpleCheckForValidColor', () => { - test('throws on null', () => { - const data = null; - expect(() => color.simpleCheckForValidColor(data)).toThrowError(TypeError); - }); - - test('throws on undefined', () => { - const data = undefined; - expect(() => color.simpleCheckForValidColor(data)).toThrowError(TypeError); - }); - - test('no-op on number', () => { - const data = 255; - expect(color.simpleCheckForValidColor(data)).toEqual(data); - }); - - test('no-op on NaN', () => { - const data = NaN; - expect(isNaN(color.simpleCheckForValidColor(data))).toBeTruthy(); - }); - - test('no-op on string', () => { - const data = 'ffffff'; - expect(color.simpleCheckForValidColor(data)).toEqual(data); - }); - - test('no-op on array', () => { - const data = []; - expect(color.simpleCheckForValidColor(data)).toEqual(data); - }); - - test('no-op on rgb objects with numeric keys', () => { - const data = { r: 0, g: 0, b: 0 }; - expect(color.simpleCheckForValidColor(data)).toEqual(data); - }); - - test('no-op on an object with an r g b a h s v key mapped to a NaN value', () => { - const data = { r: NaN }; - expect(color.simpleCheckForValidColor(data)).toEqual(data); - }); - - test('no-op on hsl "s" percentage', () => { - const data = { s: '15%' }; - expect(color.simpleCheckForValidColor(data)).toEqual(data); - }); - - test('no-op on hsl "l" percentage', () => { - const data = { l: '100%' }; - expect(color.simpleCheckForValidColor(data)).toEqual(data); - }); - - test('should return false for invalid percentage', () => { - const data = { l: '100%2' }; - expect(color.simpleCheckForValidColor(data)).toBe(false); - }); - }); - - describe('toState', () => { - test('returns an object giving a color in all formats', () => { - expect(color.toState('red')).toEqual({ - hsl: { a: 1, h: 0, l: 0.5, s: 1 }, - hex: '#ff0000', - rgb: { r: 255, g: 0, b: 0, a: 1 }, - hsv: { h: 0, s: 1, v: 1, a: 1 }, - oldHue: 0, - source: undefined, - }); - }); - - test('gives hex color with leading hash', () => { - expect(color.toState('blue').hex).toEqual('#0000ff'); - }); - - test("doesn't mutate hsl color object", () => { - const originalData = { h: 0, s: 0, l: 0, a: 1 }; - const data = Object.assign({}, originalData); - color.toState(data); - expect(data).toEqual(originalData); - }); - - test("doesn't mutate hsv color object", () => { - const originalData = { h: 0, s: 0, v: 0, a: 1 }; - const data = Object.assign({}, originalData); - color.toState(data); - expect(data).toEqual(originalData); - }); - }); - - describe('isValidHex', () => { - test('allows strings of length 3 or 6', () => { - expect(color.isValidHex('f')).toBeFalsy(); - expect(color.isValidHex('ff')).toBeFalsy(); - expect(color.isValidHex('fff')).toBeTruthy(); - expect(color.isValidHex('ffff')).toBeFalsy(); - expect(color.isValidHex('fffff')).toBeFalsy(); - expect(color.isValidHex('ffffff')).toBeTruthy(); - expect(color.isValidHex('fffffff')).toBeFalsy(); - expect(color.isValidHex('ffffffff')).toBeFalsy(); - expect(color.isValidHex('fffffffff')).toBeFalsy(); - expect(color.isValidHex('ffffffffff')).toBeFalsy(); - expect(color.isValidHex('fffffffffff')).toBeFalsy(); - expect(color.isValidHex('ffffffffffff')).toBeFalsy(); - }); - - test('allows strings without leading hash', () => { - // Check a sample of possible colors - doing all takes too long. - for (let i = 0; i <= 0xffffff; i += 0x010101) { - const hex = `000000${i.toString(16)}`.slice(-6); - expect(color.isValidHex(hex)).toBeTruthy(); - } - }); - - test('allows strings with leading hash', () => { - // Check a sample of possible colors - doing all takes too long. - for (let i = 0; i <= 0xffffff; i += 0x010101) { - const hex = `000000${i.toString(16)}`.slice(-6); - expect(color.isValidHex(`#${hex}`)).toBeTruthy(); - } - }); - - test('is case-insensitive', () => { - expect(color.isValidHex('ffffff')).toBeTruthy(); - expect(color.isValidHex('FfFffF')).toBeTruthy(); - expect(color.isValidHex('FFFFFF')).toBeTruthy(); - }); - - test('allow transparent color', () => { - expect(color.isValidHex('transparent')).toBeTruthy(); - }); - - test('does not allow non-hex characters', () => { - expect(color.isValidHex('gggggg')).toBeFalsy(); - }); - - test('does not allow numbers', () => { - expect(color.isValidHex(0xffffff)).toBeFalsy(); - }); - }); - - describe('getContrastingColor', () => { - test('returns a light color for a giving dark color', () => { - expect(color.getContrastingColor('red')).toEqual('#fff'); - }); - - test('returns a dark color for a giving light color', () => { - expect(color.getContrastingColor('white')).toEqual('#000'); - }); - - test('returns a predefined color for Transparent', () => { - expect(color.getContrastingColor('transparent')).toEqual('rgba(0,0,0,0.4)'); - }); - - test('returns a light color as default for undefined', () => { - expect(color.getContrastingColor(undefined)).toEqual('#fff'); - }); - }); -}); - -describe('validColorString', () => { - test('checks for valid RGB string', () => { - expect(color.isvalidColorString('23, 32, 3', 'rgb')).toBeTruthy(); - expect(color.isvalidColorString('290, 302, 3', 'rgb')).toBeTruthy(); - expect(color.isvalidColorString('23', 'rgb')).toBeFalsy(); - expect(color.isvalidColorString('230, 32', 'rgb')).toBeFalsy(); - }); - - test('checks for valid HSL string', () => { - expect(color.isvalidColorString('200, 12, 93', 'hsl')).toBeTruthy(); - expect(color.isvalidColorString('200, 12%, 93%', 'hsl')).toBeTruthy(); - expect(color.isvalidColorString('200, 120, 93%', 'hsl')).toBeTruthy(); - expect(color.isvalidColorString('335°, 64%, 99%', 'hsl')).toBeTruthy(); - expect(color.isvalidColorString('100', 'hsl')).toBeFalsy(); - expect(color.isvalidColorString('20, 32', 'hsl')).toBeFalsy(); - }); - - test('checks for valid HSV string', () => { - expect(color.isvalidColorString('200, 12, 93', 'hsv')).toBeTruthy(); - expect(color.isvalidColorString('200, 120, 93%', 'hsv')).toBeTruthy(); - expect(color.isvalidColorString('200°, 6%, 100%', 'hsv')).toBeTruthy(); - expect(color.isvalidColorString('1', 'hsv')).toBeFalsy(); - expect(color.isvalidColorString('20, 32', 'hsv')).toBeFalsy(); - expect(color.isvalidColorString('200°, ee3, 100%', 'hsv')).toBeFalsy(); - }); -}); diff --git a/packages/color-picker/src/store.ts b/packages/color-picker/src/store.ts index 0f03c516..90baacb7 100644 --- a/packages/color-picker/src/store.ts +++ b/packages/color-picker/src/store.ts @@ -1,5 +1,7 @@ import create from 'zustand'; import createContext from 'zustand/context'; +import { devtools } from 'zustand/middleware'; + import type { Color } from 'chroma-js'; import chroma from 'chroma-js'; @@ -8,13 +10,12 @@ import type { HSLColor, HSVColor, RGBColor } from './types'; export type ColorMode = 'rgb' | 'hsl' | 'hsv'; interface ColorPickerState { - disableAlpha: boolean; - width: number; - styles: any; colorMode: ColorMode; presetColors: string[]; + onSwatchHover?: any; onChange?: ({ hex, color }: { hex: string; color: Color }) => void; colorModel: Color; + hue: number; } interface ColorPickerAction { internalUpdateColor: (color: Color) => void; @@ -23,7 +24,7 @@ interface ColorPickerAction { updateColorMode: (mode: ColorMode) => void; updateByColorSpace: (key: string, value: number) => void; updateByHex: (hex: string) => void; - updateByHsv: (hsv: HSVColor) => void; + updateBySV: (saturation: number, value: number) => void; updateByRgb: (rgb: RGBColor) => void; } @@ -41,10 +42,7 @@ const maxValueMap = { }; const initialState: ColorPickerState = { - disableAlpha: false, - width: 200, - styles: {}, - colorMode: 'rgb', + colorMode: 'hsv', presetColors: [ '#D0021B', '#F5A623', @@ -63,80 +61,103 @@ const initialState: ColorPickerState = { '#FFFFFF', ], colorModel: chroma('#22194D'), + // 由于在 chroma 中,saturation 为 0 时,hue 会自动改成 NaN + // 这会破坏基础的预期 + // 取色器中 + hue: 250, }; -const createStore = () => - create((set, get) => ({ - ...initialState, +const createStore = () => { + const store = create( + devtools( + (set, get) => ({ + ...initialState, + + updateColorMode: (mode) => { + set({ colorMode: mode }); + }, + + internalUpdateColor: (color) => { + set({ colorModel: color }); - updateColorMode: (mode) => { - set({ colorMode: mode }); - }, + if (get().onChange) { + get().onChange({ hex: color.hex('auto'), color }); + } + }, - internalUpdateColor: (color) => { - set({ colorModel: color }); + updateAlpha: (a) => { + const { colorModel, internalUpdateColor } = get(); + if (a < 0) { + a = 0; + } else if (a > 100) { + a = 100; + } - if (get().onChange) { - get().onChange({ hex: color.hex('auto'), color }); - } - }, + a /= 100; + internalUpdateColor(colorModel.alpha(a)); + }, - updateAlpha: (a) => { - const { colorModel, internalUpdateColor } = get(); - if (a < 0) { - a = 0; - } else if (a > 100) { - a = 100; - } + updateHue: (h) => { + const { colorModel, internalUpdateColor } = get(); + internalUpdateColor(colorModel.set('hsl.h', h)); + }, - a /= 100; - internalUpdateColor(colorModel.alpha(a)); - }, + updateByColorSpace: (key, value) => { + const { colorModel, internalUpdateColor, colorMode } = get(); + let v; - updateHue: (h) => { - const { colorModel, internalUpdateColor } = get(); - internalUpdateColor(colorModel.set('hsl.h', h)); - }, + // 将值限定在相应的最大区间内 + v = value > maxValueMap[key] ? maxValueMap[key] : value; + // 且确保 v 最小值为 0 + if (value < 0) v = 0; - updateByColorSpace: (key, value) => { - const { colorModel, internalUpdateColor, colorMode } = get(); - let v; + // 如果是 s v l 三个维度值,将其转换为百分比 - // 将值限定在相应的最大区间内 - v = value > maxValueMap[key] ? maxValueMap[key] : value; - // 且确保 v 最小值为 0 - if (value <= 0) v = 1; + if (['s', 'v', 'l'].includes(key)) { + v /= 100; + } - // 如果是 s v l 三个维度值,将其转换为百分比 + internalUpdateColor(colorModel.set(`${colorMode}.${key}`, v)); + }, - if (['s', 'v', 'l'].includes(key)) { - v /= 100; - } + updateByHex: (hex) => { + const { internalUpdateColor } = get(); - internalUpdateColor(colorModel.set(`${colorMode}.${key}`, v)); - }, + internalUpdateColor(chroma(hex)); + }, - updateByHex: (hex) => { - const { internalUpdateColor } = get(); + updateBySV: (saturation, value) => { + const { internalUpdateColor, colorModel, hue } = get(); - internalUpdateColor(chroma(hex)); - }, + if (saturation < 0) saturation = 0; + if (saturation > 1) saturation = 1; - updateByHsv: (hex) => { - const { h, s, v, a } = hex; - const { internalUpdateColor } = get(); + if (value < 0) value = 0; - internalUpdateColor(chroma([h, s, v, a], 'hsv')); - }, + if (value > 1) value = 1; - updateByRgb: (rgb) => { - const { r, g, b, a } = rgb; - const { internalUpdateColor } = get(); + internalUpdateColor(chroma([hue, saturation, value, colorModel.alpha()], 'hsv')); + }, - internalUpdateColor(chroma([r, g, b, a], 'rgb')); - }, - })); + updateByRgb: (rgb) => { + const { r, g, b, a } = rgb; + const { internalUpdateColor } = get(); + internalUpdateColor(chroma([r, g, b, a], 'rgb')); + }, + }), + { name: 'ColorPicker' }, + ), + ); + + store.subscribe((state) => { + const newHue = state.colorModel.hsv()[0]; + if (isNaN(newHue)) return; + + state.hue = newHue; + }); + return store; +}; const { Provider, useStore } = createContext(); export { Provider, useStore, createStore }; @@ -173,10 +194,10 @@ export const colorSpaceSelector = (s: ColorPickerState): SpaceColor => { return { r, g, b, a }; } case 'hsv': { - return { h: hsv[0], s: hsv[1] * 100, v: hsv[2] * 100, a }; + return { h: s.hue, s: hsv[1] * 100, v: hsv[2] * 100, a }; } case 'hsl': { - return { h: hsl[0], s: hsl[1] * 100, l: hsl[2] * 100, a }; + return { h: s.hue, s: hsl[1] * 100, l: hsl[2] * 100, a }; } } };