Create color prop (#64)

Authored by andrew@theatrejs.com
This commit is contained in:
Andrew Prifer 2022-02-19 17:54:19 +01:00 committed by GitHub
parent b643739ec7
commit defb538561
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1641 additions and 70 deletions

1
credits.txt Normal file
View file

@ -0,0 +1 @@
The color picker is a fork of https://github.com/omgovich/react-colorful

View file

@ -24,6 +24,7 @@ const boxObjectConfig = {
bool: types.boolean(false),
x: types.number(200),
y: types.number(200),
color: types.rgba({r: 1, g: 0, b: 0, a: 1}),
}
const Box: React.FC<{
@ -42,6 +43,12 @@ const Box: React.FC<{
test: string
testLiteral: string
bool: boolean
color: {
r: number
g: number
b: number
a: number
}
}>(obj.value)
useLayoutEffect(() => {
@ -75,6 +82,7 @@ const Box: React.FC<{
test: initial.test,
testLiteral: initial.testLiteral,
bool: initial.bool,
color: initial.color,
})
})
},
@ -111,6 +119,12 @@ const Box: React.FC<{
<pre style={{margin: 0, padding: '1rem'}}>
{JSON.stringify(state, null, 4)}
</pre>
<div
style={{
height: 50,
background: state.color.toString(),
}}
/>
</div>
)
}

View file

@ -4,3 +4,5 @@ Simply update the version of `theatre/package.json`, then run `$ yarn run releas
1. Update the version of `@theatre/core` and `@theatre/studio` and other dependencies.
2. Bundle the `.js` and `.dts` files.
3. Publish all packages to npm.
Packages added to theatre/package.json will be bundled with studio, packages added to package.json of sub-packages will be treated as their externals.

View file

@ -25,5 +25,6 @@
"sideEffects": true,
"dependencies": {
"@theatre/dataverse": "workspace:*"
}
},
"//": "Add packages here to make them externals of core. Add them to theatre/package.json if you want to bundle them with studio."
}

View file

@ -1,5 +1,13 @@
import type {$IntentionalAny} from '@theatre/shared/utils/types'
import userReadableTypeOfValue from '@theatre/shared/utils/userReadableTypeOfValue'
import type {Rgba} from '@theatre/shared/utils/color'
import {
decorateRgba,
linearSrgbToOklab,
oklabToLinearSrgb,
srgbToLinearSrgb,
linearSrgbToSrgb,
} from '@theatre/shared/utils/color'
import {mapValues} from 'lodash-es'
import type {
IShorthandCompoundProps,
@ -234,6 +242,100 @@ const _interpolateNumber = (
return left + progression * (right - left)
}
export const rgba = (
defaultValue: Rgba = {r: 0, g: 0, b: 0, a: 1},
opts?: {
label?: string
},
): PropTypeConfig_Rgba => {
if (process.env.NODE_ENV !== 'production') {
validateCommonOpts('t.rgba(defaultValue, opts)', opts)
// Lots of duplicated code and stuff that probably shouldn't be here, mostly
// because we are still figuring out how we are doing validation, sanitization,
// decoding, decorating.
// Validate default value
let valid = true
for (const p of ['r', 'g', 'b', 'a']) {
if (
!Object.prototype.hasOwnProperty.call(defaultValue, p) ||
typeof (defaultValue as $IntentionalAny)[p] !== 'number'
) {
valid = false
}
}
if (!valid) {
throw new Error(
`Argument defaultValue in t.rgba(defaultValue) must be of the shape { r: number; g: number, b: number, a: number; }.`,
)
}
}
// Clamp defaultValue components between 0 and 1
const sanitized = {}
for (const component of ['r', 'g', 'b', 'a']) {
;(sanitized as $IntentionalAny)[component] = Math.min(
Math.max((defaultValue as $IntentionalAny)[component], 0),
1,
)
}
return {
type: 'rgba',
valueType: null as $IntentionalAny,
default: decorateRgba(sanitized as Rgba),
[propTypeSymbol]: 'TheatrePropType',
label: opts?.label,
sanitize: _sanitizeRgba,
interpolate: _interpolateRgba,
}
}
const _sanitizeRgba = (val: unknown): Rgba | undefined => {
let valid = true
for (const c of ['r', 'g', 'b', 'a']) {
if (
!Object.prototype.hasOwnProperty.call(val, c) ||
typeof (val as $IntentionalAny)[c] !== 'number'
) {
valid = false
}
}
// Clamp defaultValue components between 0 and 1
const sanitized = {}
for (const c of ['r', 'g', 'b', 'a']) {
;(sanitized as $IntentionalAny)[c] = Math.min(
Math.max((val as $IntentionalAny)[c], 0),
1,
)
}
return valid ? decorateRgba(sanitized as Rgba) : undefined
}
const _interpolateRgba = (
left: Rgba,
right: Rgba,
progression: number,
): Rgba => {
const leftLab = linearSrgbToOklab(srgbToLinearSrgb(left))
const rightLab = linearSrgbToOklab(srgbToLinearSrgb(right))
const interpolatedLab = {
L: (1 - progression) * leftLab.L + progression * rightLab.L,
a: (1 - progression) * leftLab.a + progression * rightLab.a,
b: (1 - progression) * leftLab.b + progression * rightLab.b,
alpha: (1 - progression) * leftLab.alpha + progression * rightLab.alpha,
}
const interpolatedRgba = linearSrgbToSrgb(oklabToLinearSrgb(interpolatedLab))
return decorateRgba(interpolatedRgba)
}
/**
* A boolean prop type
*
@ -467,6 +569,11 @@ export interface PropTypeConfig_StringLiteral<T extends string>
as: 'menu' | 'switch'
}
export interface PropTypeConfig_Rgba extends IBasePropType<Rgba> {
type: 'rgba'
default: Rgba
}
/**
*
*/
@ -492,6 +599,7 @@ export type PropTypeConfig_AllPrimitives =
| PropTypeConfig_Boolean
| PropTypeConfig_String
| PropTypeConfig_StringLiteral<$IntentionalAny>
| PropTypeConfig_Rgba
export type PropTypeConfig =
| PropTypeConfig_AllPrimitives

View file

@ -68,6 +68,7 @@
"prop-types": "^15.7.2",
"propose": "^0.0.5",
"react": "^17.0.2",
"react-colorful": "^5.5.1",
"react-dom": "^17.0.2",
"react-error-boundary": "^3.1.3",
"react-icons": "^4.2.0",
@ -95,5 +96,6 @@
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fuzzysort": "^1.1.4"
}
},
"//": "Add packages here to have them bundled with studio, otherwise add them in the package.json of either studio or core, and they'll be treated as their externals."
}

View file

@ -0,0 +1,120 @@
export function parseRgbaFromHex(rgba: string) {
rgba = rgba.trim().toLowerCase()
const hex = rgba.match(/^#?([0-9a-f]{8})$/i)
if (!hex) {
return {
r: 0,
g: 0,
b: 0,
a: 1,
}
}
const match = hex[1]
return {
r: parseInt(match.substr(0, 2), 16) / 255,
g: parseInt(match.substr(2, 2), 16) / 255,
b: parseInt(match.substr(4, 2), 16) / 255,
a: parseInt(match.substr(6, 2), 16) / 255,
}
}
export function rgba2hex(rgba: Rgba) {
const hex =
((rgba.r * 255) | (1 << 8)).toString(16).slice(1) +
((rgba.g * 255) | (1 << 8)).toString(16).slice(1) +
((rgba.b * 255) | (1 << 8)).toString(16).slice(1) +
((rgba.a * 255) | (1 << 8)).toString(16).slice(1)
return `#${hex}`
}
// TODO: We should add a decorate property to the propConfig too.
// Right now, each place that has anything to do with a color is individually
// responsible for defining a toString() function on the object it returns.
export function decorateRgba(rgba: Rgba) {
return {
...rgba,
toString() {
return rgba2hex(this)
},
}
}
export function linearSrgbToSrgb(rgba: Rgba) {
function compress(x: number) {
// This looks funky because sRGB uses a linear scale below 0.0031308 in
// order to avoid an infinite slope, while trying to approximate gamma 2.2
// as closely as possible, hence the branching and the 2.4 exponent.
if (x >= 0.0031308) return 1.055 * x ** (1.0 / 2.4) - 0.055
else return 12.92 * x
}
return {
r: compress(rgba.r),
g: compress(rgba.g),
b: compress(rgba.b),
a: rgba.a,
}
}
export function srgbToLinearSrgb(rgba: Rgba) {
function expand(x: number) {
if (x >= 0.04045) return ((x + 0.055) / (1 + 0.055)) ** 2.4
else return x / 12.92
}
return {
r: expand(rgba.r),
g: expand(rgba.g),
b: expand(rgba.b),
a: rgba.a,
}
}
export function linearSrgbToOklab(rgba: Rgba) {
let l = 0.4122214708 * rgba.r + 0.5363325363 * rgba.g + 0.0514459929 * rgba.b
let m = 0.2119034982 * rgba.r + 0.6806995451 * rgba.g + 0.1073969566 * rgba.b
let s = 0.0883024619 * rgba.r + 0.2817188376 * rgba.g + 0.6299787005 * rgba.b
let l_ = Math.cbrt(l)
let m_ = Math.cbrt(m)
let s_ = Math.cbrt(s)
return {
L: 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_,
a: 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_,
b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_,
alpha: rgba.a,
}
}
export function oklabToLinearSrgb(laba: Laba) {
let l_ = laba.L + 0.3963377774 * laba.a + 0.2158037573 * laba.b
let m_ = laba.L - 0.1055613458 * laba.a - 0.0638541728 * laba.b
let s_ = laba.L - 0.0894841775 * laba.a - 1.291485548 * laba.b
let l = l_ * l_ * l_
let m = m_ * m_ * m_
let s = s_ * s_ * s_
return {
r: +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
b: -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
a: laba.alpha,
}
}
export type Rgba = {
r: number
g: number
b: number
a: number
}
export type Laba = {
L: number
a: number
b: number
alpha: number
}

View file

@ -11,7 +11,38 @@ export type SerializableMap<
Primitives extends SerializablePrimitive = SerializablePrimitive,
> = {[Key in string]?: SerializableValue<Primitives>}
export type SerializablePrimitive = string | number | boolean
/*
* TODO: For now the rgba primitive type is hard-coded. We should make it proper.
* What instead we should do is somehow exclude objects where
* object.type !== 'compound'. One way to do this would be
*
* type SerializablePrimitive<T> = T extends {type: 'compound'} ? never : T;
*
* const badStuff = {
* type: 'compound',
* foo: 3,
* } as const
*
* const goodStuff = {
* type: 'literallyanythingelse',
* foo: 3,
* } as const
*
* function serializeStuff<T>(giveMeStuff: SerializablePrimitive<T>) {
* // ...
* }
*
* serializeStuff(badStuff)
* serializeStuff(goodStuff)
*
* However this wouldn't protect against other unserializable stuff, or nested
* unserializable stuff, since using mapped types seem to break it for some reason.
*/
export type SerializablePrimitive =
| string
| number
| boolean
| {r: number; g: number; b: number; a: number}
export type SerializableValue<
Primitives extends SerializablePrimitive = SerializablePrimitive,

View file

@ -30,5 +30,6 @@
"react": "^17.0.2",
"react-dom": "^17.0.2",
"styled-components": "^5.3.0"
}
},
"//": "Add packages here to make them externals of studio. Add them to theatre/package.json if you want to bundle them with studio."
}

View file

@ -30,6 +30,9 @@ import {isSheetObject} from '@theatre/shared/instanceTypes'
import type {OnDiskState} from '@theatre/core/projects/store/storeTypes'
import {generateDiskStateRevision} from './generateDiskStateRevision'
import type {PropTypeConfig} from '@theatre/core/propTypes'
import type {PathToProp} from '@theatre/shared/src/utils/addresses'
import {getPropConfigByPath} from '@theatre/shared/propTypes/utils'
import {cloneDeep} from 'lodash-es'
export type Drafts = {
historic: Draft<StudioHistoricState>
@ -131,9 +134,10 @@ export default class StudioStore {
const api: ITransactionPrivateApi = {
set: (pointer, value) => {
ensureRunning()
const {root} = getPointerParts(pointer as Pointer<$FixMe>)
const _value = cloneDeep(value)
const {root, path} = getPointerParts(pointer as Pointer<$FixMe>)
if (isSheetObject(root)) {
root.validateValue(pointer as Pointer<$FixMe>, value)
root.validateValue(pointer as Pointer<$FixMe>, _value)
const sequenceTracksTree = val(
root.template
@ -141,26 +145,33 @@ export default class StudioStore {
.getValue(),
)
forEachDeep(
value,
(v, pathToProp) => {
if (typeof v === 'undefined' || v === null) {
const propConfig = getPropConfigByPath(
root.template.config,
path,
) as PropTypeConfig
const setStaticOrKeyframeProp = <T>(
value: T,
path: PathToProp,
) => {
if (typeof value === 'undefined' || value === null) {
return
}
const propAddress = {...root.address, pathToProp}
const trackId = get(
sequenceTracksTree,
pathToProp,
) as $FixMe as SequenceTrackId | undefined
const propAddress = {...root.address, pathToProp: path}
const trackId = get(sequenceTracksTree, path) as $FixMe as
| SequenceTrackId
| undefined
if (typeof trackId === 'string') {
const propConfig = get(
root.template.config.props,
pathToProp,
const propConfig = getPropConfigByPath(
root.template.config,
path,
) as PropTypeConfig | undefined
if (propConfig?.sanitize) v = propConfig.sanitize(v)
// TODO: Make sure this causes no problems wrt decorated
// or otherwise unserializable stuff that sanitize might return.
// value needs to be serializable.
if (propConfig?.sanitize) value = propConfig.sanitize(value)
const seq = root.sheet.getSequence()
seq.position = seq.closestGridPosition(seq.position)
@ -169,18 +180,30 @@ export default class StudioStore {
...propAddress,
trackId,
position: seq.position,
value: v as $FixMe,
value: value as $FixMe,
snappingFunction: seq.closestGridPosition,
},
)
} else {
} else if (propConfig !== undefined) {
stateEditors.coreByProject.historic.sheetsById.staticOverrides.byObject.setValueOfPrimitiveProp(
{...propAddress, value: v},
{...propAddress, value: value as $FixMe},
)
}
}
// If we are dealing with a compound prop, we recurse through its
// nested properties.
if (propConfig.type === 'compound') {
forEachDeep(
_value,
(v, pathToProp) => {
setStaticOrKeyframeProp(v, pathToProp)
},
getPointerParts(pointer as Pointer<$IntentionalAny>).path,
)
} else {
setStaticOrKeyframeProp(_value, path)
}
} else {
throw new Error(
'Only setting props of SheetObject-s is supported in a transaction so far',
@ -202,33 +225,47 @@ export default class StudioStore {
path,
)
forEachDeep(
defaultValue,
(v, pathToProp) => {
const propAddress = {...root.address, pathToProp}
const propConfig = getPropConfigByPath(
root.template.config,
path,
) as PropTypeConfig
const trackId = get(
sequenceTracksTree,
pathToProp,
) as $FixMe as SequenceTrackId | undefined
const unsetStaticOrKeyframeProp = <T>(
value: T,
path: PathToProp,
) => {
const propAddress = {...root.address, pathToProp: path}
const trackId = get(sequenceTracksTree, path) as $FixMe as
| SequenceTrackId
| undefined
if (typeof trackId === 'string') {
stateEditors.coreByProject.historic.sheetsById.sequence.unsetKeyframeAtPosition(
{
...propAddress,
trackId,
position:
root.sheet.getSequence().positionSnappedToGrid,
position: root.sheet.getSequence().positionSnappedToGrid,
},
)
} else {
} else if (propConfig !== undefined) {
stateEditors.coreByProject.historic.sheetsById.staticOverrides.byObject.unsetValueOfPrimitiveProp(
propAddress,
)
}
}
if (propConfig.type === 'compound') {
forEachDeep(
defaultValue,
(v, pathToProp) => {
unsetStaticOrKeyframeProp(v, pathToProp)
},
getPointerParts(pointer as Pointer<$IntentionalAny>).path,
)
} else {
unsetStaticOrKeyframeProp(defaultValue, path)
}
} else {
throw new Error(
'Only setting props of SheetObject-s is supported in a transaction so far',

View file

@ -7,6 +7,7 @@ import CompoundPropEditor from './CompoundPropEditor'
import NumberPropEditor from './NumberPropEditor'
import StringLiteralPropEditor from './StringLiteralPropEditor'
import StringPropEditor from './StringPropEditor'
import RgbaPropEditor from './RgbaPropEditor'
/**
* Returns the PropTypeConfig by path. Assumes `path` is a valid prop path and that
@ -69,7 +70,7 @@ const propEditorByPropType: {
enum: () => <></>,
boolean: BooleanPropEditor,
stringLiteral: StringLiteralPropEditor,
// cssrgba: () => <></>,
rgba: RgbaPropEditor,
}
const DeterminePropEditor: React.FC<{

View file

@ -0,0 +1,121 @@
import type {PropTypeConfig_Rgba} from '@theatre/core/propTypes'
import type {Rgba} from '@theatre/shared/utils/color'
import {
decorateRgba,
rgba2hex,
parseRgbaFromHex,
} from '@theatre/shared/utils/color'
import type SheetObject from '@theatre/core/sheetObjects/SheetObject'
import React, {useCallback, useRef} from 'react'
import {useEditingToolsForPrimitiveProp} from './utils/useEditingToolsForPrimitiveProp'
import {SingleRowPropEditor} from './utils/SingleRowPropEditor'
import {RgbaColorPicker} from '@theatre/studio/uiComponents/colorPicker'
import styled from 'styled-components'
import usePopover from '@theatre/studio/uiComponents/Popover/usePopover'
import BasicStringInput from '@theatre/studio/uiComponents/form/BasicStringInput'
import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover'
const RowContainer = styled.div`
display: flex;
align-items: center;
height: 100%;
gap: 4px;
`
interface PuckProps {
background: Rgba
}
const Puck = styled.div.attrs<PuckProps>((props) => ({
style: {
background: props.background,
},
}))<PuckProps>`
height: calc(100% - 4px);
aspect-ratio: 1;
border-radius: 2px;
`
const HexInput = styled(BasicStringInput)`
flex: 1;
`
const noop = () => {}
const RgbaPropEditor: React.FC<{
propConfig: PropTypeConfig_Rgba
pointerToProp: SheetObject['propsP']
obj: SheetObject
}> = ({propConfig, pointerToProp, obj}) => {
const containerRef = useRef<HTMLDivElement>(null!)
const stuff = useEditingToolsForPrimitiveProp<Rgba>(
pointerToProp,
obj,
propConfig,
)
const onChange = useCallback(
(color: string) => {
const rgba = decorateRgba(parseRgbaFromHex(color))
stuff.permenantlySetValue(rgba)
},
[stuff],
)
const [popoverNode, openPopover] = usePopover({}, () => {
return (
<BasicPopover>
<div
style={{
margin: 8,
pointerEvents: 'all',
}}
>
<RgbaColorPicker
color={{
r: stuff.value.r,
g: stuff.value.g,
b: stuff.value.b,
a: stuff.value.a,
}}
temporarilySetValue={(color) => {
const rgba = decorateRgba(color)
stuff.temporarilySetValue(rgba)
}}
permanentlySetValue={(color) => {
// console.log('perm')
const rgba = decorateRgba(color)
stuff.permenantlySetValue(rgba)
}}
discardTemporaryValue={stuff.discardTemporaryValue}
/>
</div>
</BasicPopover>
)
})
return (
<SingleRowPropEditor {...{stuff, propConfig, pointerToProp}}>
<RowContainer>
<Puck
background={stuff.value}
ref={containerRef}
onClick={(e) => {
openPopover(e, containerRef.current)
}}
/>
<HexInput
value={rgba2hex(stuff.value)}
temporarilySetValue={noop}
discardTemporaryValue={noop}
permenantlySetValue={onChange}
isValid={(v) => !!v.match(/^#?([0-9a-f]{8})$/i)}
/>
</RowContainer>
{popoverNode}
</SingleRowPropEditor>
)
}
export default RgbaPropEditor

View file

@ -0,0 +1,29 @@
import type {FC} from 'react'
import React, {createContext, useContext, useState} from 'react'
const editingContext = createContext<{
editing: boolean
setEditing: (editing: boolean) => void
}>(undefined!)
/**
* Provides the current mode the color picker is in. When editing, the picker should be
* stateful and disregard controlling props, while not editing, it should behave
* in a controlled manner.
*/
export const EditingProvider: FC = ({children}) => {
const [editing, setEditing] = useState(false)
return (
<editingContext.Provider
value={{
editing,
setEditing,
}}
>
{children}
</editingContext.Provider>
)
}
export const useEditing = () => useContext(editingContext)

View file

@ -0,0 +1,54 @@
import React from 'react'
import {AlphaColorPicker} from './common/AlphaColorPicker'
import type {
ColorModel,
ColorPickerBaseProps,
HsvaColor,
RgbaColor,
} from '@theatre/studio/uiComponents/colorPicker/types'
import {equalColorObjects} from '@theatre/studio/uiComponents/colorPicker/utils/compare'
import {
rgbaToHsva,
hsvaToRgba,
} from '@theatre/studio/uiComponents/colorPicker/utils/convert'
import {EditingProvider} from './EditingProvider'
const normalizeRgba = (rgba: RgbaColor) => {
return {
r: rgba.r / 255,
g: rgba.g / 255,
b: rgba.b / 255,
a: rgba.a,
}
}
const denormalizeRgba = (rgba: RgbaColor) => {
return {
r: rgba.r * 255,
g: rgba.g * 255,
b: rgba.b * 255,
a: rgba.a,
}
}
const colorModel: ColorModel<RgbaColor> = {
defaultColor: {r: 0, g: 0, b: 0, a: 1},
toHsva: (rgba: RgbaColor) => rgbaToHsva(denormalizeRgba(rgba)),
fromHsva: (hsva: HsvaColor) => normalizeRgba(hsvaToRgba(hsva)),
equal: equalColorObjects,
}
export const RgbaColorPicker = (
props: ColorPickerBaseProps<RgbaColor>,
): JSX.Element => (
<EditingProvider>
<AlphaColorPicker
{...props}
permanentlySetValue={(newColor) => {
props.permanentlySetValue!(newColor)
}}
colorModel={colorModel}
/>
</EditingProvider>
)

View file

@ -0,0 +1,85 @@
import React from 'react'
import type {Interaction} from './Interactive'
import {Interactive} from './Interactive'
import {Pointer} from './Pointer'
import {hsvaToHslaString} from '@theatre/studio/uiComponents/colorPicker/utils/convert'
import {clamp} from '@theatre/studio/uiComponents/colorPicker/utils/clamp'
import {round} from '@theatre/studio/uiComponents/colorPicker/utils/round'
import type {HsvaColor} from '@theatre/studio/uiComponents/colorPicker/types'
import styled from 'styled-components'
const Container = styled.div`
position: relative;
height: 24px;
border-radius: 4px;
// Checkerboard
background-color: #fff;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill-opacity=".05"><rect x="8" width="8" height="8"/><rect y="8" width="8" height="8"/></svg>');
`
interface GradientProps {
colorFrom: string
colorTo: string
}
const Gradient = styled.div.attrs<GradientProps>(({colorFrom, colorTo}) => ({
style: {
backgroundImage: `linear-gradient(90deg, ${colorFrom}, ${colorTo})`,
},
}))<GradientProps>`
content: '';
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
pointer-events: none;
border-radius: inherit;
// Improve rendering on light backgrounds
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.05);
`
const StyledPointer = styled(Pointer)`
// Checkerboard
background-color: #fff;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill-opacity=".05"><rect x="8" width="8" height="8"/><rect y="8" width="8" height="8"/></svg>');
`
interface Props {
className?: string
hsva: HsvaColor
onChange: (newAlpha: {a: number}) => void
}
export const Alpha = ({className, hsva, onChange}: Props): JSX.Element => {
const handleMove = (interaction: Interaction) => {
onChange({a: interaction.left})
}
const handleKey = (offset: Interaction) => {
// Alpha always fit into [0, 1] range
onChange({a: clamp(hsva.a + offset.left)})
}
// We use `Object.assign` instead of the spread operator
// to prevent adding the polyfill (about 150 bytes gzipped)
const colorFrom = hsvaToHslaString(Object.assign({}, hsva, {a: 0}))
const colorTo = hsvaToHslaString(Object.assign({}, hsva, {a: 1}))
return (
<Container className={className}>
<Gradient colorFrom={colorFrom} colorTo={colorTo} />
<Interactive
onMove={handleMove}
onKey={handleKey}
aria-label="Alpha"
aria-valuetext={`${round(hsva.a * 100)}%`}
>
<StyledPointer left={hsva.a} color={hsvaToHslaString(hsva)} />
</Interactive>
</Container>
)
}

View file

@ -0,0 +1,59 @@
import React, {useEffect} from 'react'
import {Hue} from './Hue'
import {Saturation} from './Saturation'
import {Alpha} from './Alpha'
import type {
ColorModel,
ColorPickerBaseProps,
AnyColor,
} from '@theatre/studio/uiComponents/colorPicker/types'
import {useColorManipulation} from '@theatre/studio/uiComponents/colorPicker/hooks/useColorManipulation'
import styled from 'styled-components'
const Container = styled.div`
position: relative;
display: flex;
gap: 8px;
flex-direction: column;
width: 200px;
height: 200px;
user-select: none;
cursor: default;
`
interface Props<T extends AnyColor> extends ColorPickerBaseProps<T> {
colorModel: ColorModel<T>
}
export const AlphaColorPicker = <T extends AnyColor>({
className,
colorModel,
color = colorModel.defaultColor,
temporarilySetValue,
permanentlySetValue,
discardTemporaryValue,
...rest
}: Props<T>): JSX.Element => {
const [tempHsva, updateHsva] = useColorManipulation<T>(
colorModel,
color,
temporarilySetValue,
permanentlySetValue,
)
useEffect(() => {
return () => {
discardTemporaryValue()
}
}, [])
return (
<Container {...rest}>
<Saturation hsva={tempHsva} onChange={updateHsva} />
<Hue hue={tempHsva.h} onChange={updateHsva} />
<Alpha hsva={tempHsva} onChange={updateHsva} />
</Container>
)
}

View file

@ -0,0 +1,68 @@
import React from 'react'
import type {Interaction} from './Interactive'
import {Interactive} from './Interactive'
import {Pointer} from './Pointer'
import {hsvaToHslString} from '@theatre/studio/uiComponents/colorPicker/utils/convert'
import {clamp} from '@theatre/studio/uiComponents/colorPicker/utils/clamp'
import {round} from '@theatre/studio/uiComponents/colorPicker/utils/round'
import styled from 'styled-components'
const Container = styled.div`
position: relative;
height: 24px;
border-radius: 4px;
background: linear-gradient(
to right,
#f00 0%,
#ff0 17%,
#0f0 33%,
#0ff 50%,
#00f 67%,
#f0f 83%,
#f00 100%
);
`
const StyledPointer = styled(Pointer)`
z-index: 2;
`
interface Props {
className?: string
hue: number
onChange: (newHue: {h: number}) => void
}
const HueBase = ({className, hue, onChange}: Props) => {
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),
})
}
return (
<Container className={className}>
<Interactive
onMove={handleMove}
onKey={handleKey}
aria-label="Hue"
aria-valuetext={round(hue)}
>
<StyledPointer
left={hue / 360}
color={hsvaToHslString({h: hue, s: 100, v: 100, a: 1})}
/>
</Interactive>
</Container>
)
}
export const Hue = React.memo(HueBase)

View file

@ -0,0 +1,203 @@
import React, {useRef, useMemo, useEffect} from 'react'
import {useEventCallback} from '@theatre/studio/uiComponents/colorPicker/hooks/useEventCallback'
import {clamp} from '@theatre/studio/uiComponents/colorPicker/utils/clamp'
import styled from 'styled-components'
import {useEditing} from '@theatre/studio/uiComponents/colorPicker/components/EditingProvider'
export interface Interaction {
left: number
top: number
}
// Check if an event was triggered by touch
const isTouch = (event: MouseEvent | TouchEvent): event is TouchEvent =>
'touches' in event
// Finds a proper touch point by its identifier
const getTouchPoint = (touches: TouchList, touchId: null | number): Touch => {
for (let i = 0; i < touches.length; i++) {
if (touches[i].identifier === touchId) return touches[i]
}
return touches[0]
}
// Finds the proper window object to fix iframe embedding issues
const getParentWindow = (node?: HTMLDivElement | null): Window => {
return (node && node.ownerDocument.defaultView) || self
}
// Returns a relative position of the pointer inside the node's bounding box
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,
),
}
}
// Browsers introduced an intervention, making touch events passive by default.
// This workaround removes `preventDefault` call from the touch handlers.
// https://github.com/facebook/react/issues/19651
const preventDefaultMove = (event: MouseEvent | TouchEvent): void => {
!isTouch(event) && event.preventDefault()
}
// Prevent mobile browsers from handling mouse events (conflicting with touch ones).
// If we detected a touch interaction before, we prefer reacting to touch events only.
const isInvalid = (
event: MouseEvent | TouchEvent,
hasTouch: boolean,
): boolean => {
return hasTouch && !isTouch(event)
}
interface Props {
onMove: (interaction: Interaction) => void
onKey: (offset: Interaction) => void
children: React.ReactNode
}
const Container = styled.div`
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
border-radius: inherit;
outline: none;
/* Don't trigger the default scrolling behavior when the event is originating from this element */
touch-action: none;
`
const InteractiveBase = ({onMove, onKey, ...rest}: Props) => {
const container = useRef<HTMLDivElement>(null)
const onMoveCallback = useEventCallback<Interaction>(onMove)
const onKeyCallback = useEventCallback<Interaction>(onKey)
const touchId = useRef<null | number>(null)
const hasTouch = useRef(false)
const {setEditing} = useEditing()
const [handleMoveStart, handleKeyDown, toggleDocumentEvents] = useMemo(() => {
const handleMoveStart = ({
nativeEvent,
}: React.MouseEvent | React.TouchEvent) => {
const el = container.current
if (!el) return
// Prevent text selection
preventDefaultMove(nativeEvent)
if (isInvalid(nativeEvent, hasTouch.current) || !el) return
if (isTouch(nativeEvent)) {
hasTouch.current = true
const changedTouches = nativeEvent.changedTouches || []
if (changedTouches.length)
touchId.current = changedTouches[0].identifier
}
el.focus()
setEditing(true)
onMoveCallback(getRelativePosition(el, nativeEvent, touchId.current))
toggleDocumentEvents(true)
}
const handleMove = (event: MouseEvent | TouchEvent) => {
// Prevent text selection
preventDefaultMove(event)
// If user moves the pointer outside the window or iframe bounds and release it there,
// `mouseup`/`touchend` won't be fired. In order to stop the picker from following the cursor
// after the user has moved the mouse/finger back to the document, we check `event.buttons`
// and `event.touches`. It allows us to detect that the user is just moving his pointer
// without pressing it down
// Note: we should use pointer events to fix this, since we don't have strict compatibility
// requirements.
const isDown = isTouch(event)
? event.touches.length > 0
: event.buttons > 0
if (isDown && container.current) {
onMoveCallback(
getRelativePosition(container.current, event, touchId.current),
)
} else {
setEditing(false)
toggleDocumentEvents(false)
}
}
// Use move-end anyway (see above) so we can terminate early if we receive one
// instead of having to wait for the user to move the mouse, which they might not do.
const handleMoveEnd = (event: MouseEvent | TouchEvent) => {
setEditing(false)
toggleDocumentEvents(false)
}
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,
})
}
function toggleDocumentEvents(state?: boolean) {
const touch = hasTouch.current
const el = container.current
const parentWindow = getParentWindow(el)
// Add or remove additional pointer event listeners
const toggleEvent = state
? parentWindow.addEventListener
: parentWindow.removeEventListener
toggleEvent(touch ? 'touchmove' : 'mousemove', handleMove)
toggleEvent(touch ? 'touchend' : 'mouseup', handleMoveEnd)
}
return [handleMoveStart, handleKeyDown, toggleDocumentEvents]
}, [onKeyCallback, onMoveCallback])
// Remove window event listeners before unmounting
useEffect(() => toggleDocumentEvents, [toggleDocumentEvents])
return (
<Container
{...rest}
onTouchStart={handleMoveStart}
onMouseDown={handleMoveStart}
ref={container}
onKeyDown={handleKeyDown}
tabIndex={0}
role="slider"
/>
)
}
export const Interactive = React.memo(InteractiveBase)

View file

@ -0,0 +1,59 @@
import React from 'react'
import styled from 'styled-components'
import {Interactive} from './Interactive'
// Create an "empty" styled version, so we can reference it for contextual styling
const StyledInteractive = styled(Interactive)``
const Container = styled.div`
position: absolute;
z-index: 1;
box-sizing: border-box;
width: 28px;
height: 28px;
transform: translate(-50%, -50%);
background-color: #fff;
border: 2px solid #fff;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
${StyledInteractive}:focus & {
transform: translate(-50%, -50%) scale(1.1);
}
`
const Fill = styled.div`
content: '';
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
pointer-events: none;
border-radius: inherit;
`
interface Props {
className?: string
top?: number
left: number
color: string
}
export const Pointer = ({
className,
color,
left,
top = 0.5,
}: Props): JSX.Element => {
const style = {
top: `${top * 100}%`,
left: `${left * 100}%`,
}
return (
<Container style={style} className={className}>
<Fill style={{backgroundColor: color}} />
</Container>
)
}

View file

@ -0,0 +1,73 @@
import React from 'react'
import type {Interaction} from './Interactive'
import {Interactive} from './Interactive'
import {Pointer} from './Pointer'
import type {HsvaColor} from '@theatre/studio/uiComponents/colorPicker/types'
import {hsvaToHslString} from '@theatre/studio/uiComponents/colorPicker/utils/convert'
import {clamp} from '@theatre/studio/uiComponents/colorPicker/utils/clamp'
import {round} from '@theatre/studio/uiComponents/colorPicker/utils/round'
import styled from 'styled-components'
const Container = styled.div`
position: relative;
flex-grow: 1;
border-color: transparent; /* Fixes https://github.com/omgovich/react-colorful/issues/139 */
border-bottom: 12px solid #000;
border-radius: 4px 4px 4px 4px;
background-image: linear-gradient(to top, #000, rgba(0, 0, 0, 0)),
linear-gradient(to right, #fff, rgba(255, 255, 255, 0));
// Improve elements rendering on light backgrounds
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.05);
`
const StyledPointer = styled(Pointer)`
z-index: 3;
`
interface Props {
hsva: HsvaColor
onChange: (newColor: {s: number; v: number}) => void
}
const SaturationBase = ({hsva, onChange}: Props) => {
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),
})
}
const containerStyle = {
backgroundColor: hsvaToHslString({h: hsva.h, s: 100, v: 100, a: 1}),
}
return (
<Container style={containerStyle}>
<Interactive
onMove={handleMove}
onKey={handleKey}
aria-label="Color"
aria-valuetext={`Saturation ${round(hsva.s)}%, Brightness ${round(
hsva.v,
)}%`}
>
<StyledPointer
top={1 - hsva.v / 100}
left={hsva.s / 100}
color={hsvaToHslString(hsva)}
/>
</Interactive>
</Container>
)
}
export const Saturation = React.memo(SaturationBase)

View file

@ -0,0 +1,105 @@
import {useState, useEffect, useCallback, useRef} from 'react'
import type {
ColorModel,
AnyColor,
HsvaColor,
} from '@theatre/studio/uiComponents/colorPicker/types'
import {equalColorObjects} from '@theatre/studio/uiComponents/colorPicker/utils/compare'
import {useEventCallback} from './useEventCallback'
import {useEditing} from '@theatre/studio/uiComponents/colorPicker/components/EditingProvider'
export function useColorManipulation<T extends AnyColor>(
colorModel: ColorModel<T>,
color: T,
onTemporarilyChange: (color: T) => void,
onPermanentlyChange: (color: T) => void,
): [HsvaColor, (color: Partial<HsvaColor>) => void] {
const {editing} = useEditing()
const [editingValue, setEditingValue] = useState<T>(color)
// Save onChange callbacks in refs for to avoid unnecessarily updating when parent doesn't use useCallback
const onTemporarilyChangeCallback = useEventCallback<T>(onTemporarilyChange)
const onPermanentlyChangeCallback = useEventCallback<T>(onPermanentlyChange)
// If editing, be uncontrolled, if not editing, be controlled
let value = editing ? editingValue : color
// No matter which color model is used (HEX, RGB(A) or HSL(A)),
// all internal calculations are in HSVA
const [hsva, updateHsva] = useState<HsvaColor>(() => colorModel.toHsva(value))
// Use refs to prevent infinite update loops. They basically serve as a more
// explicit hack around the rigidity of React hooks' dep lists, since we want
// to do all color equality checks in HSVA, without breaking the roles of hooks.
// We use separate refs for temporary updates and permanent updates,
// since they are two independent update models.
const tempCache = useRef({color: value, hsva})
const permCache = useRef({color: value, hsva})
// When entering editing mode, set the internal state of the uncontrolled mode
// to the last value of the controlled mode.
useEffect(() => {
if (editing) {
setEditingValue(tempCache.current.color)
}
}, [editing])
// Trigger `on*Change` callbacks only if an updated color is different from
// the cached one; save the new color to the ref to prevent unnecessary updates.
useEffect(() => {
let newColor = colorModel.fromHsva(hsva)
if (editing) {
if (
!equalColorObjects(hsva, tempCache.current.hsva) &&
!colorModel.equal(newColor, tempCache.current.color)
) {
console.log('hsva', hsva)
console.log('hsva cache', tempCache.current.hsva)
tempCache.current = {hsva, color: newColor}
setEditingValue(newColor)
onTemporarilyChangeCallback(newColor)
}
} else {
if (
!equalColorObjects(hsva, permCache.current.hsva) &&
!colorModel.equal(newColor, permCache.current.color)
) {
permCache.current = {hsva, color: newColor}
tempCache.current = {hsva, color: newColor}
onPermanentlyChangeCallback(newColor)
}
}
}, [
editing,
hsva,
colorModel,
onTemporarilyChangeCallback,
onPermanentlyChangeCallback,
])
// This has to come after the callback calling effect, so that the cache isn't
// updated before the above effect checks for equality, otherwise no updates would
// be issued.
// Note: it doesn't make sense to have an editing version of this effect because
// the callback calling effect already updates the caches.
useEffect(() => {
if (!editing) {
if (!colorModel.equal(value, permCache.current.color)) {
const newHsva = colorModel.toHsva(value)
permCache.current = {hsva: newHsva, color: value}
updateHsva(newHsva)
}
}
}, [editing, value, colorModel])
// Merge the current HSVA color object with updated params.
// For example, when a child component sends `h` or `s` only
const handleChange = useCallback((params: Partial<HsvaColor>) => {
updateHsva((current) => Object.assign({}, current, params))
}, [])
return [hsva, handleChange]
}

View file

@ -0,0 +1,14 @@
import {useRef} from 'react'
// Saves incoming handler to the ref in order to avoid "useCallback hell"
export function useEventCallback<T>(
handler?: (value: T) => void,
): (value: T) => void {
const callbackRef = useRef(handler)
const fn = useRef((value: T) => {
callbackRef.current && callbackRef.current(value)
})
callbackRef.current = handler
return fn.current
}

View file

@ -0,0 +1,7 @@
import {useLayoutEffect, useEffect} from 'react'
// React currently throws a warning when using useLayoutEffect on the server.
// To get around it, we can conditionally useEffect on the server (no-op) and
// useLayoutEffect in the browser.
export const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect

View file

@ -0,0 +1,11 @@
export {RgbaColorPicker} from './components/RgbaColorPicker'
// Color model types
export type {
RgbColor,
RgbaColor,
HslColor,
HslaColor,
HsvColor,
HsvaColor,
} from './types'

View file

@ -0,0 +1,73 @@
import type React from 'react'
export interface RgbColor {
r: number
g: number
b: number
}
export interface RgbaColor extends RgbColor {
a: number
}
export interface HslColor {
h: number
s: number
l: number
}
export interface HslaColor extends HslColor {
a: number
}
export interface HsvColor {
h: number
s: number
v: number
}
export interface HsvaColor extends HsvColor {
a: number
}
export type ObjectColor =
| RgbColor
| HslColor
| HsvColor
| RgbaColor
| HslaColor
| HsvaColor
export type AnyColor = string | ObjectColor
export interface ColorModel<T extends AnyColor> {
defaultColor: T
toHsva: (defaultColor: T) => HsvaColor
fromHsva: (hsva: HsvaColor) => T
equal: (first: T, second: T) => boolean
}
type ColorPickerHTMLAttributes = Partial<
Omit<
React.HTMLAttributes<HTMLDivElement>,
'color' | 'onChange' | 'onChangeCapture'
>
>
export interface ColorPickerBaseProps<T extends AnyColor>
extends ColorPickerHTMLAttributes {
color: T
temporarilySetValue: (newColor: T) => void
permanentlySetValue: (newColor: T) => void
discardTemporaryValue: () => void
}
type ColorInputHTMLAttributes = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'onChange' | 'value'
>
export interface ColorInputBaseProps extends ColorInputHTMLAttributes {
color?: string
onChange?: (newColor: string) => void
}

View file

@ -0,0 +1,6 @@
// 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
}

View file

@ -0,0 +1,35 @@
import {hexToRgba} from './convert'
import type {ObjectColor} from '@theatre/studio/uiComponents/colorPicker/types'
export const equalColorObjects = (
first: ObjectColor,
second: ObjectColor,
): boolean => {
if (first === second) return true
for (const prop in first) {
// The following allows for a type-safe calling of this function (first & second have to be HSL, HSV, or RGB)
// with type-unsafe iterating over object keys. TS does not allow this without an index (`[key: string]: number`)
// on an object to define how iteration is normally done. To ensure extra keys are not allowed on our types,
// we must cast our object to unknown (as RGB demands `r` be a key, while `Record<string, x>` does not care if
// there is or not), and then as a type TS can iterate over.
if (
(first as unknown as Record<string, number>)[prop] !==
(second as unknown as Record<string, number>)[prop]
)
return false
}
return true
}
export const equalColorString = (first: string, second: string): boolean => {
return first.replace(/\s/g, '') === second.replace(/\s/g, '')
}
export const equalHex = (first: string, second: string): boolean => {
if (first.toLowerCase() === second.toLowerCase()) return true
// To compare colors like `#FFF` and `ffffff` we convert them into RGB objects
return equalColorObjects(hexToRgba(first), hexToRgba(second))
}

View file

@ -0,0 +1,220 @@
import {round} from './round'
import type {
RgbaColor,
RgbColor,
HslaColor,
HslColor,
HsvaColor,
HsvColor,
} from '@theatre/studio/uiComponents/colorPicker/types'
/**
* Valid CSS <angle> units.
* https://developer.mozilla.org/en-US/docs/Web/CSS/angle
*/
const angleUnits: Record<string, number> = {
grad: 360 / 400,
turn: 360,
rad: 360 / (Math.PI * 2),
}
export const hexToHsva = (hex: string): HsvaColor => rgbaToHsva(hexToRgba(hex))
export const hexToRgba = (hex: string): RgbaColor => {
if (hex[0] === '#') hex = hex.substr(1)
if (hex.length < 6) {
return {
r: parseInt(hex[0] + hex[0], 16),
g: parseInt(hex[1] + hex[1], 16),
b: parseInt(hex[2] + hex[2], 16),
a: 1,
}
}
return {
r: parseInt(hex.substr(0, 2), 16),
g: parseInt(hex.substr(2, 2), 16),
b: parseInt(hex.substr(4, 2), 16),
a: 1,
}
}
export const parseHue = (value: string, unit = 'deg'): number => {
return Number(value) * (angleUnits[unit] || 1)
}
export const hslaStringToHsva = (hslString: string): HsvaColor => {
const matcher =
/hsla?\(?\s*(-?\d*\.?\d+)(deg|rad|grad|turn)?[,\s]+(-?\d*\.?\d+)%?[,\s]+(-?\d*\.?\d+)%?,?\s*[/\s]*(-?\d*\.?\d+)?(%)?\s*\)?/i
const match = matcher.exec(hslString)
if (!match) return {h: 0, s: 0, v: 0, a: 1}
return hslaToHsva({
h: parseHue(match[1], match[2]),
s: Number(match[3]),
l: Number(match[4]),
a: match[5] === undefined ? 1 : Number(match[5]) / (match[6] ? 100 : 1),
})
}
export const hslStringToHsva = hslaStringToHsva
export const hslaToHsva = ({h, s, l, a}: HslaColor): HsvaColor => {
s *= (l < 50 ? l : 100 - l) / 100
return {
h: h,
s: s > 0 ? ((2 * s) / (l + s)) * 100 : 0,
v: l + s,
a,
}
}
export const hsvaToHex = (hsva: HsvaColor): string =>
rgbaToHex(hsvaToRgba(hsva))
export const hsvaToHsla = ({h, s, v, a}: HsvaColor): HslaColor => {
const hh = ((200 - s) * v) / 100
return {
h: round(h),
s: round(
hh > 0 && hh < 200
? ((s * v) / 100 / (hh <= 100 ? hh : 200 - hh)) * 100
: 0,
),
l: round(hh / 2),
a: round(a, 2),
}
}
export const hsvaToHslString = (hsva: HsvaColor): string => {
const {h, s, l} = hsvaToHsla(hsva)
return `hsl(${h}, ${s}%, ${l}%)`
}
export const hsvaToHsvString = (hsva: HsvaColor): string => {
const {h, s, v} = roundHsva(hsva)
return `hsv(${h}, ${s}%, ${v}%)`
}
export const hsvaToHsvaString = (hsva: HsvaColor): string => {
const {h, s, v, a} = roundHsva(hsva)
return `hsva(${h}, ${s}%, ${v}%, ${a})`
}
export const hsvaToHslaString = (hsva: HsvaColor): string => {
const {h, s, l, a} = hsvaToHsla(hsva)
return `hsla(${h}, ${s}%, ${l}%, ${a})`
}
export const hsvaToRgba = ({h, s, v, a}: HsvaColor): RgbaColor => {
h = (h / 360) * 6
s = s / 100
v = v / 100
const hh = Math.floor(h),
b = v * (1 - s),
c = v * (1 - (h - hh) * s),
d = v * (1 - (1 - h + hh) * s),
module = hh % 6
return {
r: round([v, c, b, b, d, v][module] * 255),
g: round([d, v, v, c, b, b][module] * 255),
b: round([b, b, d, v, v, c][module] * 255),
a: round(a, 2),
}
}
export const hsvaToRgbString = (hsva: HsvaColor): string => {
const {r, g, b} = hsvaToRgba(hsva)
return `rgb(${r}, ${g}, ${b})`
}
export const hsvaToRgbaString = (hsva: HsvaColor): string => {
const {r, g, b, a} = hsvaToRgba(hsva)
return `rgba(${r}, ${g}, ${b}, ${a})`
}
export const hsvaStringToHsva = (hsvString: string): HsvaColor => {
const matcher =
/hsva?\(?\s*(-?\d*\.?\d+)(deg|rad|grad|turn)?[,\s]+(-?\d*\.?\d+)%?[,\s]+(-?\d*\.?\d+)%?,?\s*[/\s]*(-?\d*\.?\d+)?(%)?\s*\)?/i
const match = matcher.exec(hsvString)
if (!match) return {h: 0, s: 0, v: 0, a: 1}
return roundHsva({
h: parseHue(match[1], match[2]),
s: Number(match[3]),
v: Number(match[4]),
a: match[5] === undefined ? 1 : Number(match[5]) / (match[6] ? 100 : 1),
})
}
export const hsvStringToHsva = hsvaStringToHsva
export const rgbaStringToHsva = (rgbaString: string): HsvaColor => {
const matcher =
/rgba?\(?\s*(-?\d*\.?\d+)(%)?[,\s]+(-?\d*\.?\d+)(%)?[,\s]+(-?\d*\.?\d+)(%)?,?\s*[/\s]*(-?\d*\.?\d+)?(%)?\s*\)?/i
const match = matcher.exec(rgbaString)
if (!match) return {h: 0, s: 0, v: 0, a: 1}
return rgbaToHsva({
r: Number(match[1]) / (match[2] ? 100 / 255 : 1),
g: Number(match[3]) / (match[4] ? 100 / 255 : 1),
b: Number(match[5]) / (match[6] ? 100 / 255 : 1),
a: match[7] === undefined ? 1 : Number(match[7]) / (match[8] ? 100 : 1),
})
}
export const rgbStringToHsva = rgbaStringToHsva
const format = (number: number) => {
const hex = number.toString(16)
return hex.length < 2 ? '0' + hex : hex
}
export const rgbaToHex = ({r, g, b}: RgbaColor): string => {
return '#' + format(r) + format(g) + format(b)
}
export const rgbaToHsva = ({r, g, b, a}: RgbaColor): HsvaColor => {
const max = Math.max(r, g, b)
const delta = max - Math.min(r, g, b)
// prettier-ignore
const hh = delta
? max === r
? (g - b) / delta
: max === g
? 2 + (b - r) / delta
: 4 + (r - g) / delta
: 0;
return {
h: round(60 * (hh < 0 ? hh + 6 : hh)),
s: round(max ? (delta / max) * 100 : 0),
v: round((max / 255) * 100),
a,
}
}
export const roundHsva = (hsva: HsvaColor): HsvaColor => ({
h: round(hsva.h),
s: round(hsva.s),
v: round(hsva.v),
a: round(hsva.a, 2),
})
export const rgbaToRgb = ({r, g, b}: RgbaColor): RgbColor => ({r, g, b})
export const hslaToHsl = ({h, s, l}: HslaColor): HslColor => ({h, s, l})
export const hsvaToHsv = (hsva: HsvaColor): HsvColor => {
const {h, s, v} = roundHsva(hsva)
return {h, s, v}
}

View file

@ -0,0 +1,7 @@
export const round = (
number: number,
digits = 0,
base = Math.pow(10, digits),
): number => {
return Math.round(base * number) / base
}

View file

@ -0,0 +1,13 @@
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
)
}

View file

@ -180,7 +180,7 @@ const BasicStringInput: React.FC<{
<Input
key="input"
type="text"
className={!isValid(value) ? 'invalid' : ''}
className={`${props.className ?? ''} ${!isValid(value) ? 'invalid' : ''}`}
onChange={callbacks.inputChange}
value={value}
onBlur={callbacks.onBlur}

View file

@ -19940,6 +19940,16 @@ fsevents@^1.2.7:
languageName: node
linkType: hard
"react-colorful@npm:^5.5.1":
version: 5.5.1
resolution: "react-colorful@npm:5.5.1"
peerDependencies:
react: ">=16.8.0"
react-dom: ">=16.8.0"
checksum: 2b0beb90ab5c93c3b2a19c5a9c5c7ca9cedd5eeac61807335d4466f089cc3b3530724c0d685014a9ae0b4c40db638c8cfa27fccab6485f2d3cda00819edcceb7
languageName: node
linkType: hard
"react-dev-utils@npm:^11.0.3":
version: 11.0.4
resolution: "react-dev-utils@npm:11.0.4"
@ -22959,6 +22969,7 @@ fsevents@^1.2.7:
prop-types: ^15.7.2
propose: ^0.0.5
react: ^17.0.2
react-colorful: ^5.5.1
react-dom: ^17.0.2
react-error-boundary: ^3.1.3
react-icons: ^4.2.0