Added color reducer. Performed state management refactor to prevent circular behavior.

This commit is contained in:
Jay
2025-08-06 14:26:55 -04:00
parent c27a5258d3
commit e011bd0763
21 changed files with 2592 additions and 799 deletions
+6 -9
View File
@@ -1,13 +1,10 @@
import styles from "./ColorValues.module.css";
import * as colorlib from "colorlib";
function ColorValues() {
return (
<div className={styles.container}>
<div className={styles.valueItem}>RGB: 255, 0, 0</div>
<div className={styles.valueItem}>HEX: #FF0000</div>
<div className={styles.valueItem}>HSL: 0, 100%, 50%</div>
</div>
);
import styles from "./ColorValues.module.css";
import SpaceEditor from "./SpaceEditor";
function ColorValues({ selectedColor }: { selectedColor: colorlib.Color }) {
return <div className={styles.wrapper}></div>;
}
export default ColorValues;
@@ -0,0 +1,9 @@
import * as colorlib from "colorlib";
import styles from "./SpaceEditor.module.css";
function SpaceEditor({}: {}) {
return;
}
export default SpaceEditor;
@@ -2,7 +2,7 @@
display: flex;
align-items: stretch;
width: 100%;
height: 100%;
height: var(--height, 25px);
font-family: monospace;
border: 1px solid black;
border-top: none;
@@ -27,11 +27,16 @@
aspect-ratio: 1 / 1;
}
.sliderWrapper {
.sliderSection {
position: relative;
flex-grow: 1;
}
.sliderWrapper {
width: 100%;
height: 100%;
}
.sliderBar {
position: absolute;
top: 0;
+79 -122
View File
@@ -1,91 +1,62 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { ChangeEvent, Dispatch, RefObject, SetStateAction } from "react";
import type { ChangeEvent, RefObject } from "react";
import clsx from "clsx";
import type { CartesianSpace } from "@/types";
import { minmax, setMeasurements } from "@/util";
import type { CartesianSpace, Range, Setter, Timeout } from "@/types";
import { Direction } from "@/types";
import { minmax, setMeasurements, valueToPosition } from "@/util";
import { useScroll } from "@hooks/scroll";
import { Direction, useSlider } from "@hooks/slider";
import { useSlider } from "@hooks/slider";
import { useResize } from "@hooks/window";
import styles from "./ValueEditor.module.css";
// Types
type Timeout = ReturnType<typeof setTimeout>;
interface Range {
min: number;
max: number;
}
// Calculation functions
const getPositionFromValue = (
newValue: number,
maxValue: number,
range: Range,
) => {
const newPosition = parseFloat(
(
((newValue + Math.abs(range.min)) /
(Math.abs(range.min) + Math.abs(range.max))) *
maxValue
).toFixed(0),
);
return newPosition;
};
const getValueFromPosition = (
newPosition: number,
maxValue: number,
range: Range,
) => {
const newValue = parseFloat(
(
(newPosition / maxValue) * (Math.abs(range.min) + Math.abs(range.max)) -
Math.abs(range.min)
).toFixed(0),
);
return newValue;
};
// Component
function ValueEditor({
componentSymbol,
range,
valueRange,
value,
setValue,
scale = 1,
}: {
componentSymbol: string;
range: Range;
valueRange: Range;
value: number;
setValue: Dispatch<SetStateAction<number>>;
setValue: Setter<number>;
scale?: number;
}) {
// Set up component state
const direction = Direction.HORIZONTAL;
const [origin, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
const [position, setPosition] = useState(0);
const position = useRef(0);
useEffect(() => {
position.current = valueToPosition(value, dimensions.x, valueRange);
}, [value, dimensions, valueRange]);
// Handler functions
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const inputValue = parseInt(e.target.value, 10);
if (!isNaN(inputValue)) {
const newValue = minmax(inputValue, range.min, range.max);
const newPosition = getPositionFromValue(newValue, dimensions.x, range);
const actualValue = inputValue / scale;
const newValue = minmax(actualValue, valueRange.min, valueRange.max);
setValue(newValue);
setPosition(newPosition);
} else {
setValue(range.min);
setPosition(0);
setValue(valueRange.min);
}
};
const handleValueStep = (step: number) => {
setValue((prev) => {
const newValue = minmax(prev + step, range.min, range.max);
const newPosition = getPositionFromValue(newValue, dimensions.x, range);
setPosition(newPosition);
const scaledStep = step / scale;
const newValue = minmax(
prev + scaledStep,
valueRange.min,
valueRange.max,
);
return newValue;
});
};
@@ -95,7 +66,9 @@ function ValueEditor({
direction,
origin,
dimensions,
setPosition,
valueRange,
value,
setValue,
});
// Set component dimensions for slider hook
@@ -106,17 +79,6 @@ function ValueEditor({
);
}, [sliderRef, setOrigin, setDimensions]);
// Update value when position, etc. changes
useEffect(() => {
const maxValue = dimensions.x;
if (maxValue > 0) {
const newValue = getValueFromPosition(position, maxValue, range);
setValue(newValue);
} else {
setValue(range.min);
}
}, [dimensions, position, range, setValue]);
return (
<div
className={styles.componentWrapper}
@@ -128,9 +90,8 @@ function ValueEditor({
<Slider
sliderRef={sliderRef}
position={position}
value={value}
range={range}
position={position.current}
dimensions={dimensions}
componentSymbol={componentSymbol}
/>
@@ -143,9 +104,10 @@ function ValueEditor({
<Value
value={value}
onChange={handleInputChange}
range={range}
valueRange={valueRange}
componentSymbol={componentSymbol}
handleValueStep={handleValueStep}
scale={scale}
/>
<Button
@@ -173,33 +135,33 @@ function Label({ componentSymbol }: { componentSymbol: string }) {
function Slider({
sliderRef,
position,
value,
range,
dimensions,
componentSymbol,
}: {
sliderRef: RefObject<HTMLDivElement | null>;
position: number;
value: number;
range: { min: number; max: number };
dimensions: CartesianSpace;
componentSymbol: string;
}) {
return (
<div
className={clsx(styles.section, styles.sliderWrapper)}
ref={sliderRef}
role="slider"
aria-valuemin={range.min}
aria-valuemax={range.max}
aria-valuenow={value}
aria-labelledby={`${componentSymbol}-label`}
tabIndex={0}
data-cy={`${componentSymbol}-slider`}
>
<div className={clsx(styles.section, styles.sliderSection)}>
<div
className={styles.sliderBar}
style={{ width: position }}
data-cy={`${componentSymbol}-slider-bar`}
></div>
className={styles.sliderWrapper}
ref={sliderRef}
role="slider"
aria-valuemin={0}
aria-valuemax={dimensions.x}
aria-valuenow={position}
aria-labelledby={`${componentSymbol}-label`}
tabIndex={0}
data-cy={`${componentSymbol}-slider`}
>
<div
className={styles.sliderBar}
style={{ width: position }}
data-cy={`${componentSymbol}-slider-bar`}
></div>
</div>
</div>
);
}
@@ -240,15 +202,17 @@ function Button({
function Value({
value,
onChange,
range,
valueRange,
componentSymbol,
handleValueStep,
scale,
}: {
value: number;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
range: { min: number; max: number };
valueRange: { min: number; max: number };
componentSymbol: string;
handleValueStep: (step: number) => void;
scale: number;
}) {
const valueRef = useRef(null);
const valueScroller = useScroll({
@@ -263,9 +227,7 @@ function Value({
}
return () => {
if (valueRef.current) {
valueScroller.removeScrollListener();
}
valueScroller.removeScrollListener();
};
}, [valueScroller]);
@@ -274,13 +236,13 @@ function Value({
<input
type="text"
ref={valueRef}
value={value}
value={Math.round(value * scale)}
onChange={onChange}
className={styles.value}
onFocus={(e) => e.target.select()}
aria-label={`${componentSymbol} value input`}
aria-valuemin={range.min}
aria-valuemax={range.max}
aria-valuemin={valueRange.min}
aria-valuemax={valueRange.max}
aria-valuenow={value}
data-cy={`${componentSymbol}-value-input`}
/>
@@ -294,35 +256,30 @@ function useLongPressRepeat(
startDelay = 650,
repeatInterval = 150,
) {
const [pressing, setPressing] = useState(false);
const timerRef = useRef<Timeout>(null);
const timeoutRef = useRef<Timeout>(null);
const intervalRef = useRef<Timeout>(null);
const start = useCallback(
(e: Event | any) => {
e.preventDefault();
setPressing(true);
timerRef.current = setTimeout(() => {
callback();
intervalRef.current = setInterval(callback, repeatInterval);
}, startDelay);
},
[callback, startDelay, repeatInterval],
);
const stop = useCallback(() => {
setPressing(false);
if (timerRef.current) clearTimeout(timerRef.current);
const cleanup = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (intervalRef.current) clearInterval(intervalRef.current);
}, []);
timeoutRef.current = null;
intervalRef.current = null;
};
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, []);
// Intentional 'any' to avoid overly complex typing
const start = (e: Event | any) => {
e.preventDefault();
cleanup();
timeoutRef.current = setTimeout(() => {
callback();
intervalRef.current = setInterval(callback, repeatInterval);
}, startDelay);
};
const stop = cleanup;
useEffect(() => cleanup, []);
return {
onMouseDown: start,
@@ -330,8 +287,8 @@ function useLongPressRepeat(
onMouseLeave: stop,
onTouchStart: start,
onTouchEnd: stop,
// Intentional 'any' to avoid overly complex typing
onContextMenu: (e: Event | any) => e.preventDefault(),
pressing: pressing.toString(),
};
}
@@ -1,16 +1,26 @@
import { useState } from "react";
import { useReducer } from "react";
import { Color } from "colorlib";
import { colorReducer, createColorActions } from "@hooks/color";
import ValueEditor from "./ValueEditor";
const initialState = {
color: Color.from_hex("000"),
};
function TestWrapper() {
const [value, setValue] = useState(0);
const [state, dispatch] = useReducer(colorReducer, initialState);
const actions = createColorActions(dispatch);
return (
<div style={{ width: 400, height: 25 }}>
<div style={{ width: 400 }}>
<ValueEditor
componentSymbol="R"
range={{ min: 0, max: 255 }}
value={value}
setValue={setValue}
valueRange={{ min: 0, max: 255 }}
value={state.color.rgb.r}
setValue={actions.rgb.setR}
/>
</div>
);
@@ -37,9 +47,9 @@ describe("component editor tests", () => {
cy.dataCy("R-slider")
.click()
.dataCy("R-slider-bar")
.should("have.css", "width", "141px")
.should("have.css", "width", "140px")
.dataCy("R-value-input")
.should("have.value", "128");
.should("have.value", "127");
// Test slider scroll
cy.dataCy("R-slider")
@@ -47,7 +57,8 @@ describe("component editor tests", () => {
.trigger("wheel", { deltaY: -100, eventConstructor: "WheelEvent" })
.trigger("wheel", { deltaY: -100, eventConstructor: "WheelEvent" })
.dataCy("R-value-input")
.should("have.value", "129");
.should("have.value", "128")
.wait(50);
// Input focus should select text
cy.dataCy("R-value-input")
@@ -61,14 +72,15 @@ describe("component editor tests", () => {
.type("100")
.should("have.value", "100")
.dataCy("R-slider-bar")
.should("have.css", "width", "111px");
.should("have.css", "width", "110px");
// Scrolling input should update value
cy.dataCy("R-value-input")
.trigger("wheel", { deltaY: -100, eventConstructor: "WheelEvent" })
.should("have.value", "101")
.should("have.value", "100")
.dataCy("R-slider-bar")
.should("have.css", "width", "112px");
.should("have.css", "width", "111px")
.wait(50);
// Test increment/decrement buttons
cy.dataCy("R-decrement-button")
@@ -114,9 +126,9 @@ describe("component editor tests", () => {
cy.dataCy("R-slider")
.click()
.dataCy("R-slider-bar")
.should("have.css", "width", "141px")
.should("have.css", "width", "140px")
.dataCy("R-value-input")
.should("have.value", "128");
.should("have.value", "127");
// Test button long press repeat
cy.dataCy("R-decrement-button")
@@ -128,7 +140,7 @@ describe("component editor tests", () => {
.dataCy("R-decrement-button")
.trigger("touchend")
.dataCy("R-value-input")
.should("have.value", "125")
.should("have.value", "124")
.dataCy("R-increment-button")
.trigger("touchstart")
.then(() => {
@@ -138,6 +150,6 @@ describe("component editor tests", () => {
.dataCy("R-increment-button")
.trigger("touchend")
.dataCy("R-value-input")
.should("have.value", "128");
.should("have.value", "127");
});
});