Skip to content

Commit

Permalink
Create color prop (#64)
Browse files Browse the repository at this point in the history
Authored by andrew@theatrejs.com
  • Loading branch information
AndrewPrifer committed Feb 19, 2022
1 parent b643739 commit defb538
Show file tree
Hide file tree
Showing 32 changed files with 1,647 additions and 76 deletions.
1 change: 1 addition & 0 deletions credits.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The color picker is a fork of https://github.com/omgovich/react-colorful
14 changes: 14 additions & 0 deletions packages/playground/src/shared/dom/Scene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand All @@ -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(() => {
Expand Down Expand Up @@ -75,6 +82,7 @@ const Box: React.FC<{
test: initial.test,
testLiteral: initial.testLiteral,
bool: initial.bool,
color: initial.color,
})
})
},
Expand Down Expand Up @@ -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>
)
}
Expand Down
4 changes: 3 additions & 1 deletion theatre/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@
Simply update the version of `theatre/package.json`, then run `$ yarn run release`. This script will:
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.
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.
3 changes: 2 additions & 1 deletion theatre/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
108 changes: 108 additions & 0 deletions theatre/core/src/propTypes/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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
}

/**
*
*/
Expand All @@ -492,6 +599,7 @@ export type PropTypeConfig_AllPrimitives =
| PropTypeConfig_Boolean
| PropTypeConfig_String
| PropTypeConfig_StringLiteral<$IntentionalAny>
| PropTypeConfig_Rgba

export type PropTypeConfig =
| PropTypeConfig_AllPrimitives
Expand Down
4 changes: 3 additions & 1 deletion theatre/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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."
}
120 changes: 120 additions & 0 deletions theatre/shared/src/utils/color.ts
Original file line number Diff line number Diff line change
@@ -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
}
33 changes: 32 additions & 1 deletion theatre/shared/src/utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion theatre/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
Loading

0 comments on commit defb538

Please sign in to comment.