From defb538561b3eb948471bc85e11a249c572884b7 Mon Sep 17 00:00:00 2001 From: Andrew Prifer <2991360+AndrewPrifer@users.noreply.github.com> Date: Sat, 19 Feb 2022 17:54:19 +0100 Subject: [PATCH] Create color prop (#64) Authored by andrew@theatrejs.com --- credits.txt | 1 + packages/playground/src/shared/dom/Scene.tsx | 14 ++ theatre/README.md | 4 +- theatre/core/package.json | 3 +- theatre/core/src/propTypes/index.ts | 108 +++++++++ theatre/package.json | 4 +- theatre/shared/src/utils/color.ts | 120 ++++++++++ theatre/shared/src/utils/types.ts | 33 ++- theatre/studio/package.json | 3 +- theatre/studio/src/StudioStore/StudioStore.ts | 163 ++++++++----- .../propEditors/DeterminePropEditor.tsx | 3 +- .../propEditors/RgbaPropEditor.tsx | 121 ++++++++++ .../components/EditingProvider.tsx | 29 +++ .../components/RgbaColorPicker.tsx | 54 +++++ .../colorPicker/components/common/Alpha.tsx | 85 +++++++ .../components/common/AlphaColorPicker.tsx | 59 +++++ .../colorPicker/components/common/Hue.tsx | 68 ++++++ .../components/common/Interactive.tsx | 203 ++++++++++++++++ .../colorPicker/components/common/Pointer.tsx | 59 +++++ .../components/common/Saturation.tsx | 73 ++++++ .../colorPicker/hooks/useColorManipulation.ts | 105 +++++++++ .../colorPicker/hooks/useEventCallback.ts | 14 ++ .../hooks/useIsomorphicLayoutEffect.ts | 7 + .../src/uiComponents/colorPicker/index.ts | 11 + .../src/uiComponents/colorPicker/types.ts | 73 ++++++ .../uiComponents/colorPicker/utils/clamp.ts | 6 + .../uiComponents/colorPicker/utils/compare.ts | 35 +++ .../uiComponents/colorPicker/utils/convert.ts | 220 ++++++++++++++++++ .../uiComponents/colorPicker/utils/round.ts | 7 + .../colorPicker/utils/validate.ts | 13 ++ .../uiComponents/form/BasicStringInput.tsx | 2 +- yarn.lock | 11 + 32 files changed, 1641 insertions(+), 70 deletions(-) create mode 100644 credits.txt create mode 100644 theatre/shared/src/utils/color.ts create mode 100644 theatre/studio/src/panels/DetailPanel/propEditors/RgbaPropEditor.tsx create mode 100644 theatre/studio/src/uiComponents/colorPicker/components/EditingProvider.tsx create mode 100644 theatre/studio/src/uiComponents/colorPicker/components/RgbaColorPicker.tsx create mode 100644 theatre/studio/src/uiComponents/colorPicker/components/common/Alpha.tsx create mode 100644 theatre/studio/src/uiComponents/colorPicker/components/common/AlphaColorPicker.tsx create mode 100644 theatre/studio/src/uiComponents/colorPicker/components/common/Hue.tsx create mode 100644 theatre/studio/src/uiComponents/colorPicker/components/common/Interactive.tsx create mode 100644 theatre/studio/src/uiComponents/colorPicker/components/common/Pointer.tsx create mode 100644 theatre/studio/src/uiComponents/colorPicker/components/common/Saturation.tsx create mode 100644 theatre/studio/src/uiComponents/colorPicker/hooks/useColorManipulation.ts create mode 100644 theatre/studio/src/uiComponents/colorPicker/hooks/useEventCallback.ts create mode 100644 theatre/studio/src/uiComponents/colorPicker/hooks/useIsomorphicLayoutEffect.ts create mode 100644 theatre/studio/src/uiComponents/colorPicker/index.ts create mode 100644 theatre/studio/src/uiComponents/colorPicker/types.ts create mode 100644 theatre/studio/src/uiComponents/colorPicker/utils/clamp.ts create mode 100644 theatre/studio/src/uiComponents/colorPicker/utils/compare.ts create mode 100644 theatre/studio/src/uiComponents/colorPicker/utils/convert.ts create mode 100644 theatre/studio/src/uiComponents/colorPicker/utils/round.ts create mode 100644 theatre/studio/src/uiComponents/colorPicker/utils/validate.ts diff --git a/credits.txt b/credits.txt new file mode 100644 index 0000000..cea6800 --- /dev/null +++ b/credits.txt @@ -0,0 +1 @@ +The color picker is a fork of https://github.com/omgovich/react-colorful \ No newline at end of file diff --git a/packages/playground/src/shared/dom/Scene.tsx b/packages/playground/src/shared/dom/Scene.tsx index 428ed52..6221556 100644 --- a/packages/playground/src/shared/dom/Scene.tsx +++ b/packages/playground/src/shared/dom/Scene.tsx @@ -24,6 +24,7 @@ const boxObjectConfig = { bool: types.boolean(false), x: types.number(200), y: types.number(200), + color: types.rgba({r: 1, g: 0, b: 0, a: 1}), } const Box: React.FC<{ @@ -42,6 +43,12 @@ const Box: React.FC<{ test: string testLiteral: string bool: boolean + color: { + r: number + g: number + b: number + a: number + } }>(obj.value) useLayoutEffect(() => { @@ -75,6 +82,7 @@ const Box: React.FC<{ test: initial.test, testLiteral: initial.testLiteral, bool: initial.bool, + color: initial.color, }) }) }, @@ -111,6 +119,12 @@ const Box: React.FC<{
         {JSON.stringify(state, null, 4)}
       
+
) } diff --git a/theatre/README.md b/theatre/README.md index fabf165..b7278bf 100644 --- a/theatre/README.md +++ b/theatre/README.md @@ -3,4 +3,6 @@ Simply update the version of `theatre/package.json`, then run `$ yarn run release`. This script will: 1. Update the version of `@theatre/core` and `@theatre/studio` and other dependencies. 2. Bundle the `.js` and `.dts` files. -3. Publish all packages to npm. \ No newline at end of file +3. Publish all packages to npm. + +Packages added to theatre/package.json will be bundled with studio, packages added to package.json of sub-packages will be treated as their externals. \ No newline at end of file diff --git a/theatre/core/package.json b/theatre/core/package.json index b34b0c0..9e3766e 100644 --- a/theatre/core/package.json +++ b/theatre/core/package.json @@ -25,5 +25,6 @@ "sideEffects": true, "dependencies": { "@theatre/dataverse": "workspace:*" - } + }, + "//": "Add packages here to make them externals of core. Add them to theatre/package.json if you want to bundle them with studio." } diff --git a/theatre/core/src/propTypes/index.ts b/theatre/core/src/propTypes/index.ts index 4840e67..aa2701c 100644 --- a/theatre/core/src/propTypes/index.ts +++ b/theatre/core/src/propTypes/index.ts @@ -1,5 +1,13 @@ import type {$IntentionalAny} from '@theatre/shared/utils/types' import userReadableTypeOfValue from '@theatre/shared/utils/userReadableTypeOfValue' +import type {Rgba} from '@theatre/shared/utils/color' +import { + decorateRgba, + linearSrgbToOklab, + oklabToLinearSrgb, + srgbToLinearSrgb, + linearSrgbToSrgb, +} from '@theatre/shared/utils/color' import {mapValues} from 'lodash-es' import type { IShorthandCompoundProps, @@ -234,6 +242,100 @@ const _interpolateNumber = ( return left + progression * (right - left) } +export const rgba = ( + defaultValue: Rgba = {r: 0, g: 0, b: 0, a: 1}, + opts?: { + label?: string + }, +): PropTypeConfig_Rgba => { + if (process.env.NODE_ENV !== 'production') { + validateCommonOpts('t.rgba(defaultValue, opts)', opts) + + // Lots of duplicated code and stuff that probably shouldn't be here, mostly + // because we are still figuring out how we are doing validation, sanitization, + // decoding, decorating. + + // Validate default value + let valid = true + for (const p of ['r', 'g', 'b', 'a']) { + if ( + !Object.prototype.hasOwnProperty.call(defaultValue, p) || + typeof (defaultValue as $IntentionalAny)[p] !== 'number' + ) { + valid = false + } + } + + if (!valid) { + throw new Error( + `Argument defaultValue in t.rgba(defaultValue) must be of the shape { r: number; g: number, b: number, a: number; }.`, + ) + } + } + + // Clamp defaultValue components between 0 and 1 + const sanitized = {} + for (const component of ['r', 'g', 'b', 'a']) { + ;(sanitized as $IntentionalAny)[component] = Math.min( + Math.max((defaultValue as $IntentionalAny)[component], 0), + 1, + ) + } + + return { + type: 'rgba', + valueType: null as $IntentionalAny, + default: decorateRgba(sanitized as Rgba), + [propTypeSymbol]: 'TheatrePropType', + label: opts?.label, + sanitize: _sanitizeRgba, + interpolate: _interpolateRgba, + } +} + +const _sanitizeRgba = (val: unknown): Rgba | undefined => { + let valid = true + for (const c of ['r', 'g', 'b', 'a']) { + if ( + !Object.prototype.hasOwnProperty.call(val, c) || + typeof (val as $IntentionalAny)[c] !== 'number' + ) { + valid = false + } + } + + // Clamp defaultValue components between 0 and 1 + const sanitized = {} + for (const c of ['r', 'g', 'b', 'a']) { + ;(sanitized as $IntentionalAny)[c] = Math.min( + Math.max((val as $IntentionalAny)[c], 0), + 1, + ) + } + + return valid ? decorateRgba(sanitized as Rgba) : undefined +} + +const _interpolateRgba = ( + left: Rgba, + right: Rgba, + progression: number, +): Rgba => { + const leftLab = linearSrgbToOklab(srgbToLinearSrgb(left)) + const rightLab = linearSrgbToOklab(srgbToLinearSrgb(right)) + + const interpolatedLab = { + L: (1 - progression) * leftLab.L + progression * rightLab.L, + a: (1 - progression) * leftLab.a + progression * rightLab.a, + b: (1 - progression) * leftLab.b + progression * rightLab.b, + alpha: (1 - progression) * leftLab.alpha + progression * rightLab.alpha, + } + + const interpolatedRgba = linearSrgbToSrgb(oklabToLinearSrgb(interpolatedLab)) + + return decorateRgba(interpolatedRgba) +} + /** * A boolean prop type * @@ -467,6 +569,11 @@ export interface PropTypeConfig_StringLiteral as: 'menu' | 'switch' } +export interface PropTypeConfig_Rgba extends IBasePropType { + type: 'rgba' + default: Rgba +} + /** * */ @@ -492,6 +599,7 @@ export type PropTypeConfig_AllPrimitives = | PropTypeConfig_Boolean | PropTypeConfig_String | PropTypeConfig_StringLiteral<$IntentionalAny> + | PropTypeConfig_Rgba export type PropTypeConfig = | PropTypeConfig_AllPrimitives diff --git a/theatre/package.json b/theatre/package.json index 3c2284d..8e88077 100644 --- a/theatre/package.json +++ b/theatre/package.json @@ -68,6 +68,7 @@ "prop-types": "^15.7.2", "propose": "^0.0.5", "react": "^17.0.2", + "react-colorful": "^5.5.1", "react-dom": "^17.0.2", "react-error-boundary": "^3.1.3", "react-icons": "^4.2.0", @@ -95,5 +96,6 @@ "dependencies": { "fast-deep-equal": "^3.1.3", "fuzzysort": "^1.1.4" - } + }, + "//": "Add packages here to have them bundled with studio, otherwise add them in the package.json of either studio or core, and they'll be treated as their externals." } diff --git a/theatre/shared/src/utils/color.ts b/theatre/shared/src/utils/color.ts new file mode 100644 index 0000000..696f12f --- /dev/null +++ b/theatre/shared/src/utils/color.ts @@ -0,0 +1,120 @@ +export function parseRgbaFromHex(rgba: string) { + rgba = rgba.trim().toLowerCase() + const hex = rgba.match(/^#?([0-9a-f]{8})$/i) + + if (!hex) { + return { + r: 0, + g: 0, + b: 0, + a: 1, + } + } + + const match = hex[1] + return { + r: parseInt(match.substr(0, 2), 16) / 255, + g: parseInt(match.substr(2, 2), 16) / 255, + b: parseInt(match.substr(4, 2), 16) / 255, + a: parseInt(match.substr(6, 2), 16) / 255, + } +} + +export function rgba2hex(rgba: Rgba) { + const hex = + ((rgba.r * 255) | (1 << 8)).toString(16).slice(1) + + ((rgba.g * 255) | (1 << 8)).toString(16).slice(1) + + ((rgba.b * 255) | (1 << 8)).toString(16).slice(1) + + ((rgba.a * 255) | (1 << 8)).toString(16).slice(1) + + return `#${hex}` +} + +// TODO: We should add a decorate property to the propConfig too. +// Right now, each place that has anything to do with a color is individually +// responsible for defining a toString() function on the object it returns. +export function decorateRgba(rgba: Rgba) { + return { + ...rgba, + toString() { + return rgba2hex(this) + }, + } +} + +export function linearSrgbToSrgb(rgba: Rgba) { + function compress(x: number) { + // This looks funky because sRGB uses a linear scale below 0.0031308 in + // order to avoid an infinite slope, while trying to approximate gamma 2.2 + // as closely as possible, hence the branching and the 2.4 exponent. + if (x >= 0.0031308) return 1.055 * x ** (1.0 / 2.4) - 0.055 + else return 12.92 * x + } + return { + r: compress(rgba.r), + g: compress(rgba.g), + b: compress(rgba.b), + a: rgba.a, + } +} + +export function srgbToLinearSrgb(rgba: Rgba) { + function expand(x: number) { + if (x >= 0.04045) return ((x + 0.055) / (1 + 0.055)) ** 2.4 + else return x / 12.92 + } + return { + r: expand(rgba.r), + g: expand(rgba.g), + b: expand(rgba.b), + a: rgba.a, + } +} + +export function linearSrgbToOklab(rgba: Rgba) { + let l = 0.4122214708 * rgba.r + 0.5363325363 * rgba.g + 0.0514459929 * rgba.b + let m = 0.2119034982 * rgba.r + 0.6806995451 * rgba.g + 0.1073969566 * rgba.b + let s = 0.0883024619 * rgba.r + 0.2817188376 * rgba.g + 0.6299787005 * rgba.b + + let l_ = Math.cbrt(l) + let m_ = Math.cbrt(m) + let s_ = Math.cbrt(s) + + return { + L: 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_, + a: 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_, + b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_, + alpha: rgba.a, + } +} + +export function oklabToLinearSrgb(laba: Laba) { + let l_ = laba.L + 0.3963377774 * laba.a + 0.2158037573 * laba.b + let m_ = laba.L - 0.1055613458 * laba.a - 0.0638541728 * laba.b + let s_ = laba.L - 0.0894841775 * laba.a - 1.291485548 * laba.b + + let l = l_ * l_ * l_ + let m = m_ * m_ * m_ + let s = s_ * s_ * s_ + + return { + r: +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + b: -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, + a: laba.alpha, + } +} + +export type Rgba = { + r: number + g: number + b: number + a: number +} + +export type Laba = { + L: number + a: number + b: number + alpha: number +} diff --git a/theatre/shared/src/utils/types.ts b/theatre/shared/src/utils/types.ts index cda2c3b..82c2c9c 100644 --- a/theatre/shared/src/utils/types.ts +++ b/theatre/shared/src/utils/types.ts @@ -11,7 +11,38 @@ export type SerializableMap< Primitives extends SerializablePrimitive = SerializablePrimitive, > = {[Key in string]?: SerializableValue} -export type SerializablePrimitive = string | number | boolean +/* + * TODO: For now the rgba primitive type is hard-coded. We should make it proper. + * What instead we should do is somehow exclude objects where + * object.type !== 'compound'. One way to do this would be + * + * type SerializablePrimitive = T extends {type: 'compound'} ? never : T; + * + * const badStuff = { + * type: 'compound', + * foo: 3, + * } as const + * + * const goodStuff = { + * type: 'literallyanythingelse', + * foo: 3, + * } as const + * + * function serializeStuff(giveMeStuff: SerializablePrimitive) { + * // ... + * } + * + * serializeStuff(badStuff) + * serializeStuff(goodStuff) + * + * However this wouldn't protect against other unserializable stuff, or nested + * unserializable stuff, since using mapped types seem to break it for some reason. + */ +export type SerializablePrimitive = + | string + | number + | boolean + | {r: number; g: number; b: number; a: number} export type SerializableValue< Primitives extends SerializablePrimitive = SerializablePrimitive, diff --git a/theatre/studio/package.json b/theatre/studio/package.json index dd1883e..a9e0221 100644 --- a/theatre/studio/package.json +++ b/theatre/studio/package.json @@ -30,5 +30,6 @@ "react": "^17.0.2", "react-dom": "^17.0.2", "styled-components": "^5.3.0" - } + }, + "//": "Add packages here to make them externals of studio. Add them to theatre/package.json if you want to bundle them with studio." } diff --git a/theatre/studio/src/StudioStore/StudioStore.ts b/theatre/studio/src/StudioStore/StudioStore.ts index 13ca49e..4b4dc17 100644 --- a/theatre/studio/src/StudioStore/StudioStore.ts +++ b/theatre/studio/src/StudioStore/StudioStore.ts @@ -30,6 +30,9 @@ import {isSheetObject} from '@theatre/shared/instanceTypes' import type {OnDiskState} from '@theatre/core/projects/store/storeTypes' import {generateDiskStateRevision} from './generateDiskStateRevision' import type {PropTypeConfig} from '@theatre/core/propTypes' +import type {PathToProp} from '@theatre/shared/src/utils/addresses' +import {getPropConfigByPath} from '@theatre/shared/propTypes/utils' +import {cloneDeep} from 'lodash-es' export type Drafts = { historic: Draft @@ -131,9 +134,10 @@ export default class StudioStore { const api: ITransactionPrivateApi = { set: (pointer, value) => { ensureRunning() - const {root} = getPointerParts(pointer as Pointer<$FixMe>) + const _value = cloneDeep(value) + const {root, path} = getPointerParts(pointer as Pointer<$FixMe>) if (isSheetObject(root)) { - root.validateValue(pointer as Pointer<$FixMe>, value) + root.validateValue(pointer as Pointer<$FixMe>, _value) const sequenceTracksTree = val( root.template @@ -141,46 +145,65 @@ export default class StudioStore { .getValue(), ) - forEachDeep( - value, - (v, pathToProp) => { - if (typeof v === 'undefined' || v === null) { - return - } + const propConfig = getPropConfigByPath( + root.template.config, + path, + ) as PropTypeConfig - const propAddress = {...root.address, pathToProp} + const setStaticOrKeyframeProp = ( + value: T, + path: PathToProp, + ) => { + if (typeof value === 'undefined' || value === null) { + return + } - const trackId = get( - sequenceTracksTree, - pathToProp, - ) as $FixMe as SequenceTrackId | undefined + const propAddress = {...root.address, pathToProp: path} - if (typeof trackId === 'string') { - const propConfig = get( - root.template.config.props, - pathToProp, - ) as PropTypeConfig | undefined - if (propConfig?.sanitize) v = propConfig.sanitize(v) + const trackId = get(sequenceTracksTree, path) as $FixMe as + | SequenceTrackId + | undefined + if (typeof trackId === 'string') { + const propConfig = getPropConfigByPath( + root.template.config, + path, + ) as PropTypeConfig | undefined + // TODO: Make sure this causes no problems wrt decorated + // or otherwise unserializable stuff that sanitize might return. + // value needs to be serializable. + if (propConfig?.sanitize) value = propConfig.sanitize(value) - const seq = root.sheet.getSequence() - seq.position = seq.closestGridPosition(seq.position) - stateEditors.coreByProject.historic.sheetsById.sequence.setKeyframeAtPosition( - { - ...propAddress, - trackId, - position: seq.position, - value: v as $FixMe, - snappingFunction: seq.closestGridPosition, - }, - ) - } else { - stateEditors.coreByProject.historic.sheetsById.staticOverrides.byObject.setValueOfPrimitiveProp( - {...propAddress, value: v}, - ) - } - }, - getPointerParts(pointer as Pointer<$IntentionalAny>).path, - ) + const seq = root.sheet.getSequence() + seq.position = seq.closestGridPosition(seq.position) + stateEditors.coreByProject.historic.sheetsById.sequence.setKeyframeAtPosition( + { + ...propAddress, + trackId, + position: seq.position, + value: value as $FixMe, + snappingFunction: seq.closestGridPosition, + }, + ) + } else if (propConfig !== undefined) { + stateEditors.coreByProject.historic.sheetsById.staticOverrides.byObject.setValueOfPrimitiveProp( + {...propAddress, value: value as $FixMe}, + ) + } + } + + // If we are dealing with a compound prop, we recurse through its + // nested properties. + if (propConfig.type === 'compound') { + forEachDeep( + _value, + (v, pathToProp) => { + setStaticOrKeyframeProp(v, pathToProp) + }, + getPointerParts(pointer as Pointer<$IntentionalAny>).path, + ) + } else { + setStaticOrKeyframeProp(_value, path) + } } else { throw new Error( 'Only setting props of SheetObject-s is supported in a transaction so far', @@ -202,33 +225,47 @@ export default class StudioStore { path, ) - forEachDeep( - defaultValue, - (v, pathToProp) => { - const propAddress = {...root.address, pathToProp} + const propConfig = getPropConfigByPath( + root.template.config, + path, + ) as PropTypeConfig - const trackId = get( - sequenceTracksTree, - pathToProp, - ) as $FixMe as SequenceTrackId | undefined + const unsetStaticOrKeyframeProp = ( + value: T, + path: PathToProp, + ) => { + const propAddress = {...root.address, pathToProp: path} - if (typeof trackId === 'string') { - stateEditors.coreByProject.historic.sheetsById.sequence.unsetKeyframeAtPosition( - { - ...propAddress, - trackId, - position: - root.sheet.getSequence().positionSnappedToGrid, - }, - ) - } else { - stateEditors.coreByProject.historic.sheetsById.staticOverrides.byObject.unsetValueOfPrimitiveProp( - propAddress, - ) - } - }, - getPointerParts(pointer as Pointer<$IntentionalAny>).path, - ) + const trackId = get(sequenceTracksTree, path) as $FixMe as + | SequenceTrackId + | undefined + + if (typeof trackId === 'string') { + stateEditors.coreByProject.historic.sheetsById.sequence.unsetKeyframeAtPosition( + { + ...propAddress, + trackId, + position: root.sheet.getSequence().positionSnappedToGrid, + }, + ) + } else if (propConfig !== undefined) { + stateEditors.coreByProject.historic.sheetsById.staticOverrides.byObject.unsetValueOfPrimitiveProp( + propAddress, + ) + } + } + + if (propConfig.type === 'compound') { + forEachDeep( + defaultValue, + (v, pathToProp) => { + unsetStaticOrKeyframeProp(v, pathToProp) + }, + getPointerParts(pointer as Pointer<$IntentionalAny>).path, + ) + } else { + unsetStaticOrKeyframeProp(defaultValue, path) + } } else { throw new Error( 'Only setting props of SheetObject-s is supported in a transaction so far', diff --git a/theatre/studio/src/panels/DetailPanel/propEditors/DeterminePropEditor.tsx b/theatre/studio/src/panels/DetailPanel/propEditors/DeterminePropEditor.tsx index ca5a2be..c049a8b 100644 --- a/theatre/studio/src/panels/DetailPanel/propEditors/DeterminePropEditor.tsx +++ b/theatre/studio/src/panels/DetailPanel/propEditors/DeterminePropEditor.tsx @@ -7,6 +7,7 @@ import CompoundPropEditor from './CompoundPropEditor' import NumberPropEditor from './NumberPropEditor' import StringLiteralPropEditor from './StringLiteralPropEditor' import StringPropEditor from './StringPropEditor' +import RgbaPropEditor from './RgbaPropEditor' /** * Returns the PropTypeConfig by path. Assumes `path` is a valid prop path and that @@ -69,7 +70,7 @@ const propEditorByPropType: { enum: () => <>, boolean: BooleanPropEditor, stringLiteral: StringLiteralPropEditor, - // cssrgba: () => <>, + rgba: RgbaPropEditor, } const DeterminePropEditor: React.FC<{ diff --git a/theatre/studio/src/panels/DetailPanel/propEditors/RgbaPropEditor.tsx b/theatre/studio/src/panels/DetailPanel/propEditors/RgbaPropEditor.tsx new file mode 100644 index 0000000..010b6e9 --- /dev/null +++ b/theatre/studio/src/panels/DetailPanel/propEditors/RgbaPropEditor.tsx @@ -0,0 +1,121 @@ +import type {PropTypeConfig_Rgba} from '@theatre/core/propTypes' +import type {Rgba} from '@theatre/shared/utils/color' +import { + decorateRgba, + rgba2hex, + parseRgbaFromHex, +} from '@theatre/shared/utils/color' +import type SheetObject from '@theatre/core/sheetObjects/SheetObject' +import React, {useCallback, useRef} from 'react' +import {useEditingToolsForPrimitiveProp} from './utils/useEditingToolsForPrimitiveProp' +import {SingleRowPropEditor} from './utils/SingleRowPropEditor' +import {RgbaColorPicker} from '@theatre/studio/uiComponents/colorPicker' +import styled from 'styled-components' +import usePopover from '@theatre/studio/uiComponents/Popover/usePopover' +import BasicStringInput from '@theatre/studio/uiComponents/form/BasicStringInput' +import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover' + +const RowContainer = styled.div` + display: flex; + align-items: center; + height: 100%; + gap: 4px; +` + +interface PuckProps { + background: Rgba +} + +const Puck = styled.div.attrs((props) => ({ + style: { + background: props.background, + }, +}))` + height: calc(100% - 4px); + aspect-ratio: 1; + border-radius: 2px; +` + +const HexInput = styled(BasicStringInput)` + flex: 1; +` + +const noop = () => {} + +const RgbaPropEditor: React.FC<{ + propConfig: PropTypeConfig_Rgba + pointerToProp: SheetObject['propsP'] + obj: SheetObject +}> = ({propConfig, pointerToProp, obj}) => { + const containerRef = useRef(null!) + + const stuff = useEditingToolsForPrimitiveProp( + pointerToProp, + obj, + propConfig, + ) + + const onChange = useCallback( + (color: string) => { + const rgba = decorateRgba(parseRgbaFromHex(color)) + stuff.permenantlySetValue(rgba) + }, + [stuff], + ) + + const [popoverNode, openPopover] = usePopover({}, () => { + return ( + +
+ { + const rgba = decorateRgba(color) + stuff.temporarilySetValue(rgba) + }} + permanentlySetValue={(color) => { + // console.log('perm') + const rgba = decorateRgba(color) + stuff.permenantlySetValue(rgba) + }} + discardTemporaryValue={stuff.discardTemporaryValue} + /> +
+
+ ) + }) + + return ( + + + { + openPopover(e, containerRef.current) + }} + /> + !!v.match(/^#?([0-9a-f]{8})$/i)} + /> + + {popoverNode} + + ) +} + +export default RgbaPropEditor diff --git a/theatre/studio/src/uiComponents/colorPicker/components/EditingProvider.tsx b/theatre/studio/src/uiComponents/colorPicker/components/EditingProvider.tsx new file mode 100644 index 0000000..dc08bf9 --- /dev/null +++ b/theatre/studio/src/uiComponents/colorPicker/components/EditingProvider.tsx @@ -0,0 +1,29 @@ +import type {FC} from 'react' +import React, {createContext, useContext, useState} from 'react' + +const editingContext = createContext<{ + editing: boolean + setEditing: (editing: boolean) => void +}>(undefined!) + +/** + * Provides the current mode the color picker is in. When editing, the picker should be + * stateful and disregard controlling props, while not editing, it should behave + * in a controlled manner. + */ +export const EditingProvider: FC = ({children}) => { + const [editing, setEditing] = useState(false) + + return ( + + {children} + + ) +} + +export const useEditing = () => useContext(editingContext) diff --git a/theatre/studio/src/uiComponents/colorPicker/components/RgbaColorPicker.tsx b/theatre/studio/src/uiComponents/colorPicker/components/RgbaColorPicker.tsx new file mode 100644 index 0000000..fe4f35b --- /dev/null +++ b/theatre/studio/src/uiComponents/colorPicker/components/RgbaColorPicker.tsx @@ -0,0 +1,54 @@ +import React from 'react' + +import {AlphaColorPicker} from './common/AlphaColorPicker' +import type { + ColorModel, + ColorPickerBaseProps, + HsvaColor, + RgbaColor, +} from '@theatre/studio/uiComponents/colorPicker/types' +import {equalColorObjects} from '@theatre/studio/uiComponents/colorPicker/utils/compare' +import { + rgbaToHsva, + hsvaToRgba, +} from '@theatre/studio/uiComponents/colorPicker/utils/convert' +import {EditingProvider} from './EditingProvider' + +const normalizeRgba = (rgba: RgbaColor) => { + return { + r: rgba.r / 255, + g: rgba.g / 255, + b: rgba.b / 255, + a: rgba.a, + } +} + +const denormalizeRgba = (rgba: RgbaColor) => { + return { + r: rgba.r * 255, + g: rgba.g * 255, + b: rgba.b * 255, + a: rgba.a, + } +} + +const colorModel: ColorModel = { + defaultColor: {r: 0, g: 0, b: 0, a: 1}, + toHsva: (rgba: RgbaColor) => rgbaToHsva(denormalizeRgba(rgba)), + fromHsva: (hsva: HsvaColor) => normalizeRgba(hsvaToRgba(hsva)), + equal: equalColorObjects, +} + +export const RgbaColorPicker = ( + props: ColorPickerBaseProps, +): JSX.Element => ( + + { + props.permanentlySetValue!(newColor) + }} + colorModel={colorModel} + /> + +) diff --git a/theatre/studio/src/uiComponents/colorPicker/components/common/Alpha.tsx b/theatre/studio/src/uiComponents/colorPicker/components/common/Alpha.tsx new file mode 100644 index 0000000..af58768 --- /dev/null +++ b/theatre/studio/src/uiComponents/colorPicker/components/common/Alpha.tsx @@ -0,0 +1,85 @@ +import React from 'react' + +import type {Interaction} from './Interactive' +import {Interactive} from './Interactive' +import {Pointer} from './Pointer' + +import {hsvaToHslaString} from '@theatre/studio/uiComponents/colorPicker/utils/convert' +import {clamp} from '@theatre/studio/uiComponents/colorPicker/utils/clamp' +import {round} from '@theatre/studio/uiComponents/colorPicker/utils/round' +import type {HsvaColor} from '@theatre/studio/uiComponents/colorPicker/types' +import styled from 'styled-components' + +const Container = styled.div` + position: relative; + height: 24px; + border-radius: 4px; + // Checkerboard + background-color: #fff; + background-image: url('data:image/svg+xml,'); +` + +interface GradientProps { + colorFrom: string + colorTo: string +} + +const Gradient = styled.div.attrs(({colorFrom, colorTo}) => ({ + style: { + backgroundImage: `linear-gradient(90deg, ${colorFrom}, ${colorTo})`, + }, +}))` + content: ''; + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + pointer-events: none; + border-radius: inherit; + + // Improve rendering on light backgrounds + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.05); +` + +const StyledPointer = styled(Pointer)` + // Checkerboard + background-color: #fff; + background-image: url('data:image/svg+xml,'); +` + +interface Props { + className?: string + hsva: HsvaColor + onChange: (newAlpha: {a: number}) => void +} + +export const Alpha = ({className, hsva, onChange}: Props): JSX.Element => { + const handleMove = (interaction: Interaction) => { + onChange({a: interaction.left}) + } + + const handleKey = (offset: Interaction) => { + // Alpha always fit into [0, 1] range + onChange({a: clamp(hsva.a + offset.left)}) + } + + // We use `Object.assign` instead of the spread operator + // to prevent adding the polyfill (about 150 bytes gzipped) + const colorFrom = hsvaToHslaString(Object.assign({}, hsva, {a: 0})) + const colorTo = hsvaToHslaString(Object.assign({}, hsva, {a: 1})) + + return ( + + + + + + + ) +} diff --git a/theatre/studio/src/uiComponents/colorPicker/components/common/AlphaColorPicker.tsx b/theatre/studio/src/uiComponents/colorPicker/components/common/AlphaColorPicker.tsx new file mode 100644 index 0000000..79aff98 --- /dev/null +++ b/theatre/studio/src/uiComponents/colorPicker/components/common/AlphaColorPicker.tsx @@ -0,0 +1,59 @@ +import React, {useEffect} from 'react' + +import {Hue} from './Hue' +import {Saturation} from './Saturation' +import {Alpha} from './Alpha' + +import type { + ColorModel, + ColorPickerBaseProps, + AnyColor, +} from '@theatre/studio/uiComponents/colorPicker/types' +import {useColorManipulation} from '@theatre/studio/uiComponents/colorPicker/hooks/useColorManipulation' +import styled from 'styled-components' + +const Container = styled.div` + position: relative; + display: flex; + gap: 8px; + flex-direction: column; + width: 200px; + height: 200px; + user-select: none; + cursor: default; +` + +interface Props extends ColorPickerBaseProps { + colorModel: ColorModel +} + +export const AlphaColorPicker = ({ + className, + colorModel, + color = colorModel.defaultColor, + temporarilySetValue, + permanentlySetValue, + discardTemporaryValue, + ...rest +}: Props): JSX.Element => { + const [tempHsva, updateHsva] = useColorManipulation( + colorModel, + color, + temporarilySetValue, + permanentlySetValue, + ) + + useEffect(() => { + return () => { + discardTemporaryValue() + } + }, []) + + return ( + + + + + + ) +} diff --git a/theatre/studio/src/uiComponents/colorPicker/components/common/Hue.tsx b/theatre/studio/src/uiComponents/colorPicker/components/common/Hue.tsx new file mode 100644 index 0000000..a32c0f0 --- /dev/null +++ b/theatre/studio/src/uiComponents/colorPicker/components/common/Hue.tsx @@ -0,0 +1,68 @@ +import React from 'react' + +import type {Interaction} from './Interactive' +import {Interactive} from './Interactive' +import {Pointer} from './Pointer' + +import {hsvaToHslString} from '@theatre/studio/uiComponents/colorPicker/utils/convert' +import {clamp} from '@theatre/studio/uiComponents/colorPicker/utils/clamp' +import {round} from '@theatre/studio/uiComponents/colorPicker/utils/round' +import styled from 'styled-components' + +const Container = styled.div` + position: relative; + height: 24px; + border-radius: 4px; + + background: linear-gradient( + to right, + #f00 0%, + #ff0 17%, + #0f0 33%, + #0ff 50%, + #00f 67%, + #f0f 83%, + #f00 100% + ); +` + +const StyledPointer = styled(Pointer)` + z-index: 2; +` + +interface Props { + className?: string + hue: number + onChange: (newHue: {h: number}) => void +} + +const HueBase = ({className, hue, onChange}: Props) => { + const handleMove = (interaction: Interaction) => { + onChange({h: 360 * interaction.left}) + } + + const handleKey = (offset: Interaction) => { + // Hue measured in degrees of the color circle ranging from 0 to 360 + onChange({ + h: clamp(hue + offset.left * 360, 0, 360), + }) + } + + return ( + + + + + + ) +} + +export const Hue = React.memo(HueBase) diff --git a/theatre/studio/src/uiComponents/colorPicker/components/common/Interactive.tsx b/theatre/studio/src/uiComponents/colorPicker/components/common/Interactive.tsx new file mode 100644 index 0000000..48addf7 --- /dev/null +++ b/theatre/studio/src/uiComponents/colorPicker/components/common/Interactive.tsx @@ -0,0 +1,203 @@ +import React, {useRef, useMemo, useEffect} from 'react' + +import {useEventCallback} from '@theatre/studio/uiComponents/colorPicker/hooks/useEventCallback' +import {clamp} from '@theatre/studio/uiComponents/colorPicker/utils/clamp' +import styled from 'styled-components' +import {useEditing} from '@theatre/studio/uiComponents/colorPicker/components/EditingProvider' + +export interface Interaction { + left: number + top: number +} + +// Check if an event was triggered by touch +const isTouch = (event: MouseEvent | TouchEvent): event is TouchEvent => + 'touches' in event + +// Finds a proper touch point by its identifier +const getTouchPoint = (touches: TouchList, touchId: null | number): Touch => { + for (let i = 0; i < touches.length; i++) { + if (touches[i].identifier === touchId) return touches[i] + } + return touches[0] +} + +// Finds the proper window object to fix iframe embedding issues +const getParentWindow = (node?: HTMLDivElement | null): Window => { + return (node && node.ownerDocument.defaultView) || self +} + +// Returns a relative position of the pointer inside the node's bounding box +const getRelativePosition = ( + node: HTMLDivElement, + event: MouseEvent | TouchEvent, + touchId: null | number, +): Interaction => { + const rect = node.getBoundingClientRect() + + // Get user's pointer position from `touches` array if it's a `TouchEvent` + const pointer = isTouch(event) + ? getTouchPoint(event.touches, touchId) + : (event as MouseEvent) + + return { + left: clamp( + (pointer.pageX - (rect.left + getParentWindow(node).pageXOffset)) / + rect.width, + ), + top: clamp( + (pointer.pageY - (rect.top + getParentWindow(node).pageYOffset)) / + rect.height, + ), + } +} + +// Browsers introduced an intervention, making touch events passive by default. +// This workaround removes `preventDefault` call from the touch handlers. +// https://github.com/facebook/react/issues/19651 +const preventDefaultMove = (event: MouseEvent | TouchEvent): void => { + !isTouch(event) && event.preventDefault() +} + +// Prevent mobile browsers from handling mouse events (conflicting with touch ones). +// If we detected a touch interaction before, we prefer reacting to touch events only. +const isInvalid = ( + event: MouseEvent | TouchEvent, + hasTouch: boolean, +): boolean => { + return hasTouch && !isTouch(event) +} + +interface Props { + onMove: (interaction: Interaction) => void + onKey: (offset: Interaction) => void + children: React.ReactNode +} + +const Container = styled.div` + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + border-radius: inherit; + outline: none; + /* Don't trigger the default scrolling behavior when the event is originating from this element */ + touch-action: none; +` + +const InteractiveBase = ({onMove, onKey, ...rest}: Props) => { + const container = useRef(null) + const onMoveCallback = useEventCallback(onMove) + const onKeyCallback = useEventCallback(onKey) + const touchId = useRef(null) + const hasTouch = useRef(false) + + const {setEditing} = useEditing() + + const [handleMoveStart, handleKeyDown, toggleDocumentEvents] = useMemo(() => { + const handleMoveStart = ({ + nativeEvent, + }: React.MouseEvent | React.TouchEvent) => { + const el = container.current + if (!el) return + + // Prevent text selection + preventDefaultMove(nativeEvent) + + if (isInvalid(nativeEvent, hasTouch.current) || !el) return + + if (isTouch(nativeEvent)) { + hasTouch.current = true + const changedTouches = nativeEvent.changedTouches || [] + if (changedTouches.length) + touchId.current = changedTouches[0].identifier + } + + el.focus() + setEditing(true) + onMoveCallback(getRelativePosition(el, nativeEvent, touchId.current)) + toggleDocumentEvents(true) + } + + const handleMove = (event: MouseEvent | TouchEvent) => { + // Prevent text selection + preventDefaultMove(event) + + // If user moves the pointer outside the window or iframe bounds and release it there, + // `mouseup`/`touchend` won't be fired. In order to stop the picker from following the cursor + // after the user has moved the mouse/finger back to the document, we check `event.buttons` + // and `event.touches`. It allows us to detect that the user is just moving his pointer + // without pressing it down + // Note: we should use pointer events to fix this, since we don't have strict compatibility + // requirements. + const isDown = isTouch(event) + ? event.touches.length > 0 + : event.buttons > 0 + + if (isDown && container.current) { + onMoveCallback( + getRelativePosition(container.current, event, touchId.current), + ) + } else { + setEditing(false) + toggleDocumentEvents(false) + } + } + + // Use move-end anyway (see above) so we can terminate early if we receive one + // instead of having to wait for the user to move the mouse, which they might not do. + const handleMoveEnd = (event: MouseEvent | TouchEvent) => { + setEditing(false) + toggleDocumentEvents(false) + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + const keyCode = event.which || event.keyCode + + // Ignore all keys except arrow ones + if (keyCode < 37 || keyCode > 40) return + // Do not scroll page by arrow keys when document is focused on the element + event.preventDefault() + // Send relative offset to the parent component. + // We use codes (37←, 38↑, 39→, 40↓) instead of keys ('ArrowRight', 'ArrowDown', etc) + // to reduce the size of the library + onKeyCallback({ + left: keyCode === 39 ? 0.05 : keyCode === 37 ? -0.05 : 0, + top: keyCode === 40 ? 0.05 : keyCode === 38 ? -0.05 : 0, + }) + } + + function toggleDocumentEvents(state?: boolean) { + const touch = hasTouch.current + const el = container.current + const parentWindow = getParentWindow(el) + + // Add or remove additional pointer event listeners + const toggleEvent = state + ? parentWindow.addEventListener + : parentWindow.removeEventListener + toggleEvent(touch ? 'touchmove' : 'mousemove', handleMove) + toggleEvent(touch ? 'touchend' : 'mouseup', handleMoveEnd) + } + + return [handleMoveStart, handleKeyDown, toggleDocumentEvents] + }, [onKeyCallback, onMoveCallback]) + + // Remove window event listeners before unmounting + useEffect(() => toggleDocumentEvents, [toggleDocumentEvents]) + + return ( + + ) +} + +export const Interactive = React.memo(InteractiveBase) diff --git a/theatre/studio/src/uiComponents/colorPicker/components/common/Pointer.tsx b/theatre/studio/src/uiComponents/colorPicker/components/common/Pointer.tsx new file mode 100644 index 0000000..66e663f --- /dev/null +++ b/theatre/studio/src/uiComponents/colorPicker/components/common/Pointer.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import styled from 'styled-components' +import {Interactive} from './Interactive' + +// Create an "empty" styled version, so we can reference it for contextual styling +const StyledInteractive = styled(Interactive)`` + +const Container = styled.div` + position: absolute; + z-index: 1; + box-sizing: border-box; + width: 28px; + height: 28px; + transform: translate(-50%, -50%); + background-color: #fff; + border: 2px solid #fff; + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + + ${StyledInteractive}:focus & { + transform: translate(-50%, -50%) scale(1.1); + } +` + +const Fill = styled.div` + content: ''; + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + pointer-events: none; + border-radius: inherit; +` + +interface Props { + className?: string + top?: number + left: number + color: string +} + +export const Pointer = ({ + className, + color, + left, + top = 0.5, +}: Props): JSX.Element => { + const style = { + top: `${top * 100}%`, + left: `${left * 100}%`, + } + + return ( + + + + ) +} diff --git a/theatre/studio/src/uiComponents/colorPicker/components/common/Saturation.tsx b/theatre/studio/src/uiComponents/colorPicker/components/common/Saturation.tsx new file mode 100644 index 0000000..0ec2852 --- /dev/null +++ b/theatre/studio/src/uiComponents/colorPicker/components/common/Saturation.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import type {Interaction} from './Interactive' +import {Interactive} from './Interactive' +import {Pointer} from './Pointer' +import type {HsvaColor} from '@theatre/studio/uiComponents/colorPicker/types' +import {hsvaToHslString} from '@theatre/studio/uiComponents/colorPicker/utils/convert' +import {clamp} from '@theatre/studio/uiComponents/colorPicker/utils/clamp' +import {round} from '@theatre/studio/uiComponents/colorPicker/utils/round' +import styled from 'styled-components' + +const Container = styled.div` + position: relative; + flex-grow: 1; + border-color: transparent; /* Fixes https://github.com/omgovich/react-colorful/issues/139 */ + border-bottom: 12px solid #000; + border-radius: 4px 4px 4px 4px; + background-image: linear-gradient(to top, #000, rgba(0, 0, 0, 0)), + linear-gradient(to right, #fff, rgba(255, 255, 255, 0)); + + // Improve elements rendering on light backgrounds + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.05); +` + +const StyledPointer = styled(Pointer)` + z-index: 3; +` + +interface Props { + hsva: HsvaColor + onChange: (newColor: {s: number; v: number}) => void +} + +const SaturationBase = ({hsva, onChange}: Props) => { + const handleMove = (interaction: Interaction) => { + onChange({ + s: interaction.left * 100, + v: 100 - interaction.top * 100, + }) + } + + const handleKey = (offset: Interaction) => { + // Saturation and brightness always fit into [0, 100] range + onChange({ + s: clamp(hsva.s + offset.left * 100, 0, 100), + v: clamp(hsva.v - offset.top * 100, 0, 100), + }) + } + + const containerStyle = { + backgroundColor: hsvaToHslString({h: hsva.h, s: 100, v: 100, a: 1}), + } + + return ( + + + + + + ) +} + +export const Saturation = React.memo(SaturationBase) diff --git a/theatre/studio/src/uiComponents/colorPicker/hooks/useColorManipulation.ts b/theatre/studio/src/uiComponents/colorPicker/hooks/useColorManipulation.ts new file mode 100644 index 0000000..d708ada --- /dev/null +++ b/theatre/studio/src/uiComponents/colorPicker/hooks/useColorManipulation.ts @@ -0,0 +1,105 @@ +import {useState, useEffect, useCallback, useRef} from 'react' +import type { + ColorModel, + AnyColor, + HsvaColor, +} from '@theatre/studio/uiComponents/colorPicker/types' +import {equalColorObjects} from '@theatre/studio/uiComponents/colorPicker/utils/compare' +import {useEventCallback} from './useEventCallback' +import {useEditing} from '@theatre/studio/uiComponents/colorPicker/components/EditingProvider' + +export function useColorManipulation( + colorModel: ColorModel, + color: T, + onTemporarilyChange: (color: T) => void, + onPermanentlyChange: (color: T) => void, +): [HsvaColor, (color: Partial) => void] { + const {editing} = useEditing() + const [editingValue, setEditingValue] = useState(color) + + // Save onChange callbacks in refs for to avoid unnecessarily updating when parent doesn't use useCallback + const onTemporarilyChangeCallback = useEventCallback(onTemporarilyChange) + const onPermanentlyChangeCallback = useEventCallback(onPermanentlyChange) + + // If editing, be uncontrolled, if not editing, be controlled + let value = editing ? editingValue : color + + // No matter which color model is used (HEX, RGB(A) or HSL(A)), + // all internal calculations are in HSVA + const [hsva, updateHsva] = useState(() => colorModel.toHsva(value)) + + // Use refs to prevent infinite update loops. They basically serve as a more + // explicit hack around the rigidity of React hooks' dep lists, since we want + // to do all color equality checks in HSVA, without breaking the roles of hooks. + // We use separate refs for temporary updates and permanent updates, + // since they are two independent update models. + const tempCache = useRef({color: value, hsva}) + const permCache = useRef({color: value, hsva}) + + // When entering editing mode, set the internal state of the uncontrolled mode + // to the last value of the controlled mode. + useEffect(() => { + if (editing) { + setEditingValue(tempCache.current.color) + } + }, [editing]) + + // Trigger `on*Change` callbacks only if an updated color is different from + // the cached one; save the new color to the ref to prevent unnecessary updates. + useEffect(() => { + let newColor = colorModel.fromHsva(hsva) + + if (editing) { + if ( + !equalColorObjects(hsva, tempCache.current.hsva) && + !colorModel.equal(newColor, tempCache.current.color) + ) { + console.log('hsva', hsva) + console.log('hsva cache', tempCache.current.hsva) + tempCache.current = {hsva, color: newColor} + + setEditingValue(newColor) + onTemporarilyChangeCallback(newColor) + } + } else { + if ( + !equalColorObjects(hsva, permCache.current.hsva) && + !colorModel.equal(newColor, permCache.current.color) + ) { + permCache.current = {hsva, color: newColor} + tempCache.current = {hsva, color: newColor} + + onPermanentlyChangeCallback(newColor) + } + } + }, [ + editing, + hsva, + colorModel, + onTemporarilyChangeCallback, + onPermanentlyChangeCallback, + ]) + + // This has to come after the callback calling effect, so that the cache isn't + // updated before the above effect checks for equality, otherwise no updates would + // be issued. + // Note: it doesn't make sense to have an editing version of this effect because + // the callback calling effect already updates the caches. + useEffect(() => { + if (!editing) { + if (!colorModel.equal(value, permCache.current.color)) { + const newHsva = colorModel.toHsva(value) + permCache.current = {hsva: newHsva, color: value} + updateHsva(newHsva) + } + } + }, [editing, value, colorModel]) + + // Merge the current HSVA color object with updated params. + // For example, when a child component sends `h` or `s` only + const handleChange = useCallback((params: Partial) => { + updateHsva((current) => Object.assign({}, current, params)) + }, []) + + return [hsva, handleChange] +} diff --git a/theatre/studio/src/uiComponents/colorPicker/hooks/useEventCallback.ts b/theatre/studio/src/uiComponents/colorPicker/hooks/useEventCallback.ts new file mode 100644 index 0000000..5ddec2d --- /dev/null +++ b/theatre/studio/src/uiComponents/colorPicker/hooks/useEventCallback.ts @@ -0,0 +1,14 @@ +import {useRef} from 'react' + +// Saves incoming handler to the ref in order to avoid "useCallback hell" +export function useEventCallback( + handler?: (value: T) => void, +): (value: T) => void { + const callbackRef = useRef(handler) + const fn = useRef((value: T) => { + callbackRef.current && callbackRef.current(value) + }) + callbackRef.current = handler + + return fn.current +} diff --git a/theatre/studio/src/uiComponents/colorPicker/hooks/useIsomorphicLayoutEffect.ts b/theatre/studio/src/uiComponents/colorPicker/hooks/useIsomorphicLayoutEffect.ts new file mode 100644 index 0000000..105fa5a --- /dev/null +++ b/theatre/studio/src/uiComponents/colorPicker/hooks/useIsomorphicLayoutEffect.ts @@ -0,0 +1,7 @@ +import {useLayoutEffect, useEffect} from 'react' + +// React currently throws a warning when using useLayoutEffect on the server. +// To get around it, we can conditionally useEffect on the server (no-op) and +// useLayoutEffect in the browser. +export const useIsomorphicLayoutEffect = + typeof window !== 'undefined' ? useLayoutEffect : useEffect diff --git a/theatre/studio/src/uiComponents/colorPicker/index.ts b/theatre/studio/src/uiComponents/colorPicker/index.ts new file mode 100644 index 0000000..a1aedb7 --- /dev/null +++ b/theatre/studio/src/uiComponents/colorPicker/index.ts @@ -0,0 +1,11 @@ +export {RgbaColorPicker} from './components/RgbaColorPicker' + +// Color model types +export type { + RgbColor, + RgbaColor, + HslColor, + HslaColor, + HsvColor, + HsvaColor, +} from './types' diff --git a/theatre/studio/src/uiComponents/colorPicker/types.ts b/theatre/studio/src/uiComponents/colorPicker/types.ts new file mode 100644 index 0000000..3b0f1d4 --- /dev/null +++ b/theatre/studio/src/uiComponents/colorPicker/types.ts @@ -0,0 +1,73 @@ +import type React from 'react' + +export interface RgbColor { + r: number + g: number + b: number +} + +export interface RgbaColor extends RgbColor { + a: number +} + +export interface HslColor { + h: number + s: number + l: number +} + +export interface HslaColor extends HslColor { + a: number +} + +export interface HsvColor { + h: number + s: number + v: number +} + +export interface HsvaColor extends HsvColor { + a: number +} + +export type ObjectColor = + | RgbColor + | HslColor + | HsvColor + | RgbaColor + | HslaColor + | HsvaColor + +export type AnyColor = string | ObjectColor + +export interface ColorModel { + defaultColor: T + toHsva: (defaultColor: T) => HsvaColor + fromHsva: (hsva: HsvaColor) => T + equal: (first: T, second: T) => boolean +} + +type ColorPickerHTMLAttributes = Partial< + Omit< + React.HTMLAttributes, + 'color' | 'onChange' | 'onChangeCapture' + > +> + +export interface ColorPickerBaseProps + extends ColorPickerHTMLAttributes { + color: T + temporarilySetValue: (newColor: T) => void + permanentlySetValue: (newColor: T) => void + discardTemporaryValue: () => void +} + +type ColorInputHTMLAttributes = Omit< + React.InputHTMLAttributes, + 'onChange' | 'value' +> + +export interface ColorInputBaseProps extends ColorInputHTMLAttributes { + color?: string + onChange?: (newColor: string) => void +} diff --git a/theatre/studio/src/uiComponents/colorPicker/utils/clamp.ts b/theatre/studio/src/uiComponents/colorPicker/utils/clamp.ts new file mode 100644 index 0000000..00fc430 --- /dev/null +++ b/theatre/studio/src/uiComponents/colorPicker/utils/clamp.ts @@ -0,0 +1,6 @@ +// Clamps a value between an upper and lower bound. +// We use ternary operators because it makes the minified code +// 2 times shorter then `Math.min(Math.max(a,b),c)` +export const clamp = (number: number, min = 0, max = 1): number => { + return number > max ? max : number < min ? min : number +} diff --git a/theatre/studio/src/uiComponents/colorPicker/utils/compare.ts b/theatre/studio/src/uiComponents/colorPicker/utils/compare.ts new file mode 100644 index 0000000..a9152ea --- /dev/null +++ b/theatre/studio/src/uiComponents/colorPicker/utils/compare.ts @@ -0,0 +1,35 @@ +import {hexToRgba} from './convert' +import type {ObjectColor} from '@theatre/studio/uiComponents/colorPicker/types' + +export const equalColorObjects = ( + first: ObjectColor, + second: ObjectColor, +): boolean => { + if (first === second) return true + + for (const prop in first) { + // The following allows for a type-safe calling of this function (first & second have to be HSL, HSV, or RGB) + // with type-unsafe iterating over object keys. TS does not allow this without an index (`[key: string]: number`) + // on an object to define how iteration is normally done. To ensure extra keys are not allowed on our types, + // we must cast our object to unknown (as RGB demands `r` be a key, while `Record` does not care if + // there is or not), and then as a type TS can iterate over. + if ( + (first as unknown as Record)[prop] !== + (second as unknown as Record)[prop] + ) + return false + } + + return true +} + +export const equalColorString = (first: string, second: string): boolean => { + return first.replace(/\s/g, '') === second.replace(/\s/g, '') +} + +export const equalHex = (first: string, second: string): boolean => { + if (first.toLowerCase() === second.toLowerCase()) return true + + // To compare colors like `#FFF` and `ffffff` we convert them into RGB objects + return equalColorObjects(hexToRgba(first), hexToRgba(second)) +} diff --git a/theatre/studio/src/uiComponents/colorPicker/utils/convert.ts b/theatre/studio/src/uiComponents/colorPicker/utils/convert.ts new file mode 100644 index 0000000..04aacb4 --- /dev/null +++ b/theatre/studio/src/uiComponents/colorPicker/utils/convert.ts @@ -0,0 +1,220 @@ +import {round} from './round' +import type { + RgbaColor, + RgbColor, + HslaColor, + HslColor, + HsvaColor, + HsvColor, +} from '@theatre/studio/uiComponents/colorPicker/types' + +/** + * Valid CSS units. + * https://developer.mozilla.org/en-US/docs/Web/CSS/angle + */ +const angleUnits: Record = { + grad: 360 / 400, + turn: 360, + rad: 360 / (Math.PI * 2), +} + +export const hexToHsva = (hex: string): HsvaColor => rgbaToHsva(hexToRgba(hex)) + +export const hexToRgba = (hex: string): RgbaColor => { + if (hex[0] === '#') hex = hex.substr(1) + + if (hex.length < 6) { + return { + r: parseInt(hex[0] + hex[0], 16), + g: parseInt(hex[1] + hex[1], 16), + b: parseInt(hex[2] + hex[2], 16), + a: 1, + } + } + + return { + r: parseInt(hex.substr(0, 2), 16), + g: parseInt(hex.substr(2, 2), 16), + b: parseInt(hex.substr(4, 2), 16), + a: 1, + } +} + +export const parseHue = (value: string, unit = 'deg'): number => { + return Number(value) * (angleUnits[unit] || 1) +} + +export const hslaStringToHsva = (hslString: string): HsvaColor => { + const matcher = + /hsla?\(?\s*(-?\d*\.?\d+)(deg|rad|grad|turn)?[,\s]+(-?\d*\.?\d+)%?[,\s]+(-?\d*\.?\d+)%?,?\s*[/\s]*(-?\d*\.?\d+)?(%)?\s*\)?/i + const match = matcher.exec(hslString) + + if (!match) return {h: 0, s: 0, v: 0, a: 1} + + return hslaToHsva({ + h: parseHue(match[1], match[2]), + s: Number(match[3]), + l: Number(match[4]), + a: match[5] === undefined ? 1 : Number(match[5]) / (match[6] ? 100 : 1), + }) +} + +export const hslStringToHsva = hslaStringToHsva + +export const hslaToHsva = ({h, s, l, a}: HslaColor): HsvaColor => { + s *= (l < 50 ? l : 100 - l) / 100 + + return { + h: h, + s: s > 0 ? ((2 * s) / (l + s)) * 100 : 0, + v: l + s, + a, + } +} + +export const hsvaToHex = (hsva: HsvaColor): string => + rgbaToHex(hsvaToRgba(hsva)) + +export const hsvaToHsla = ({h, s, v, a}: HsvaColor): HslaColor => { + const hh = ((200 - s) * v) / 100 + + return { + h: round(h), + s: round( + hh > 0 && hh < 200 + ? ((s * v) / 100 / (hh <= 100 ? hh : 200 - hh)) * 100 + : 0, + ), + l: round(hh / 2), + a: round(a, 2), + } +} + +export const hsvaToHslString = (hsva: HsvaColor): string => { + const {h, s, l} = hsvaToHsla(hsva) + return `hsl(${h}, ${s}%, ${l}%)` +} + +export const hsvaToHsvString = (hsva: HsvaColor): string => { + const {h, s, v} = roundHsva(hsva) + return `hsv(${h}, ${s}%, ${v}%)` +} + +export const hsvaToHsvaString = (hsva: HsvaColor): string => { + const {h, s, v, a} = roundHsva(hsva) + return `hsva(${h}, ${s}%, ${v}%, ${a})` +} + +export const hsvaToHslaString = (hsva: HsvaColor): string => { + const {h, s, l, a} = hsvaToHsla(hsva) + return `hsla(${h}, ${s}%, ${l}%, ${a})` +} + +export const hsvaToRgba = ({h, s, v, a}: HsvaColor): RgbaColor => { + h = (h / 360) * 6 + s = s / 100 + v = v / 100 + + const hh = Math.floor(h), + b = v * (1 - s), + c = v * (1 - (h - hh) * s), + d = v * (1 - (1 - h + hh) * s), + module = hh % 6 + + return { + r: round([v, c, b, b, d, v][module] * 255), + g: round([d, v, v, c, b, b][module] * 255), + b: round([b, b, d, v, v, c][module] * 255), + a: round(a, 2), + } +} + +export const hsvaToRgbString = (hsva: HsvaColor): string => { + const {r, g, b} = hsvaToRgba(hsva) + return `rgb(${r}, ${g}, ${b})` +} + +export const hsvaToRgbaString = (hsva: HsvaColor): string => { + const {r, g, b, a} = hsvaToRgba(hsva) + return `rgba(${r}, ${g}, ${b}, ${a})` +} + +export const hsvaStringToHsva = (hsvString: string): HsvaColor => { + const matcher = + /hsva?\(?\s*(-?\d*\.?\d+)(deg|rad|grad|turn)?[,\s]+(-?\d*\.?\d+)%?[,\s]+(-?\d*\.?\d+)%?,?\s*[/\s]*(-?\d*\.?\d+)?(%)?\s*\)?/i + const match = matcher.exec(hsvString) + + if (!match) return {h: 0, s: 0, v: 0, a: 1} + + return roundHsva({ + h: parseHue(match[1], match[2]), + s: Number(match[3]), + v: Number(match[4]), + a: match[5] === undefined ? 1 : Number(match[5]) / (match[6] ? 100 : 1), + }) +} + +export const hsvStringToHsva = hsvaStringToHsva + +export const rgbaStringToHsva = (rgbaString: string): HsvaColor => { + const matcher = + /rgba?\(?\s*(-?\d*\.?\d+)(%)?[,\s]+(-?\d*\.?\d+)(%)?[,\s]+(-?\d*\.?\d+)(%)?,?\s*[/\s]*(-?\d*\.?\d+)?(%)?\s*\)?/i + const match = matcher.exec(rgbaString) + + if (!match) return {h: 0, s: 0, v: 0, a: 1} + + return rgbaToHsva({ + r: Number(match[1]) / (match[2] ? 100 / 255 : 1), + g: Number(match[3]) / (match[4] ? 100 / 255 : 1), + b: Number(match[5]) / (match[6] ? 100 / 255 : 1), + a: match[7] === undefined ? 1 : Number(match[7]) / (match[8] ? 100 : 1), + }) +} + +export const rgbStringToHsva = rgbaStringToHsva + +const format = (number: number) => { + const hex = number.toString(16) + return hex.length < 2 ? '0' + hex : hex +} + +export const rgbaToHex = ({r, g, b}: RgbaColor): string => { + return '#' + format(r) + format(g) + format(b) +} + +export const rgbaToHsva = ({r, g, b, a}: RgbaColor): HsvaColor => { + const max = Math.max(r, g, b) + const delta = max - Math.min(r, g, b) + + // prettier-ignore + const hh = delta + ? max === r + ? (g - b) / delta + : max === g + ? 2 + (b - r) / delta + : 4 + (r - g) / delta + : 0; + + return { + h: round(60 * (hh < 0 ? hh + 6 : hh)), + s: round(max ? (delta / max) * 100 : 0), + v: round((max / 255) * 100), + a, + } +} + +export const roundHsva = (hsva: HsvaColor): HsvaColor => ({ + h: round(hsva.h), + s: round(hsva.s), + v: round(hsva.v), + a: round(hsva.a, 2), +}) + +export const rgbaToRgb = ({r, g, b}: RgbaColor): RgbColor => ({r, g, b}) + +export const hslaToHsl = ({h, s, l}: HslaColor): HslColor => ({h, s, l}) + +export const hsvaToHsv = (hsva: HsvaColor): HsvColor => { + const {h, s, v} = roundHsva(hsva) + return {h, s, v} +} diff --git a/theatre/studio/src/uiComponents/colorPicker/utils/round.ts b/theatre/studio/src/uiComponents/colorPicker/utils/round.ts new file mode 100644 index 0000000..b8ea771 --- /dev/null +++ b/theatre/studio/src/uiComponents/colorPicker/utils/round.ts @@ -0,0 +1,7 @@ +export const round = ( + number: number, + digits = 0, + base = Math.pow(10, digits), +): number => { + return Math.round(base * number) / base +} diff --git a/theatre/studio/src/uiComponents/colorPicker/utils/validate.ts b/theatre/studio/src/uiComponents/colorPicker/utils/validate.ts new file mode 100644 index 0000000..a8c6a14 --- /dev/null +++ b/theatre/studio/src/uiComponents/colorPicker/utils/validate.ts @@ -0,0 +1,13 @@ +const matcher = /^#?([0-9A-F]{3,8})$/i + +export const validHex = (value: string, alpha?: boolean): boolean => { + const match = matcher.exec(value) + const length = match ? match[1].length : 0 + + return ( + length === 3 || // '#rgb' format + length === 6 || // '#rrggbb' format + (!!alpha && length === 4) || // '#rgba' format + (!!alpha && length === 8) // '#rrggbbaa' format + ) +} diff --git a/theatre/studio/src/uiComponents/form/BasicStringInput.tsx b/theatre/studio/src/uiComponents/form/BasicStringInput.tsx index a41596c..d17fade 100644 --- a/theatre/studio/src/uiComponents/form/BasicStringInput.tsx +++ b/theatre/studio/src/uiComponents/form/BasicStringInput.tsx @@ -180,7 +180,7 @@ const BasicStringInput: React.FC<{ =16.8.0" + react-dom: ">=16.8.0" + checksum: 2b0beb90ab5c93c3b2a19c5a9c5c7ca9cedd5eeac61807335d4466f089cc3b3530724c0d685014a9ae0b4c40db638c8cfa27fccab6485f2d3cda00819edcceb7 + languageName: node + linkType: hard + "react-dev-utils@npm:^11.0.3": version: 11.0.4 resolution: "react-dev-utils@npm:11.0.4" @@ -22959,6 +22969,7 @@ fsevents@^1.2.7: prop-types: ^15.7.2 propose: ^0.0.5 react: ^17.0.2 + react-colorful: ^5.5.1 react-dom: ^17.0.2 react-error-boundary: ^3.1.3 react-icons: ^4.2.0