parent
b643739ec7
commit
defb538561
32 changed files with 1641 additions and 70 deletions
1
credits.txt
Normal file
1
credits.txt
Normal file
|
@ -0,0 +1 @@
|
||||||
|
The color picker is a fork of https://github.com/omgovich/react-colorful
|
|
@ -24,6 +24,7 @@ const boxObjectConfig = {
|
||||||
bool: types.boolean(false),
|
bool: types.boolean(false),
|
||||||
x: types.number(200),
|
x: types.number(200),
|
||||||
y: types.number(200),
|
y: types.number(200),
|
||||||
|
color: types.rgba({r: 1, g: 0, b: 0, a: 1}),
|
||||||
}
|
}
|
||||||
|
|
||||||
const Box: React.FC<{
|
const Box: React.FC<{
|
||||||
|
@ -42,6 +43,12 @@ const Box: React.FC<{
|
||||||
test: string
|
test: string
|
||||||
testLiteral: string
|
testLiteral: string
|
||||||
bool: boolean
|
bool: boolean
|
||||||
|
color: {
|
||||||
|
r: number
|
||||||
|
g: number
|
||||||
|
b: number
|
||||||
|
a: number
|
||||||
|
}
|
||||||
}>(obj.value)
|
}>(obj.value)
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
@ -75,6 +82,7 @@ const Box: React.FC<{
|
||||||
test: initial.test,
|
test: initial.test,
|
||||||
testLiteral: initial.testLiteral,
|
testLiteral: initial.testLiteral,
|
||||||
bool: initial.bool,
|
bool: initial.bool,
|
||||||
|
color: initial.color,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
@ -111,6 +119,12 @@ const Box: React.FC<{
|
||||||
<pre style={{margin: 0, padding: '1rem'}}>
|
<pre style={{margin: 0, padding: '1rem'}}>
|
||||||
{JSON.stringify(state, null, 4)}
|
{JSON.stringify(state, null, 4)}
|
||||||
</pre>
|
</pre>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 50,
|
||||||
|
background: state.color.toString(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
1. Update the version of `@theatre/core` and `@theatre/studio` and other dependencies.
|
||||||
2. Bundle the `.js` and `.dts` files.
|
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.
|
|
@ -25,5 +25,6 @@
|
||||||
"sideEffects": true,
|
"sideEffects": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@theatre/dataverse": "workspace:*"
|
"@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."
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,13 @@
|
||||||
import type {$IntentionalAny} from '@theatre/shared/utils/types'
|
import type {$IntentionalAny} from '@theatre/shared/utils/types'
|
||||||
import userReadableTypeOfValue from '@theatre/shared/utils/userReadableTypeOfValue'
|
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 {mapValues} from 'lodash-es'
|
||||||
import type {
|
import type {
|
||||||
IShorthandCompoundProps,
|
IShorthandCompoundProps,
|
||||||
|
@ -234,6 +242,100 @@ const _interpolateNumber = (
|
||||||
return left + progression * (right - left)
|
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
|
* A boolean prop type
|
||||||
*
|
*
|
||||||
|
@ -467,6 +569,11 @@ export interface PropTypeConfig_StringLiteral<T extends string>
|
||||||
as: 'menu' | 'switch'
|
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_Boolean
|
||||||
| PropTypeConfig_String
|
| PropTypeConfig_String
|
||||||
| PropTypeConfig_StringLiteral<$IntentionalAny>
|
| PropTypeConfig_StringLiteral<$IntentionalAny>
|
||||||
|
| PropTypeConfig_Rgba
|
||||||
|
|
||||||
export type PropTypeConfig =
|
export type PropTypeConfig =
|
||||||
| PropTypeConfig_AllPrimitives
|
| PropTypeConfig_AllPrimitives
|
||||||
|
|
|
@ -68,6 +68,7 @@
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
"propose": "^0.0.5",
|
"propose": "^0.0.5",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
|
"react-colorful": "^5.5.1",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"react-error-boundary": "^3.1.3",
|
"react-error-boundary": "^3.1.3",
|
||||||
"react-icons": "^4.2.0",
|
"react-icons": "^4.2.0",
|
||||||
|
@ -95,5 +96,6 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fuzzysort": "^1.1.4"
|
"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
theatre/shared/src/utils/color.ts
Normal file
120
theatre/shared/src/utils/color.ts
Normal 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
|
||||||
|
}
|
|
@ -11,7 +11,38 @@ export type SerializableMap<
|
||||||
Primitives extends SerializablePrimitive = SerializablePrimitive,
|
Primitives extends SerializablePrimitive = SerializablePrimitive,
|
||||||
> = {[Key in string]?: SerializableValue<Primitives>}
|
> = {[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<
|
export type SerializableValue<
|
||||||
Primitives extends SerializablePrimitive = SerializablePrimitive,
|
Primitives extends SerializablePrimitive = SerializablePrimitive,
|
||||||
|
|
|
@ -30,5 +30,6 @@
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"styled-components": "^5.3.0"
|
"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."
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,6 +30,9 @@ import {isSheetObject} from '@theatre/shared/instanceTypes'
|
||||||
import type {OnDiskState} from '@theatre/core/projects/store/storeTypes'
|
import type {OnDiskState} from '@theatre/core/projects/store/storeTypes'
|
||||||
import {generateDiskStateRevision} from './generateDiskStateRevision'
|
import {generateDiskStateRevision} from './generateDiskStateRevision'
|
||||||
import type {PropTypeConfig} from '@theatre/core/propTypes'
|
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 = {
|
export type Drafts = {
|
||||||
historic: Draft<StudioHistoricState>
|
historic: Draft<StudioHistoricState>
|
||||||
|
@ -131,9 +134,10 @@ export default class StudioStore {
|
||||||
const api: ITransactionPrivateApi = {
|
const api: ITransactionPrivateApi = {
|
||||||
set: (pointer, value) => {
|
set: (pointer, value) => {
|
||||||
ensureRunning()
|
ensureRunning()
|
||||||
const {root} = getPointerParts(pointer as Pointer<$FixMe>)
|
const _value = cloneDeep(value)
|
||||||
|
const {root, path} = getPointerParts(pointer as Pointer<$FixMe>)
|
||||||
if (isSheetObject(root)) {
|
if (isSheetObject(root)) {
|
||||||
root.validateValue(pointer as Pointer<$FixMe>, value)
|
root.validateValue(pointer as Pointer<$FixMe>, _value)
|
||||||
|
|
||||||
const sequenceTracksTree = val(
|
const sequenceTracksTree = val(
|
||||||
root.template
|
root.template
|
||||||
|
@ -141,26 +145,33 @@ export default class StudioStore {
|
||||||
.getValue(),
|
.getValue(),
|
||||||
)
|
)
|
||||||
|
|
||||||
forEachDeep(
|
const propConfig = getPropConfigByPath(
|
||||||
value,
|
root.template.config,
|
||||||
(v, pathToProp) => {
|
path,
|
||||||
if (typeof v === 'undefined' || v === null) {
|
) as PropTypeConfig
|
||||||
|
|
||||||
|
const setStaticOrKeyframeProp = <T>(
|
||||||
|
value: T,
|
||||||
|
path: PathToProp,
|
||||||
|
) => {
|
||||||
|
if (typeof value === 'undefined' || value === null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const propAddress = {...root.address, pathToProp}
|
const propAddress = {...root.address, pathToProp: path}
|
||||||
|
|
||||||
const trackId = get(
|
|
||||||
sequenceTracksTree,
|
|
||||||
pathToProp,
|
|
||||||
) as $FixMe as SequenceTrackId | undefined
|
|
||||||
|
|
||||||
|
const trackId = get(sequenceTracksTree, path) as $FixMe as
|
||||||
|
| SequenceTrackId
|
||||||
|
| undefined
|
||||||
if (typeof trackId === 'string') {
|
if (typeof trackId === 'string') {
|
||||||
const propConfig = get(
|
const propConfig = getPropConfigByPath(
|
||||||
root.template.config.props,
|
root.template.config,
|
||||||
pathToProp,
|
path,
|
||||||
) as PropTypeConfig | undefined
|
) 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()
|
const seq = root.sheet.getSequence()
|
||||||
seq.position = seq.closestGridPosition(seq.position)
|
seq.position = seq.closestGridPosition(seq.position)
|
||||||
|
@ -169,18 +180,30 @@ export default class StudioStore {
|
||||||
...propAddress,
|
...propAddress,
|
||||||
trackId,
|
trackId,
|
||||||
position: seq.position,
|
position: seq.position,
|
||||||
value: v as $FixMe,
|
value: value as $FixMe,
|
||||||
snappingFunction: seq.closestGridPosition,
|
snappingFunction: seq.closestGridPosition,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else if (propConfig !== undefined) {
|
||||||
stateEditors.coreByProject.historic.sheetsById.staticOverrides.byObject.setValueOfPrimitiveProp(
|
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,
|
getPointerParts(pointer as Pointer<$IntentionalAny>).path,
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
setStaticOrKeyframeProp(_value, path)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Only setting props of SheetObject-s is supported in a transaction so far',
|
'Only setting props of SheetObject-s is supported in a transaction so far',
|
||||||
|
@ -202,33 +225,47 @@ export default class StudioStore {
|
||||||
path,
|
path,
|
||||||
)
|
)
|
||||||
|
|
||||||
forEachDeep(
|
const propConfig = getPropConfigByPath(
|
||||||
defaultValue,
|
root.template.config,
|
||||||
(v, pathToProp) => {
|
path,
|
||||||
const propAddress = {...root.address, pathToProp}
|
) as PropTypeConfig
|
||||||
|
|
||||||
const trackId = get(
|
const unsetStaticOrKeyframeProp = <T>(
|
||||||
sequenceTracksTree,
|
value: T,
|
||||||
pathToProp,
|
path: 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') {
|
if (typeof trackId === 'string') {
|
||||||
stateEditors.coreByProject.historic.sheetsById.sequence.unsetKeyframeAtPosition(
|
stateEditors.coreByProject.historic.sheetsById.sequence.unsetKeyframeAtPosition(
|
||||||
{
|
{
|
||||||
...propAddress,
|
...propAddress,
|
||||||
trackId,
|
trackId,
|
||||||
position:
|
position: root.sheet.getSequence().positionSnappedToGrid,
|
||||||
root.sheet.getSequence().positionSnappedToGrid,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else if (propConfig !== undefined) {
|
||||||
stateEditors.coreByProject.historic.sheetsById.staticOverrides.byObject.unsetValueOfPrimitiveProp(
|
stateEditors.coreByProject.historic.sheetsById.staticOverrides.byObject.unsetValueOfPrimitiveProp(
|
||||||
propAddress,
|
propAddress,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (propConfig.type === 'compound') {
|
||||||
|
forEachDeep(
|
||||||
|
defaultValue,
|
||||||
|
(v, pathToProp) => {
|
||||||
|
unsetStaticOrKeyframeProp(v, pathToProp)
|
||||||
},
|
},
|
||||||
getPointerParts(pointer as Pointer<$IntentionalAny>).path,
|
getPointerParts(pointer as Pointer<$IntentionalAny>).path,
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
unsetStaticOrKeyframeProp(defaultValue, path)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Only setting props of SheetObject-s is supported in a transaction so far',
|
'Only setting props of SheetObject-s is supported in a transaction so far',
|
||||||
|
|
|
@ -7,6 +7,7 @@ import CompoundPropEditor from './CompoundPropEditor'
|
||||||
import NumberPropEditor from './NumberPropEditor'
|
import NumberPropEditor from './NumberPropEditor'
|
||||||
import StringLiteralPropEditor from './StringLiteralPropEditor'
|
import StringLiteralPropEditor from './StringLiteralPropEditor'
|
||||||
import StringPropEditor from './StringPropEditor'
|
import StringPropEditor from './StringPropEditor'
|
||||||
|
import RgbaPropEditor from './RgbaPropEditor'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the PropTypeConfig by path. Assumes `path` is a valid prop path and that
|
* Returns the PropTypeConfig by path. Assumes `path` is a valid prop path and that
|
||||||
|
@ -69,7 +70,7 @@ const propEditorByPropType: {
|
||||||
enum: () => <></>,
|
enum: () => <></>,
|
||||||
boolean: BooleanPropEditor,
|
boolean: BooleanPropEditor,
|
||||||
stringLiteral: StringLiteralPropEditor,
|
stringLiteral: StringLiteralPropEditor,
|
||||||
// cssrgba: () => <></>,
|
rgba: RgbaPropEditor,
|
||||||
}
|
}
|
||||||
|
|
||||||
const DeterminePropEditor: React.FC<{
|
const DeterminePropEditor: React.FC<{
|
||||||
|
|
|
@ -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
|
|
@ -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)
|
|
@ -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>
|
||||||
|
)
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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)
|
|
@ -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)
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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)
|
|
@ -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]
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
11
theatre/studio/src/uiComponents/colorPicker/index.ts
Normal file
11
theatre/studio/src/uiComponents/colorPicker/index.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
export {RgbaColorPicker} from './components/RgbaColorPicker'
|
||||||
|
|
||||||
|
// Color model types
|
||||||
|
export type {
|
||||||
|
RgbColor,
|
||||||
|
RgbaColor,
|
||||||
|
HslColor,
|
||||||
|
HslaColor,
|
||||||
|
HsvColor,
|
||||||
|
HsvaColor,
|
||||||
|
} from './types'
|
73
theatre/studio/src/uiComponents/colorPicker/types.ts
Normal file
73
theatre/studio/src/uiComponents/colorPicker/types.ts
Normal 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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
35
theatre/studio/src/uiComponents/colorPicker/utils/compare.ts
Normal file
35
theatre/studio/src/uiComponents/colorPicker/utils/compare.ts
Normal 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))
|
||||||
|
}
|
220
theatre/studio/src/uiComponents/colorPicker/utils/convert.ts
Normal file
220
theatre/studio/src/uiComponents/colorPicker/utils/convert.ts
Normal 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}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
export const round = (
|
||||||
|
number: number,
|
||||||
|
digits = 0,
|
||||||
|
base = Math.pow(10, digits),
|
||||||
|
): number => {
|
||||||
|
return Math.round(base * number) / base
|
||||||
|
}
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
|
@ -180,7 +180,7 @@ const BasicStringInput: React.FC<{
|
||||||
<Input
|
<Input
|
||||||
key="input"
|
key="input"
|
||||||
type="text"
|
type="text"
|
||||||
className={!isValid(value) ? 'invalid' : ''}
|
className={`${props.className ?? ''} ${!isValid(value) ? 'invalid' : ''}`}
|
||||||
onChange={callbacks.inputChange}
|
onChange={callbacks.inputChange}
|
||||||
value={value}
|
value={value}
|
||||||
onBlur={callbacks.onBlur}
|
onBlur={callbacks.onBlur}
|
||||||
|
|
11
yarn.lock
11
yarn.lock
|
@ -19940,6 +19940,16 @@ fsevents@^1.2.7:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"react-dev-utils@npm:^11.0.3":
|
||||||
version: 11.0.4
|
version: 11.0.4
|
||||||
resolution: "react-dev-utils@npm:11.0.4"
|
resolution: "react-dev-utils@npm:11.0.4"
|
||||||
|
@ -22959,6 +22969,7 @@ fsevents@^1.2.7:
|
||||||
prop-types: ^15.7.2
|
prop-types: ^15.7.2
|
||||||
propose: ^0.0.5
|
propose: ^0.0.5
|
||||||
react: ^17.0.2
|
react: ^17.0.2
|
||||||
|
react-colorful: ^5.5.1
|
||||||
react-dom: ^17.0.2
|
react-dom: ^17.0.2
|
||||||
react-error-boundary: ^3.1.3
|
react-error-boundary: ^3.1.3
|
||||||
react-icons: ^4.2.0
|
react-icons: ^4.2.0
|
||||||
|
|
Loading…
Reference in a new issue