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),
|
||||
x: types.number(200),
|
||||
y: types.number(200),
|
||||
color: types.rgba({r: 1, g: 0, b: 0, a: 1}),
|
||||
}
|
||||
|
||||
const Box: React.FC<{
|
||||
|
@ -42,6 +43,12 @@ const Box: React.FC<{
|
|||
test: string
|
||||
testLiteral: string
|
||||
bool: boolean
|
||||
color: {
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
a: number
|
||||
}
|
||||
}>(obj.value)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
|
@ -75,6 +82,7 @@ const Box: React.FC<{
|
|||
test: initial.test,
|
||||
testLiteral: initial.testLiteral,
|
||||
bool: initial.bool,
|
||||
color: initial.color,
|
||||
})
|
||||
})
|
||||
},
|
||||
|
@ -111,6 +119,12 @@ const Box: React.FC<{
|
|||
<pre style={{margin: 0, padding: '1rem'}}>
|
||||
{JSON.stringify(state, null, 4)}
|
||||
</pre>
|
||||
<div
|
||||
style={{
|
||||
height: 50,
|
||||
background: state.color.toString(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -4,3 +4,5 @@ Simply update the version of `theatre/package.json`, then run `$ yarn run releas
|
|||
1. Update the version of `@theatre/core` and `@theatre/studio` and other dependencies.
|
||||
2. Bundle the `.js` and `.dts` files.
|
||||
3. Publish all packages to npm.
|
||||
|
||||
Packages added to theatre/package.json will be bundled with studio, packages added to package.json of sub-packages will be treated as their externals.
|
|
@ -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."
|
||||
}
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
import type {$IntentionalAny} from '@theatre/shared/utils/types'
|
||||
import userReadableTypeOfValue from '@theatre/shared/utils/userReadableTypeOfValue'
|
||||
import type {Rgba} from '@theatre/shared/utils/color'
|
||||
import {
|
||||
decorateRgba,
|
||||
linearSrgbToOklab,
|
||||
oklabToLinearSrgb,
|
||||
srgbToLinearSrgb,
|
||||
linearSrgbToSrgb,
|
||||
} from '@theatre/shared/utils/color'
|
||||
import {mapValues} from 'lodash-es'
|
||||
import type {
|
||||
IShorthandCompoundProps,
|
||||
|
@ -234,6 +242,100 @@ const _interpolateNumber = (
|
|||
return left + progression * (right - left)
|
||||
}
|
||||
|
||||
export const rgba = (
|
||||
defaultValue: Rgba = {r: 0, g: 0, b: 0, a: 1},
|
||||
opts?: {
|
||||
label?: string
|
||||
},
|
||||
): PropTypeConfig_Rgba => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
validateCommonOpts('t.rgba(defaultValue, opts)', opts)
|
||||
|
||||
// Lots of duplicated code and stuff that probably shouldn't be here, mostly
|
||||
// because we are still figuring out how we are doing validation, sanitization,
|
||||
// decoding, decorating.
|
||||
|
||||
// Validate default value
|
||||
let valid = true
|
||||
for (const p of ['r', 'g', 'b', 'a']) {
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(defaultValue, p) ||
|
||||
typeof (defaultValue as $IntentionalAny)[p] !== 'number'
|
||||
) {
|
||||
valid = false
|
||||
}
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
throw new Error(
|
||||
`Argument defaultValue in t.rgba(defaultValue) must be of the shape { r: number; g: number, b: number, a: number; }.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp defaultValue components between 0 and 1
|
||||
const sanitized = {}
|
||||
for (const component of ['r', 'g', 'b', 'a']) {
|
||||
;(sanitized as $IntentionalAny)[component] = Math.min(
|
||||
Math.max((defaultValue as $IntentionalAny)[component], 0),
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'rgba',
|
||||
valueType: null as $IntentionalAny,
|
||||
default: decorateRgba(sanitized as Rgba),
|
||||
[propTypeSymbol]: 'TheatrePropType',
|
||||
label: opts?.label,
|
||||
sanitize: _sanitizeRgba,
|
||||
interpolate: _interpolateRgba,
|
||||
}
|
||||
}
|
||||
|
||||
const _sanitizeRgba = (val: unknown): Rgba | undefined => {
|
||||
let valid = true
|
||||
for (const c of ['r', 'g', 'b', 'a']) {
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(val, c) ||
|
||||
typeof (val as $IntentionalAny)[c] !== 'number'
|
||||
) {
|
||||
valid = false
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp defaultValue components between 0 and 1
|
||||
const sanitized = {}
|
||||
for (const c of ['r', 'g', 'b', 'a']) {
|
||||
;(sanitized as $IntentionalAny)[c] = Math.min(
|
||||
Math.max((val as $IntentionalAny)[c], 0),
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
return valid ? decorateRgba(sanitized as Rgba) : undefined
|
||||
}
|
||||
|
||||
const _interpolateRgba = (
|
||||
left: Rgba,
|
||||
right: Rgba,
|
||||
progression: number,
|
||||
): Rgba => {
|
||||
const leftLab = linearSrgbToOklab(srgbToLinearSrgb(left))
|
||||
const rightLab = linearSrgbToOklab(srgbToLinearSrgb(right))
|
||||
|
||||
const interpolatedLab = {
|
||||
L: (1 - progression) * leftLab.L + progression * rightLab.L,
|
||||
a: (1 - progression) * leftLab.a + progression * rightLab.a,
|
||||
b: (1 - progression) * leftLab.b + progression * rightLab.b,
|
||||
alpha: (1 - progression) * leftLab.alpha + progression * rightLab.alpha,
|
||||
}
|
||||
|
||||
const interpolatedRgba = linearSrgbToSrgb(oklabToLinearSrgb(interpolatedLab))
|
||||
|
||||
return decorateRgba(interpolatedRgba)
|
||||
}
|
||||
|
||||
/**
|
||||
* A boolean prop type
|
||||
*
|
||||
|
@ -467,6 +569,11 @@ export interface PropTypeConfig_StringLiteral<T extends string>
|
|||
as: 'menu' | 'switch'
|
||||
}
|
||||
|
||||
export interface PropTypeConfig_Rgba extends IBasePropType<Rgba> {
|
||||
type: 'rgba'
|
||||
default: Rgba
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
|
@ -492,6 +599,7 @@ export type PropTypeConfig_AllPrimitives =
|
|||
| PropTypeConfig_Boolean
|
||||
| PropTypeConfig_String
|
||||
| PropTypeConfig_StringLiteral<$IntentionalAny>
|
||||
| PropTypeConfig_Rgba
|
||||
|
||||
export type PropTypeConfig =
|
||||
| PropTypeConfig_AllPrimitives
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
|
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,
|
||||
> = {[Key in string]?: SerializableValue<Primitives>}
|
||||
|
||||
export type SerializablePrimitive = string | number | boolean
|
||||
/*
|
||||
* TODO: For now the rgba primitive type is hard-coded. We should make it proper.
|
||||
* What instead we should do is somehow exclude objects where
|
||||
* object.type !== 'compound'. One way to do this would be
|
||||
*
|
||||
* type SerializablePrimitive<T> = T extends {type: 'compound'} ? never : T;
|
||||
*
|
||||
* const badStuff = {
|
||||
* type: 'compound',
|
||||
* foo: 3,
|
||||
* } as const
|
||||
*
|
||||
* const goodStuff = {
|
||||
* type: 'literallyanythingelse',
|
||||
* foo: 3,
|
||||
* } as const
|
||||
*
|
||||
* function serializeStuff<T>(giveMeStuff: SerializablePrimitive<T>) {
|
||||
* // ...
|
||||
* }
|
||||
*
|
||||
* serializeStuff(badStuff)
|
||||
* serializeStuff(goodStuff)
|
||||
*
|
||||
* However this wouldn't protect against other unserializable stuff, or nested
|
||||
* unserializable stuff, since using mapped types seem to break it for some reason.
|
||||
*/
|
||||
export type SerializablePrimitive =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| {r: number; g: number; b: number; a: number}
|
||||
|
||||
export type SerializableValue<
|
||||
Primitives extends SerializablePrimitive = SerializablePrimitive,
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
|
|
@ -30,6 +30,9 @@ import {isSheetObject} from '@theatre/shared/instanceTypes'
|
|||
import type {OnDiskState} from '@theatre/core/projects/store/storeTypes'
|
||||
import {generateDiskStateRevision} from './generateDiskStateRevision'
|
||||
import type {PropTypeConfig} from '@theatre/core/propTypes'
|
||||
import type {PathToProp} from '@theatre/shared/src/utils/addresses'
|
||||
import {getPropConfigByPath} from '@theatre/shared/propTypes/utils'
|
||||
import {cloneDeep} from 'lodash-es'
|
||||
|
||||
export type Drafts = {
|
||||
historic: Draft<StudioHistoricState>
|
||||
|
@ -131,9 +134,10 @@ export default class StudioStore {
|
|||
const api: ITransactionPrivateApi = {
|
||||
set: (pointer, value) => {
|
||||
ensureRunning()
|
||||
const {root} = getPointerParts(pointer as Pointer<$FixMe>)
|
||||
const _value = cloneDeep(value)
|
||||
const {root, path} = getPointerParts(pointer as Pointer<$FixMe>)
|
||||
if (isSheetObject(root)) {
|
||||
root.validateValue(pointer as Pointer<$FixMe>, value)
|
||||
root.validateValue(pointer as Pointer<$FixMe>, _value)
|
||||
|
||||
const sequenceTracksTree = val(
|
||||
root.template
|
||||
|
@ -141,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 = <T>(
|
||||
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 = <T>(
|
||||
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',
|
||||
|
|
|
@ -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<{
|
||||
|
|
|
@ -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
|
||||
key="input"
|
||||
type="text"
|
||||
className={!isValid(value) ? 'invalid' : ''}
|
||||
className={`${props.className ?? ''} ${!isValid(value) ? 'invalid' : ''}`}
|
||||
onChange={callbacks.inputChange}
|
||||
value={value}
|
||||
onBlur={callbacks.onBlur}
|
||||
|
|
11
yarn.lock
11
yarn.lock
|
@ -19940,6 +19940,16 @@ fsevents@^1.2.7:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-colorful@npm:^5.5.1":
|
||||
version: 5.5.1
|
||||
resolution: "react-colorful@npm:5.5.1"
|
||||
peerDependencies:
|
||||
react: ">=16.8.0"
|
||||
react-dom: ">=16.8.0"
|
||||
checksum: 2b0beb90ab5c93c3b2a19c5a9c5c7ca9cedd5eeac61807335d4466f089cc3b3530724c0d685014a9ae0b4c40db638c8cfa27fccab6485f2d3cda00819edcceb7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-dev-utils@npm:^11.0.3":
|
||||
version: 11.0.4
|
||||
resolution: "react-dev-utils@npm:11.0.4"
|
||||
|
@ -22959,6 +22969,7 @@ fsevents@^1.2.7:
|
|||
prop-types: ^15.7.2
|
||||
propose: ^0.0.5
|
||||
react: ^17.0.2
|
||||
react-colorful: ^5.5.1
|
||||
react-dom: ^17.0.2
|
||||
react-error-boundary: ^3.1.3
|
||||
react-icons: ^4.2.0
|
||||
|
|
Loading…
Reference in a new issue