diff --git a/credits.txt b/credits.txt
new file mode 100644
index 0000000..cea6800
--- /dev/null
+++ b/credits.txt
@@ -0,0 +1 @@
+The color picker is a fork of https://github.com/omgovich/react-colorful
\ No newline at end of file
diff --git a/packages/playground/src/shared/dom/Scene.tsx b/packages/playground/src/shared/dom/Scene.tsx
index 428ed52..6221556 100644
--- a/packages/playground/src/shared/dom/Scene.tsx
+++ b/packages/playground/src/shared/dom/Scene.tsx
@@ -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<{
{JSON.stringify(state, null, 4)}
+
)
}
diff --git a/theatre/README.md b/theatre/README.md
index fabf165..b7278bf 100644
--- a/theatre/README.md
+++ b/theatre/README.md
@@ -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.
\ No newline at end of file
+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.
\ No newline at end of file
diff --git a/theatre/core/package.json b/theatre/core/package.json
index b34b0c0..9e3766e 100644
--- a/theatre/core/package.json
+++ b/theatre/core/package.json
@@ -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."
}
diff --git a/theatre/core/src/propTypes/index.ts b/theatre/core/src/propTypes/index.ts
index 4840e67..aa2701c 100644
--- a/theatre/core/src/propTypes/index.ts
+++ b/theatre/core/src/propTypes/index.ts
@@ -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
as: 'menu' | 'switch'
}
+export interface PropTypeConfig_Rgba extends IBasePropType {
+ 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
diff --git a/theatre/package.json b/theatre/package.json
index 3c2284d..8e88077 100644
--- a/theatre/package.json
+++ b/theatre/package.json
@@ -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."
}
diff --git a/theatre/shared/src/utils/color.ts b/theatre/shared/src/utils/color.ts
new file mode 100644
index 0000000..696f12f
--- /dev/null
+++ b/theatre/shared/src/utils/color.ts
@@ -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
+}
diff --git a/theatre/shared/src/utils/types.ts b/theatre/shared/src/utils/types.ts
index cda2c3b..82c2c9c 100644
--- a/theatre/shared/src/utils/types.ts
+++ b/theatre/shared/src/utils/types.ts
@@ -11,7 +11,38 @@ export type SerializableMap<
Primitives extends SerializablePrimitive = SerializablePrimitive,
> = {[Key in string]?: SerializableValue}
-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 extends {type: 'compound'} ? never : T;
+ *
+ * const badStuff = {
+ * type: 'compound',
+ * foo: 3,
+ * } as const
+ *
+ * const goodStuff = {
+ * type: 'literallyanythingelse',
+ * foo: 3,
+ * } as const
+ *
+ * function serializeStuff(giveMeStuff: SerializablePrimitive) {
+ * // ...
+ * }
+ *
+ * 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,
diff --git a/theatre/studio/package.json b/theatre/studio/package.json
index dd1883e..a9e0221 100644
--- a/theatre/studio/package.json
+++ b/theatre/studio/package.json
@@ -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."
}
diff --git a/theatre/studio/src/StudioStore/StudioStore.ts b/theatre/studio/src/StudioStore/StudioStore.ts
index 13ca49e..4b4dc17 100644
--- a/theatre/studio/src/StudioStore/StudioStore.ts
+++ b/theatre/studio/src/StudioStore/StudioStore.ts
@@ -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
@@ -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,46 +145,65 @@ export default class StudioStore {
.getValue(),
)
- forEachDeep(
- value,
- (v, pathToProp) => {
- if (typeof v === 'undefined' || v === null) {
- return
- }
+ const propConfig = getPropConfigByPath(
+ root.template.config,
+ path,
+ ) as PropTypeConfig
- const propAddress = {...root.address, pathToProp}
+ const setStaticOrKeyframeProp = (
+ value: T,
+ path: PathToProp,
+ ) => {
+ if (typeof value === 'undefined' || value === null) {
+ return
+ }
- const trackId = get(
- sequenceTracksTree,
- pathToProp,
- ) as $FixMe as SequenceTrackId | undefined
+ const propAddress = {...root.address, pathToProp: path}
- if (typeof trackId === 'string') {
- const propConfig = get(
- root.template.config.props,
- pathToProp,
- ) as PropTypeConfig | undefined
- if (propConfig?.sanitize) v = propConfig.sanitize(v)
+ const trackId = get(sequenceTracksTree, path) as $FixMe as
+ | SequenceTrackId
+ | undefined
+ if (typeof trackId === 'string') {
+ const propConfig = getPropConfigByPath(
+ root.template.config,
+ path,
+ ) as PropTypeConfig | undefined
+ // 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)
- stateEditors.coreByProject.historic.sheetsById.sequence.setKeyframeAtPosition(
- {
- ...propAddress,
- trackId,
- position: seq.position,
- value: v as $FixMe,
- snappingFunction: seq.closestGridPosition,
- },
- )
- } else {
- stateEditors.coreByProject.historic.sheetsById.staticOverrides.byObject.setValueOfPrimitiveProp(
- {...propAddress, value: v},
- )
- }
- },
- getPointerParts(pointer as Pointer<$IntentionalAny>).path,
- )
+ const seq = root.sheet.getSequence()
+ seq.position = seq.closestGridPosition(seq.position)
+ stateEditors.coreByProject.historic.sheetsById.sequence.setKeyframeAtPosition(
+ {
+ ...propAddress,
+ trackId,
+ position: seq.position,
+ value: value as $FixMe,
+ snappingFunction: seq.closestGridPosition,
+ },
+ )
+ } else if (propConfig !== undefined) {
+ stateEditors.coreByProject.historic.sheetsById.staticOverrides.byObject.setValueOfPrimitiveProp(
+ {...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 = (
+ value: T,
+ path: PathToProp,
+ ) => {
+ const propAddress = {...root.address, pathToProp: path}
- if (typeof trackId === 'string') {
- stateEditors.coreByProject.historic.sheetsById.sequence.unsetKeyframeAtPosition(
- {
- ...propAddress,
- trackId,
- position:
- root.sheet.getSequence().positionSnappedToGrid,
- },
- )
- } else {
- stateEditors.coreByProject.historic.sheetsById.staticOverrides.byObject.unsetValueOfPrimitiveProp(
- propAddress,
- )
- }
- },
- getPointerParts(pointer as Pointer<$IntentionalAny>).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,
+ },
+ )
+ } 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',
diff --git a/theatre/studio/src/panels/DetailPanel/propEditors/DeterminePropEditor.tsx b/theatre/studio/src/panels/DetailPanel/propEditors/DeterminePropEditor.tsx
index ca5a2be..c049a8b 100644
--- a/theatre/studio/src/panels/DetailPanel/propEditors/DeterminePropEditor.tsx
+++ b/theatre/studio/src/panels/DetailPanel/propEditors/DeterminePropEditor.tsx
@@ -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<{
diff --git a/theatre/studio/src/panels/DetailPanel/propEditors/RgbaPropEditor.tsx b/theatre/studio/src/panels/DetailPanel/propEditors/RgbaPropEditor.tsx
new file mode 100644
index 0000000..010b6e9
--- /dev/null
+++ b/theatre/studio/src/panels/DetailPanel/propEditors/RgbaPropEditor.tsx
@@ -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((props) => ({
+ style: {
+ background: props.background,
+ },
+}))`
+ 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(null!)
+
+ const stuff = useEditingToolsForPrimitiveProp(
+ pointerToProp,
+ obj,
+ propConfig,
+ )
+
+ const onChange = useCallback(
+ (color: string) => {
+ const rgba = decorateRgba(parseRgbaFromHex(color))
+ stuff.permenantlySetValue(rgba)
+ },
+ [stuff],
+ )
+
+ const [popoverNode, openPopover] = usePopover({}, () => {
+ return (
+
+
+ {
+ const rgba = decorateRgba(color)
+ stuff.temporarilySetValue(rgba)
+ }}
+ permanentlySetValue={(color) => {
+ // console.log('perm')
+ const rgba = decorateRgba(color)
+ stuff.permenantlySetValue(rgba)
+ }}
+ discardTemporaryValue={stuff.discardTemporaryValue}
+ />
+
+
+ )
+ })
+
+ return (
+
+
+ {
+ openPopover(e, containerRef.current)
+ }}
+ />
+ !!v.match(/^#?([0-9a-f]{8})$/i)}
+ />
+
+ {popoverNode}
+
+ )
+}
+
+export default RgbaPropEditor
diff --git a/theatre/studio/src/uiComponents/colorPicker/components/EditingProvider.tsx b/theatre/studio/src/uiComponents/colorPicker/components/EditingProvider.tsx
new file mode 100644
index 0000000..dc08bf9
--- /dev/null
+++ b/theatre/studio/src/uiComponents/colorPicker/components/EditingProvider.tsx
@@ -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 (
+
+ {children}
+
+ )
+}
+
+export const useEditing = () => useContext(editingContext)
diff --git a/theatre/studio/src/uiComponents/colorPicker/components/RgbaColorPicker.tsx b/theatre/studio/src/uiComponents/colorPicker/components/RgbaColorPicker.tsx
new file mode 100644
index 0000000..fe4f35b
--- /dev/null
+++ b/theatre/studio/src/uiComponents/colorPicker/components/RgbaColorPicker.tsx
@@ -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 = {
+ 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,
+): JSX.Element => (
+
+ {
+ props.permanentlySetValue!(newColor)
+ }}
+ colorModel={colorModel}
+ />
+
+)
diff --git a/theatre/studio/src/uiComponents/colorPicker/components/common/Alpha.tsx b/theatre/studio/src/uiComponents/colorPicker/components/common/Alpha.tsx
new file mode 100644
index 0000000..af58768
--- /dev/null
+++ b/theatre/studio/src/uiComponents/colorPicker/components/common/Alpha.tsx
@@ -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,');
+`
+
+interface GradientProps {
+ colorFrom: string
+ colorTo: string
+}
+
+const Gradient = styled.div.attrs(({colorFrom, colorTo}) => ({
+ style: {
+ backgroundImage: `linear-gradient(90deg, ${colorFrom}, ${colorTo})`,
+ },
+}))`
+ 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,');
+`
+
+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 (
+
+
+
+
+
+
+ )
+}
diff --git a/theatre/studio/src/uiComponents/colorPicker/components/common/AlphaColorPicker.tsx b/theatre/studio/src/uiComponents/colorPicker/components/common/AlphaColorPicker.tsx
new file mode 100644
index 0000000..79aff98
--- /dev/null
+++ b/theatre/studio/src/uiComponents/colorPicker/components/common/AlphaColorPicker.tsx
@@ -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 extends ColorPickerBaseProps {
+ colorModel: ColorModel
+}
+
+export const AlphaColorPicker = ({
+ className,
+ colorModel,
+ color = colorModel.defaultColor,
+ temporarilySetValue,
+ permanentlySetValue,
+ discardTemporaryValue,
+ ...rest
+}: Props): JSX.Element => {
+ const [tempHsva, updateHsva] = useColorManipulation(
+ colorModel,
+ color,
+ temporarilySetValue,
+ permanentlySetValue,
+ )
+
+ useEffect(() => {
+ return () => {
+ discardTemporaryValue()
+ }
+ }, [])
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/theatre/studio/src/uiComponents/colorPicker/components/common/Hue.tsx b/theatre/studio/src/uiComponents/colorPicker/components/common/Hue.tsx
new file mode 100644
index 0000000..a32c0f0
--- /dev/null
+++ b/theatre/studio/src/uiComponents/colorPicker/components/common/Hue.tsx
@@ -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 (
+
+
+
+
+
+ )
+}
+
+export const Hue = React.memo(HueBase)
diff --git a/theatre/studio/src/uiComponents/colorPicker/components/common/Interactive.tsx b/theatre/studio/src/uiComponents/colorPicker/components/common/Interactive.tsx
new file mode 100644
index 0000000..48addf7
--- /dev/null
+++ b/theatre/studio/src/uiComponents/colorPicker/components/common/Interactive.tsx
@@ -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(null)
+ const onMoveCallback = useEventCallback(onMove)
+ const onKeyCallback = useEventCallback(onKey)
+ const touchId = useRef(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 (
+
+ )
+}
+
+export const Interactive = React.memo(InteractiveBase)
diff --git a/theatre/studio/src/uiComponents/colorPicker/components/common/Pointer.tsx b/theatre/studio/src/uiComponents/colorPicker/components/common/Pointer.tsx
new file mode 100644
index 0000000..66e663f
--- /dev/null
+++ b/theatre/studio/src/uiComponents/colorPicker/components/common/Pointer.tsx
@@ -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 (
+
+
+
+ )
+}
diff --git a/theatre/studio/src/uiComponents/colorPicker/components/common/Saturation.tsx b/theatre/studio/src/uiComponents/colorPicker/components/common/Saturation.tsx
new file mode 100644
index 0000000..0ec2852
--- /dev/null
+++ b/theatre/studio/src/uiComponents/colorPicker/components/common/Saturation.tsx
@@ -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 (
+
+
+
+
+
+ )
+}
+
+export const Saturation = React.memo(SaturationBase)
diff --git a/theatre/studio/src/uiComponents/colorPicker/hooks/useColorManipulation.ts b/theatre/studio/src/uiComponents/colorPicker/hooks/useColorManipulation.ts
new file mode 100644
index 0000000..d708ada
--- /dev/null
+++ b/theatre/studio/src/uiComponents/colorPicker/hooks/useColorManipulation.ts
@@ -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(
+ colorModel: ColorModel,
+ color: T,
+ onTemporarilyChange: (color: T) => void,
+ onPermanentlyChange: (color: T) => void,
+): [HsvaColor, (color: Partial) => void] {
+ const {editing} = useEditing()
+ const [editingValue, setEditingValue] = useState(color)
+
+ // Save onChange callbacks in refs for to avoid unnecessarily updating when parent doesn't use useCallback
+ const onTemporarilyChangeCallback = useEventCallback(onTemporarilyChange)
+ const onPermanentlyChangeCallback = useEventCallback(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(() => 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) => {
+ updateHsva((current) => Object.assign({}, current, params))
+ }, [])
+
+ return [hsva, handleChange]
+}
diff --git a/theatre/studio/src/uiComponents/colorPicker/hooks/useEventCallback.ts b/theatre/studio/src/uiComponents/colorPicker/hooks/useEventCallback.ts
new file mode 100644
index 0000000..5ddec2d
--- /dev/null
+++ b/theatre/studio/src/uiComponents/colorPicker/hooks/useEventCallback.ts
@@ -0,0 +1,14 @@
+import {useRef} from 'react'
+
+// Saves incoming handler to the ref in order to avoid "useCallback hell"
+export function useEventCallback(
+ 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
+}
diff --git a/theatre/studio/src/uiComponents/colorPicker/hooks/useIsomorphicLayoutEffect.ts b/theatre/studio/src/uiComponents/colorPicker/hooks/useIsomorphicLayoutEffect.ts
new file mode 100644
index 0000000..105fa5a
--- /dev/null
+++ b/theatre/studio/src/uiComponents/colorPicker/hooks/useIsomorphicLayoutEffect.ts
@@ -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
diff --git a/theatre/studio/src/uiComponents/colorPicker/index.ts b/theatre/studio/src/uiComponents/colorPicker/index.ts
new file mode 100644
index 0000000..a1aedb7
--- /dev/null
+++ b/theatre/studio/src/uiComponents/colorPicker/index.ts
@@ -0,0 +1,11 @@
+export {RgbaColorPicker} from './components/RgbaColorPicker'
+
+// Color model types
+export type {
+ RgbColor,
+ RgbaColor,
+ HslColor,
+ HslaColor,
+ HsvColor,
+ HsvaColor,
+} from './types'
diff --git a/theatre/studio/src/uiComponents/colorPicker/types.ts b/theatre/studio/src/uiComponents/colorPicker/types.ts
new file mode 100644
index 0000000..3b0f1d4
--- /dev/null
+++ b/theatre/studio/src/uiComponents/colorPicker/types.ts
@@ -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 {
+ defaultColor: T
+ toHsva: (defaultColor: T) => HsvaColor
+ fromHsva: (hsva: HsvaColor) => T
+ equal: (first: T, second: T) => boolean
+}
+
+type ColorPickerHTMLAttributes = Partial<
+ Omit<
+ React.HTMLAttributes,
+ 'color' | 'onChange' | 'onChangeCapture'
+ >
+>
+
+export interface ColorPickerBaseProps
+ extends ColorPickerHTMLAttributes {
+ color: T
+ temporarilySetValue: (newColor: T) => void
+ permanentlySetValue: (newColor: T) => void
+ discardTemporaryValue: () => void
+}
+
+type ColorInputHTMLAttributes = Omit<
+ React.InputHTMLAttributes,
+ 'onChange' | 'value'
+>
+
+export interface ColorInputBaseProps extends ColorInputHTMLAttributes {
+ color?: string
+ onChange?: (newColor: string) => void
+}
diff --git a/theatre/studio/src/uiComponents/colorPicker/utils/clamp.ts b/theatre/studio/src/uiComponents/colorPicker/utils/clamp.ts
new file mode 100644
index 0000000..00fc430
--- /dev/null
+++ b/theatre/studio/src/uiComponents/colorPicker/utils/clamp.ts
@@ -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
+}
diff --git a/theatre/studio/src/uiComponents/colorPicker/utils/compare.ts b/theatre/studio/src/uiComponents/colorPicker/utils/compare.ts
new file mode 100644
index 0000000..a9152ea
--- /dev/null
+++ b/theatre/studio/src/uiComponents/colorPicker/utils/compare.ts
@@ -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` does not care if
+ // there is or not), and then as a type TS can iterate over.
+ if (
+ (first as unknown as Record)[prop] !==
+ (second as unknown as Record)[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))
+}
diff --git a/theatre/studio/src/uiComponents/colorPicker/utils/convert.ts b/theatre/studio/src/uiComponents/colorPicker/utils/convert.ts
new file mode 100644
index 0000000..04aacb4
--- /dev/null
+++ b/theatre/studio/src/uiComponents/colorPicker/utils/convert.ts
@@ -0,0 +1,220 @@
+import {round} from './round'
+import type {
+ RgbaColor,
+ RgbColor,
+ HslaColor,
+ HslColor,
+ HsvaColor,
+ HsvColor,
+} from '@theatre/studio/uiComponents/colorPicker/types'
+
+/**
+ * Valid CSS units.
+ * https://developer.mozilla.org/en-US/docs/Web/CSS/angle
+ */
+const angleUnits: Record = {
+ 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}
+}
diff --git a/theatre/studio/src/uiComponents/colorPicker/utils/round.ts b/theatre/studio/src/uiComponents/colorPicker/utils/round.ts
new file mode 100644
index 0000000..b8ea771
--- /dev/null
+++ b/theatre/studio/src/uiComponents/colorPicker/utils/round.ts
@@ -0,0 +1,7 @@
+export const round = (
+ number: number,
+ digits = 0,
+ base = Math.pow(10, digits),
+): number => {
+ return Math.round(base * number) / base
+}
diff --git a/theatre/studio/src/uiComponents/colorPicker/utils/validate.ts b/theatre/studio/src/uiComponents/colorPicker/utils/validate.ts
new file mode 100644
index 0000000..a8c6a14
--- /dev/null
+++ b/theatre/studio/src/uiComponents/colorPicker/utils/validate.ts
@@ -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
+ )
+}
diff --git a/theatre/studio/src/uiComponents/form/BasicStringInput.tsx b/theatre/studio/src/uiComponents/form/BasicStringInput.tsx
index a41596c..d17fade 100644
--- a/theatre/studio/src/uiComponents/form/BasicStringInput.tsx
+++ b/theatre/studio/src/uiComponents/form/BasicStringInput.tsx
@@ -180,7 +180,7 @@ const BasicStringInput: React.FC<{
=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