Skip to content

Commit

Permalink
feat(ui): add color picker popover to color input (#962)
Browse files Browse the repository at this point in the history
  • Loading branch information
jmaen committed Mar 21, 2024
1 parent 109d84e commit 5ed7669
Show file tree
Hide file tree
Showing 6 changed files with 396 additions and 94 deletions.
85 changes: 63 additions & 22 deletions packages/ui/src/components/controls/ColorInput.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {Color} from '@motion-canvas/core';
import {valid} from 'chroma-js';
import clsx from 'clsx';
import {useRef, useState} from 'preact/hooks';
import {useReducedMotion} from '../../hooks';
import {useClickOutside} from '../../hooks/useClickOutside';
import {shake} from '../animations';
import {ColorPicker} from './ColorPicker';
import {ColorPreview} from './ColorPreview';
import styles from './Controls.module.scss';
import {Input} from './Input';
Expand All @@ -13,29 +15,68 @@ export interface ColorInputProps {
}

export function ColorInput({value, onChange}: ColorInputProps) {
const pickerRef = useRef<HTMLDivElement>();
const [position, setPosition] = useState<{x: number; y: number}>(null);
const reducedMotion = useReducedMotion();

useClickOutside(pickerRef, () => {
if (position) {
setPosition(null);
}
});

return (
<div className={clsx(styles.input, styles.color)}>
<Input
onChange={event => {
const input = event.target as HTMLInputElement;
const newValue = input.value;
if (!newValue || valid(newValue)) {
onChange(newValue);
} else {
input.value = value?.serialize() ?? '';
if (!reducedMotion) {
input.parentElement.animate(shake(2), {
duration: 300,
});
<>
<div className={styles.color}>
<Input
onChange={event => {
const input = event.target as HTMLInputElement;
const newValue = input.value;
if (!newValue || valid(newValue)) {
onChange(newValue);
} else {
input.value = value?.serialize() ?? '';
if (!reducedMotion) {
input.parentElement.animate(shake(2), {
duration: 300,
});
}
}
}
}}
placeholder="none"
type="text"
value={value?.serialize() ?? ''}
/>
<ColorPreview color={value?.hex() ?? '#00000000'} />
</div>
}}
placeholder="none"
type="text"
value={value?.serialize() ?? ''}
/>
<div
className={styles.button}
onPointerDown={event => {
event.preventDefault();
}}
onClick={event => {
const buttonRect = event.currentTarget.getBoundingClientRect();
const paneRect = document
.getElementById('settings-pane')
.getBoundingClientRect();
setPosition({
x: paneRect.right + 2 + 12, // 2px seperator, 12px padding
y: buttonRect.y,
});
}}
>
<ColorPreview color={value?.hex() ?? '#00000000'} />
</div>
</div>
{position && (
<ColorPicker
ref={pickerRef}
color={value ?? new Color('rgba(0, 0, 0, 0)')}
onChange={onChange}
style={{
left: position.x,
top: position.y,
}}
/>
)}
</>
);
}
156 changes: 156 additions & 0 deletions packages/ui/src/components/controls/ColorPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import {JSX, Ref} from 'preact';

import {Color} from '@motion-canvas/core';
import {hsv} from 'chroma-js';
import {forwardRef} from 'preact/compat';
import {useEffect, useRef, useState} from 'preact/hooks';
import {useSize} from '../../hooks';
import {MouseButton, clamp} from '../../utils';
import styles from './Controls.module.scss';
import {NumberInput} from './NumberInput';

type ColorPickerProps = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'onChange'> & {
color: Color;
onChange: (value: string) => void;
};

function ColorPickerInternal(
{color, onChange, ...props}: ColorPickerProps,
ref: Ref<HTMLDivElement>,
) {
const saturationRef = useRef<HTMLDivElement>();
const hueRef = useRef<HTMLDivElement>();
const saturationRect = useSize(saturationRef);
const hueRect = useSize(hueRef);

const [hue, setHue] = useState(
isNaN(color.hsv()[0]) ? 0 : color.hsv()[0] / 360,
);
const [saturation, setSaturation] = useState(color.hsv()[1]);
const [value, setValue] = useState(color.hsv()[2]);
const [alpha, setAlpha] = useState(color.alpha());

useEffect(() => {
onChange(
hsv(hue * 360, saturation, value)
.alpha(alpha)
.hex(),
);
}, [hue, saturation, value, alpha]);

return (
<div ref={ref} className={styles.colorPicker} {...props}>
<div
ref={saturationRef}
className={styles.saturation}
style={{backgroundColor: hsv(hue * 360, 1, 1).hex()}}
onPointerDown={event => {
if (event.button === MouseButton.Left) {
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);

const x = event.clientX - saturationRect.left;
const y = event.clientY - saturationRect.top;
const s = x / saturationRect.width;
const v = 1 - y / saturationRect.height;
setSaturation(clamp(0, 1, s));
setValue(clamp(0, 1, v));
}
}}
onPointerMove={event => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
const x = event.clientX - saturationRect.left;
const y = event.clientY - saturationRect.top;
const s = x / saturationRect.width;
const v = 1 - y / saturationRect.height;
setSaturation(clamp(0, 1, s));
setValue(clamp(0, 1, v));
}
}}
>
<div
class={styles.slider}
style={{
top: `calc(${(1 - value) * 100}% - 6px)`,
left: `calc(${saturation * 100}% - 6px)`,
backgroundColor: color.hex(),
}}
/>
</div>
<div
ref={hueRef}
className={styles.hue}
onPointerDown={event => {
if (event.button === MouseButton.Left) {
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);

const x = event.clientX - hueRect.left;
const h = x / hueRect.width;
setHue(clamp(0, 1, h));
}
}}
onPointerMove={event => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
const x = event.clientX - hueRect.left;
const h = x / hueRect.width;
setHue(clamp(0, 1, h));
}
}}
>
<div
class={styles.slider}
style={{
top: 6,
left: `calc(${hue * 100}% - 6px)`,
backgroundColor: hsv(hue * 360, 1, 1).hex(),
}}
/>
</div>

<NumberInput
value={hue}
onChange={h => setHue(clamp(0, 1, h))}
min={0}
max={1}
step={0.005}
decimalPlaces={4}
label={'H'}
/>

<NumberInput
value={saturation}
onChange={s => setSaturation(clamp(0, 1, s))}
min={0}
max={1}
step={0.005}
decimalPlaces={4}
label={'S'}
/>

<NumberInput
value={value}
onChange={v => setValue(clamp(0, 1, v))}
min={0}
max={1}
step={0.005}
decimalPlaces={4}
label={'V'}
/>

<NumberInput
value={alpha}
onChange={a => setAlpha(clamp(0, 1, a))}
min={0}
max={1}
step={0.005}
decimalPlaces={4}
label={'A'}
/>
</div>
);
}

export const ColorPicker = forwardRef(ColorPickerInternal);
107 changes: 90 additions & 17 deletions packages/ui/src/components/controls/Controls.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,17 @@
}
}

.numberInputLabel {
position: relative;

div {
position: absolute;
left: 8px;
bottom: 0;
font-family: var(--font-family-mono);
}
}

.checkbox {
width: 16px;
height: 16px;
Expand Down Expand Up @@ -165,23 +176,6 @@
-moz-appearance: textfield;
}

.color {
display: flex;
padding-left: 0;
align-items: center;

input {
width: 0;
margin: 0 8px 0 0;
padding-left: 8px;
appearance: none;
background-color: transparent;
&:hover {
background-color: transparent;
}
}
}

.select {
background-image: url('../../img/dropdown-light.svg');
background-repeat: no-repeat;
Expand All @@ -198,6 +192,85 @@
}
}

.color {
display: flex;
flex-grow: 1;
flex-shrink: 1;

.input {
width: 0;
border-radius: var(--radius) 0 0 var(--radius);
border-right: 1px solid var(--surface-color);
}

.button {
width: 0;
flex-grow: 0;
flex-basis: 0;
padding: 0 12px;
border-radius: 0 var(--radius) var(--radius) 0;
}
}

.colorPicker {
display: flex;
flex-direction: column;
position: fixed;
z-index: 9999;
padding: 16px;
border-radius: var(--radius);
background-color: var(--surface-color);

.saturation {
position: relative;
width: 200px;
height: 200px;
border-radius: var(--radius);
background-image: linear-gradient(transparent, black),
linear-gradient(to right, white, transparent);
cursor: pointer;

.slider {
border: 2px solid white;
}
}

.hue {
position: relative;
width: 200px;
height: 24px;
margin-top: 8px;
border-radius: var(--radius);
background-image: linear-gradient(
to right,
#ff0000,
#ffff00,
#00ff00,
#00ffff,
#0000ff,
#ff00ff,
#ff0000
);
cursor: pointer;

.slider {
border: 2px solid var(--background-color-dark);
}
}

.slider {
position: absolute;
z-index: 2;
width: 12px;
height: 12px;
border-radius: 50%;
}

.input {
margin-top: 8px;
}
}

.colorPreview {
width: 16px;
height: 16px;
Expand Down

0 comments on commit 5ed7669

Please sign in to comment.