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
+1482 -569
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -13,7 +13,8 @@
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"test:wasm": "cargo test --lib --manifest-path colorlib/Cargo.toml", "test:wasm": "cargo test --lib --manifest-path colorlib/Cargo.toml",
"test:wasmdoc": "cargo test --doc --manifest-path colorlib/Cargo.toml" "test:wasmdoc": "cargo test --doc --manifest-path colorlib/Cargo.toml",
"test": "vitest"
}, },
"dependencies": { "dependencies": {
"motion": "^12.23.12", "motion": "^12.23.12",
@@ -35,12 +36,14 @@
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19", "eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0", "globals": "^16.0.0",
"jsdom": "^26.1.0",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.30.1", "typescript-eslint": "^8.30.1",
"vite": "^6.3.5", "vite": "^6.3.5",
"vite-plugin-top-level-await": "^1.5.0", "vite-plugin-top-level-await": "^1.5.0",
"vite-plugin-wasm": "^3.4.1" "vite-plugin-wasm": "^3.4.1",
"vitest": "^3.2.4"
}, },
"prettier": { "prettier": {
"importOrder": [ "importOrder": [
+6 -9
View File
@@ -1,13 +1,10 @@
import styles from "./ColorValues.module.css"; import * as colorlib from "colorlib";
function ColorValues() { import styles from "./ColorValues.module.css";
return ( import SpaceEditor from "./SpaceEditor";
<div className={styles.container}>
<div className={styles.valueItem}>RGB: 255, 0, 0</div> function ColorValues({ selectedColor }: { selectedColor: colorlib.Color }) {
<div className={styles.valueItem}>HEX: #FF0000</div> return <div className={styles.wrapper}></div>;
<div className={styles.valueItem}>HSL: 0, 100%, 50%</div>
</div>
);
} }
export default ColorValues; 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; display: flex;
align-items: stretch; align-items: stretch;
width: 100%; width: 100%;
height: 100%; height: var(--height, 25px);
font-family: monospace; font-family: monospace;
border: 1px solid black; border: 1px solid black;
border-top: none; border-top: none;
@@ -27,11 +27,16 @@
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
} }
.sliderWrapper { .sliderSection {
position: relative; position: relative;
flex-grow: 1; flex-grow: 1;
} }
.sliderWrapper {
width: 100%;
height: 100%;
}
.sliderBar { .sliderBar {
position: absolute; position: absolute;
top: 0; top: 0;
+79 -122
View File
@@ -1,91 +1,62 @@
import { useCallback, useEffect, useRef, useState } from "react"; 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 clsx from "clsx";
import type { CartesianSpace } from "@/types"; import type { CartesianSpace, Range, Setter, Timeout } from "@/types";
import { minmax, setMeasurements } from "@/util"; import { Direction } from "@/types";
import { minmax, setMeasurements, valueToPosition } from "@/util";
import { useScroll } from "@hooks/scroll"; import { useScroll } from "@hooks/scroll";
import { Direction, useSlider } from "@hooks/slider"; import { useSlider } from "@hooks/slider";
import { useResize } from "@hooks/window"; import { useResize } from "@hooks/window";
import styles from "./ValueEditor.module.css"; 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 // Component
function ValueEditor({ function ValueEditor({
componentSymbol, componentSymbol,
range, valueRange,
value, value,
setValue, setValue,
scale = 1,
}: { }: {
componentSymbol: string; componentSymbol: string;
range: Range; valueRange: Range;
value: number; value: number;
setValue: Dispatch<SetStateAction<number>>; setValue: Setter<number>;
scale?: number;
}) { }) {
// Set up component state // Set up component state
const direction = Direction.HORIZONTAL; const direction = Direction.HORIZONTAL;
const [origin, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 }); const [origin, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
const [dimensions, setDimensions] = 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 // Handler functions
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const inputValue = parseInt(e.target.value, 10); const inputValue = parseInt(e.target.value, 10);
if (!isNaN(inputValue)) { if (!isNaN(inputValue)) {
const newValue = minmax(inputValue, range.min, range.max); const actualValue = inputValue / scale;
const newPosition = getPositionFromValue(newValue, dimensions.x, range); const newValue = minmax(actualValue, valueRange.min, valueRange.max);
setValue(newValue); setValue(newValue);
setPosition(newPosition);
} else { } else {
setValue(range.min); setValue(valueRange.min);
setPosition(0);
} }
}; };
const handleValueStep = (step: number) => { const handleValueStep = (step: number) => {
setValue((prev) => { setValue((prev) => {
const newValue = minmax(prev + step, range.min, range.max); const scaledStep = step / scale;
const newPosition = getPositionFromValue(newValue, dimensions.x, range); const newValue = minmax(
setPosition(newPosition); prev + scaledStep,
valueRange.min,
valueRange.max,
);
return newValue; return newValue;
}); });
}; };
@@ -95,7 +66,9 @@ function ValueEditor({
direction, direction,
origin, origin,
dimensions, dimensions,
setPosition, valueRange,
value,
setValue,
}); });
// Set component dimensions for slider hook // Set component dimensions for slider hook
@@ -106,17 +79,6 @@ function ValueEditor({
); );
}, [sliderRef, setOrigin, setDimensions]); }, [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 ( return (
<div <div
className={styles.componentWrapper} className={styles.componentWrapper}
@@ -128,9 +90,8 @@ function ValueEditor({
<Slider <Slider
sliderRef={sliderRef} sliderRef={sliderRef}
position={position} position={position.current}
value={value} dimensions={dimensions}
range={range}
componentSymbol={componentSymbol} componentSymbol={componentSymbol}
/> />
@@ -143,9 +104,10 @@ function ValueEditor({
<Value <Value
value={value} value={value}
onChange={handleInputChange} onChange={handleInputChange}
range={range} valueRange={valueRange}
componentSymbol={componentSymbol} componentSymbol={componentSymbol}
handleValueStep={handleValueStep} handleValueStep={handleValueStep}
scale={scale}
/> />
<Button <Button
@@ -173,33 +135,33 @@ function Label({ componentSymbol }: { componentSymbol: string }) {
function Slider({ function Slider({
sliderRef, sliderRef,
position, position,
value, dimensions,
range,
componentSymbol, componentSymbol,
}: { }: {
sliderRef: RefObject<HTMLDivElement | null>; sliderRef: RefObject<HTMLDivElement | null>;
position: number; position: number;
value: number; dimensions: CartesianSpace;
range: { min: number; max: number };
componentSymbol: string; componentSymbol: string;
}) { }) {
return ( return (
<div <div className={clsx(styles.section, styles.sliderSection)}>
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 <div
className={styles.sliderBar} className={styles.sliderWrapper}
style={{ width: position }} ref={sliderRef}
data-cy={`${componentSymbol}-slider-bar`} role="slider"
></div> 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> </div>
); );
} }
@@ -240,15 +202,17 @@ function Button({
function Value({ function Value({
value, value,
onChange, onChange,
range, valueRange,
componentSymbol, componentSymbol,
handleValueStep, handleValueStep,
scale,
}: { }: {
value: number; value: number;
onChange: (e: ChangeEvent<HTMLInputElement>) => void; onChange: (e: ChangeEvent<HTMLInputElement>) => void;
range: { min: number; max: number }; valueRange: { min: number; max: number };
componentSymbol: string; componentSymbol: string;
handleValueStep: (step: number) => void; handleValueStep: (step: number) => void;
scale: number;
}) { }) {
const valueRef = useRef(null); const valueRef = useRef(null);
const valueScroller = useScroll({ const valueScroller = useScroll({
@@ -263,9 +227,7 @@ function Value({
} }
return () => { return () => {
if (valueRef.current) { valueScroller.removeScrollListener();
valueScroller.removeScrollListener();
}
}; };
}, [valueScroller]); }, [valueScroller]);
@@ -274,13 +236,13 @@ function Value({
<input <input
type="text" type="text"
ref={valueRef} ref={valueRef}
value={value} value={Math.round(value * scale)}
onChange={onChange} onChange={onChange}
className={styles.value} className={styles.value}
onFocus={(e) => e.target.select()} onFocus={(e) => e.target.select()}
aria-label={`${componentSymbol} value input`} aria-label={`${componentSymbol} value input`}
aria-valuemin={range.min} aria-valuemin={valueRange.min}
aria-valuemax={range.max} aria-valuemax={valueRange.max}
aria-valuenow={value} aria-valuenow={value}
data-cy={`${componentSymbol}-value-input`} data-cy={`${componentSymbol}-value-input`}
/> />
@@ -294,35 +256,30 @@ function useLongPressRepeat(
startDelay = 650, startDelay = 650,
repeatInterval = 150, repeatInterval = 150,
) { ) {
const [pressing, setPressing] = useState(false); const timeoutRef = useRef<Timeout>(null);
const timerRef = useRef<Timeout>(null);
const intervalRef = useRef<Timeout>(null); const intervalRef = useRef<Timeout>(null);
const start = useCallback( const cleanup = () => {
(e: Event | any) => { if (timeoutRef.current) clearTimeout(timeoutRef.current);
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);
if (intervalRef.current) clearInterval(intervalRef.current); if (intervalRef.current) clearInterval(intervalRef.current);
}, []); timeoutRef.current = null;
intervalRef.current = null;
};
useEffect(() => { // Intentional 'any' to avoid overly complex typing
return () => { const start = (e: Event | any) => {
if (timerRef.current) clearTimeout(timerRef.current); e.preventDefault();
if (intervalRef.current) clearInterval(intervalRef.current); cleanup();
};
}, []); timeoutRef.current = setTimeout(() => {
callback();
intervalRef.current = setInterval(callback, repeatInterval);
}, startDelay);
};
const stop = cleanup;
useEffect(() => cleanup, []);
return { return {
onMouseDown: start, onMouseDown: start,
@@ -330,8 +287,8 @@ function useLongPressRepeat(
onMouseLeave: stop, onMouseLeave: stop,
onTouchStart: start, onTouchStart: start,
onTouchEnd: stop, onTouchEnd: stop,
// Intentional 'any' to avoid overly complex typing
onContextMenu: (e: Event | any) => e.preventDefault(), 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"; import ValueEditor from "./ValueEditor";
const initialState = {
color: Color.from_hex("000"),
};
function TestWrapper() { function TestWrapper() {
const [value, setValue] = useState(0); const [state, dispatch] = useReducer(colorReducer, initialState);
const actions = createColorActions(dispatch);
return ( return (
<div style={{ width: 400, height: 25 }}> <div style={{ width: 400 }}>
<ValueEditor <ValueEditor
componentSymbol="R" componentSymbol="R"
range={{ min: 0, max: 255 }} valueRange={{ min: 0, max: 255 }}
value={value} value={state.color.rgb.r}
setValue={setValue} setValue={actions.rgb.setR}
/> />
</div> </div>
); );
@@ -37,9 +47,9 @@ describe("component editor tests", () => {
cy.dataCy("R-slider") cy.dataCy("R-slider")
.click() .click()
.dataCy("R-slider-bar") .dataCy("R-slider-bar")
.should("have.css", "width", "141px") .should("have.css", "width", "140px")
.dataCy("R-value-input") .dataCy("R-value-input")
.should("have.value", "128"); .should("have.value", "127");
// Test slider scroll // Test slider scroll
cy.dataCy("R-slider") cy.dataCy("R-slider")
@@ -47,7 +57,8 @@ describe("component editor tests", () => {
.trigger("wheel", { deltaY: -100, eventConstructor: "WheelEvent" }) .trigger("wheel", { deltaY: -100, eventConstructor: "WheelEvent" })
.trigger("wheel", { deltaY: -100, eventConstructor: "WheelEvent" }) .trigger("wheel", { deltaY: -100, eventConstructor: "WheelEvent" })
.dataCy("R-value-input") .dataCy("R-value-input")
.should("have.value", "129"); .should("have.value", "128")
.wait(50);
// Input focus should select text // Input focus should select text
cy.dataCy("R-value-input") cy.dataCy("R-value-input")
@@ -61,14 +72,15 @@ describe("component editor tests", () => {
.type("100") .type("100")
.should("have.value", "100") .should("have.value", "100")
.dataCy("R-slider-bar") .dataCy("R-slider-bar")
.should("have.css", "width", "111px"); .should("have.css", "width", "110px");
// Scrolling input should update value // Scrolling input should update value
cy.dataCy("R-value-input") cy.dataCy("R-value-input")
.trigger("wheel", { deltaY: -100, eventConstructor: "WheelEvent" }) .trigger("wheel", { deltaY: -100, eventConstructor: "WheelEvent" })
.should("have.value", "101") .should("have.value", "100")
.dataCy("R-slider-bar") .dataCy("R-slider-bar")
.should("have.css", "width", "112px"); .should("have.css", "width", "111px")
.wait(50);
// Test increment/decrement buttons // Test increment/decrement buttons
cy.dataCy("R-decrement-button") cy.dataCy("R-decrement-button")
@@ -114,9 +126,9 @@ describe("component editor tests", () => {
cy.dataCy("R-slider") cy.dataCy("R-slider")
.click() .click()
.dataCy("R-slider-bar") .dataCy("R-slider-bar")
.should("have.css", "width", "141px") .should("have.css", "width", "140px")
.dataCy("R-value-input") .dataCy("R-value-input")
.should("have.value", "128"); .should("have.value", "127");
// Test button long press repeat // Test button long press repeat
cy.dataCy("R-decrement-button") cy.dataCy("R-decrement-button")
@@ -128,7 +140,7 @@ describe("component editor tests", () => {
.dataCy("R-decrement-button") .dataCy("R-decrement-button")
.trigger("touchend") .trigger("touchend")
.dataCy("R-value-input") .dataCy("R-value-input")
.should("have.value", "125") .should("have.value", "124")
.dataCy("R-increment-button") .dataCy("R-increment-button")
.trigger("touchstart") .trigger("touchstart")
.then(() => { .then(() => {
@@ -138,6 +150,6 @@ describe("component editor tests", () => {
.dataCy("R-increment-button") .dataCy("R-increment-button")
.trigger("touchend") .trigger("touchend")
.dataCy("R-value-input") .dataCy("R-value-input")
.should("have.value", "128"); .should("have.value", "127");
}); });
}); });
+143
View File
@@ -0,0 +1,143 @@
import type { Dispatch } from "react";
import * as colorlib from "colorlib";
import type { SetterValueOrCallback } from "@/types";
export interface ColorState {
color: colorlib.Color;
}
export type ColorAction =
| { type: "SET_COLOR"; payload: colorlib.Color }
| { type: "SET_RGB"; payload: colorlib.RGB }
| { type: "SET_HSV"; payload: colorlib.HSV }
| { type: "SET_HCL"; payload: colorlib.HCL }
| { type: "SET_HEX"; payload: colorlib.Hex }
| {
type: "SET_VALUE";
component: colorlib.Component;
payload: SetterValueOrCallback<number>;
};
export function colorReducer(
state: ColorState,
action: ColorAction,
): ColorState {
let comp;
switch (action.type) {
case "SET_COLOR":
return { ...state, color: action.payload };
case "SET_RGB":
let rgb = action.payload;
return { ...state, color: colorlib.Color.from_rgb(rgb.r, rgb.g, rgb.b) };
case "SET_HSV":
let hsv = action.payload;
return { ...state, color: colorlib.Color.from_hsv(hsv.h, hsv.s, hsv.v) };
case "SET_HCL":
let hcl = action.payload;
return { ...state, color: colorlib.Color.from_hcl(hcl.h, hcl.c, hcl.l) };
case "SET_HEX":
let hex = action.payload;
return { ...state, color: colorlib.Color.from_hex(hex.to_code()) };
case "SET_VALUE":
comp = action.component;
let valOrFn = action.payload;
if (typeof valOrFn === "function") {
let prev = state.color.get(comp);
return { ...state, color: state.color.update(comp, valOrFn(prev)) };
} else {
return { ...state, color: state.color.update(comp, valOrFn) };
}
default:
return state;
}
}
type Setter = (valOrCallback: SetterValueOrCallback<number>) => void;
export interface CommonColorActions {
setColor: (color: colorlib.Color) => void;
}
export interface RGBColorActions {
setRGB: (rgb: colorlib.RGB) => void;
setR: Setter;
setG: Setter;
setB: Setter;
}
export interface HSVColorActions {
setHSV: (hsv: colorlib.HSV) => void;
setH: Setter;
setS: Setter;
setV: Setter;
}
export interface HCLColorActions {
setHCL: (hcl: colorlib.HCL) => void;
setH: Setter;
setC: Setter;
setL: Setter;
}
export interface HexColorActions {
setHex: (hex: colorlib.Hex) => void;
}
export interface ColorActions {
common: CommonColorActions;
rgb: RGBColorActions;
hsv: HSVColorActions;
hcl: HCLColorActions;
hex: HexColorActions;
}
export function createColorActions(
dispatch: Dispatch<ColorAction>,
): ColorActions {
const Comp = colorlib.Component;
const setValue = (
comp: colorlib.Component,
payload: SetterValueOrCallback<number>,
) => dispatch({ type: "SET_VALUE", component: comp, payload });
return {
common: {
setColor: (payload) => dispatch({ type: "SET_COLOR", payload }),
},
rgb: {
setRGB: (rgb) => dispatch({ type: "SET_RGB", payload: rgb }),
setR: (val) => setValue(Comp.RGB_R, val),
setG: (val) => setValue(Comp.RGB_G, val),
setB: (val) => setValue(Comp.RGB_B, val),
},
hsv: {
setHSV: (hsv) => dispatch({ type: "SET_HSV", payload: hsv }),
setH: (val) => setValue(Comp.HSV_H, val),
setS: (val) => setValue(Comp.HSV_S, val),
setV: (val) => setValue(Comp.HSV_V, val),
},
hcl: {
setHCL: (hcl) => dispatch({ type: "SET_HCL", payload: hcl }),
setH: (val) => setValue(Comp.HCL_H, val),
setC: (val) => setValue(Comp.HCL_C, val),
setL: (val) => setValue(Comp.HCL_L, val),
},
hex: {
setHex: (hex) => dispatch({ type: "SET_HEX", payload: hex }),
},
};
}
+47 -17
View File
@@ -1,12 +1,13 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import type { CartesianSpace } from "@/types"; import type { CartesianSpace, Range, Setter } from "@/types";
import { import {
extractEventCoordinates, extractEventCoordinates,
isLeftMouseButton, isLeftMouseButton,
isTouchEvent, isTouchEvent,
minmax, minmax,
positionToValue,
valueToPosition,
} from "@/util"; } from "@/util";
if (typeof TouchEvent === "undefined") { if (typeof TouchEvent === "undefined") {
@@ -19,37 +20,57 @@ export function useCrosshair({
dimensions, dimensions,
setXPosition, setXPosition,
setYPosition, setYPosition,
xValue,
yValue,
setXValue,
setYValue,
xValueRange,
yValueRange,
}: { }: {
origin: CartesianSpace; origin: CartesianSpace;
dimensions: CartesianSpace; dimensions: CartesianSpace;
setXPosition: Dispatch<SetStateAction<number>>; setXPosition: Setter<number>;
setYPosition: Dispatch<SetStateAction<number>>; setYPosition: Setter<number>;
xValue: number;
yValue: number;
setXValue: Setter<number>;
setYValue: Setter<number>;
xValueRange: Range;
yValueRange: Range;
}) { }) {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const crosshairRef = useRef<HTMLDivElement>(null); const crosshairRef = useRef<HTMLDivElement>(null);
const originRef = useRef(origin); const originRef = useRef(origin);
const dimensionsRef = useRef(dimensions); const dimensionsRef = useRef(dimensions);
const setXValueRef = useRef(setXValue);
const setYValueRef = useRef(setYValue);
const xValueRangeRef = useRef(xValueRange);
const yValueRangeRef = useRef(yValueRange);
useEffect(() => { useEffect(() => {
originRef.current = origin; originRef.current = origin;
dimensionsRef.current = dimensions; dimensionsRef.current = dimensions;
}, [origin, dimensions]); setXValueRef.current = setXValue;
setYValueRef.current = setYValue;
xValueRangeRef.current = xValueRange;
yValueRangeRef.current = yValueRange;
}, [origin, dimensions, setXValue, setYValue, xValueRange, yValueRange]);
const calculatePositions = useCallback( const calculatePositions = useCallback((event: MouseEvent | TouchEvent) => {
(event: MouseEvent | TouchEvent) => { const orig = originRef.current;
const orig = originRef.current; const dims = dimensionsRef.current;
const dims = dimensionsRef.current;
const { clientX, clientY } = extractEventCoordinates(event); const { clientX, clientY } = extractEventCoordinates(event);
const xPos = minmax(clientX - orig.x, 0, dims.x - 1); const xPos = minmax(clientX - orig.x, 0, dims.x - 1);
const yPos = minmax(clientY - orig.y, 0, dims.y - 1); const yPos = minmax(clientY - orig.y, 0, dims.y - 1);
setXPosition(xPos); const newXValue = positionToValue(xPos, dims.x - 1, xValueRangeRef.current);
setYPosition(yPos); const newYValue = positionToValue(yPos, dims.y - 1, yValueRangeRef.current);
},
[setXPosition, setYPosition], setXValueRef.current(newXValue);
); setYValueRef.current(newYValue);
}, []);
const handleMove = useCallback( const handleMove = useCallback(
(event: MouseEvent | TouchEvent) => { (event: MouseEvent | TouchEvent) => {
@@ -101,6 +122,15 @@ export function useCrosshair({
[calculatePositions, handleMove, handleEnd], [calculatePositions, handleMove, handleEnd],
); );
useEffect(() => {
const dims = dimensionsRef.current;
const newXPos = valueToPosition(xValue, dims.x - 1, xValueRangeRef.current);
const newYPos = valueToPosition(yValue, dims.y - 1, yValueRangeRef.current);
if (newXPos === newXPos) setXPosition(newXPos);
if (newYPos === newYPos) setYPosition(newYPos);
}, [xValue, yValue, setXPosition, setYPosition]);
useEffect(() => { useEffect(() => {
const currentRef = crosshairRef.current; const currentRef = crosshairRef.current;
if (currentRef) { if (currentRef) {
+7 -9
View File
@@ -35,7 +35,7 @@ export function useScroll<T extends HTMLElement>({
onScrollDown: ScrollHandler; onScrollDown: ScrollHandler;
deltaYMultiplier?: number; deltaYMultiplier?: number;
}) { }) {
const [_, setScrollLength] = useState(0); const scrollLength = useRef(0);
const onScrollUpRef = useRef(onScrollUp); const onScrollUpRef = useRef(onScrollUp);
const onScrollDownRef = useRef(onScrollDown); const onScrollDownRef = useRef(onScrollDown);
@@ -49,16 +49,14 @@ export function useScroll<T extends HTMLElement>({
const handleWheelEvent = useCallback((event: WheelEvent) => { const handleWheelEvent = useCallback((event: WheelEvent) => {
event.preventDefault(); event.preventDefault();
console.log("Handling wheel event.");
setScrollLength((prev) => const newScrollLength = handleScroll(
handleScroll( scrollLength.current,
prev, event.deltaY * deltaYMultiplierRef.current,
event.deltaY * deltaYMultiplierRef.current, onScrollDownRef.current,
onScrollDownRef.current, onScrollUpRef.current,
onScrollUpRef.current,
),
); );
scrollLength.current = newScrollLength;
}, []); }, []);
const addScrollListener = useCallback(() => { const addScrollListener = useCallback(() => {
+86 -39
View File
@@ -1,12 +1,15 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import type { CartesianSpace } from "@/types"; import type { CartesianSpace, Range, Setter } from "@/types";
import { Direction } from "@/types";
import { import {
chooseValueByDirection,
extractEventCoordinates, extractEventCoordinates,
isLeftMouseButton, isLeftMouseButton,
isTouchEvent, isTouchEvent,
minmax, minmax,
positionToValue,
valueToPosition,
} from "@/util"; } from "@/util";
import { useScroll } from "./scroll"; import { useScroll } from "./scroll";
@@ -16,19 +19,6 @@ if (typeof TouchEvent === "undefined") {
window.TouchEvent = window.MouseEvent; window.TouchEvent = window.MouseEvent;
} }
export enum Direction {
HORIZONTAL = "horizontal",
VERTICAL = "vertical",
}
function chooseValueByDirection(
direction: Direction,
xValue: number,
yValue: number,
) {
return direction === Direction.HORIZONTAL ? xValue : yValue;
}
function extractEventCoordinateByDirection( function extractEventCoordinateByDirection(
event: MouseEvent | TouchEvent, event: MouseEvent | TouchEvent,
direction: Direction, direction: Direction,
@@ -41,43 +31,74 @@ export function useSlider({
direction, direction,
origin, origin,
dimensions, dimensions,
setPosition, valueRange,
value,
setValue,
}: { }: {
direction: Direction; direction: Direction;
origin: CartesianSpace; origin: CartesianSpace;
dimensions: CartesianSpace; dimensions: CartesianSpace;
setPosition: Dispatch<SetStateAction<number>>; valueRange: Range;
value: number;
setValue: Setter<number>;
}) { }) {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const sliderRef = useRef<HTMLDivElement>(null); const sliderRef = useRef<HTMLDivElement>(null);
// Slider UI refs
const directionRef = useRef(direction); const directionRef = useRef(direction);
const originRef = useRef(origin); const originRef = useRef(origin);
const dimensionsRef = useRef(dimensions); const dimensionsRef = useRef(dimensions);
// Slider value refs
const setValueRef = useRef(setValue);
const valueRangeRef = useRef(valueRange);
const maxPosition = useRef(0);
// Internal position management
const [position, setPosition] = useState(0);
const positionRef = useRef(0);
useEffect(() => { useEffect(() => {
directionRef.current = direction; directionRef.current = direction;
originRef.current = origin; originRef.current = origin;
dimensionsRef.current = dimensions; dimensionsRef.current = dimensions;
}, [direction, origin, dimensions]); maxPosition.current = chooseValueByDirection(
direction,
dimensions.x,
dimensions.y,
);
valueRangeRef.current = valueRange;
}, [direction, origin, dimensions, valueRangeRef]);
useEffect(() => {
setValueRef.current = setValue;
}, [setValue]);
useEffect(() => {
positionRef.current = position;
}, [position]);
// Setup drag handlers // Setup drag handlers
const calculatePosition = useCallback( const calculatePosition = useCallback((event: MouseEvent | TouchEvent) => {
(event: MouseEvent | TouchEvent) => { const dir = directionRef.current;
const dir = directionRef.current; const orig = originRef.current;
const orig = originRef.current; const dims = dimensionsRef.current;
const dims = dimensionsRef.current;
const clientCoord = extractEventCoordinateByDirection(event, dir); const clientCoord = extractEventCoordinateByDirection(event, dir);
const positionValue = minmax( const newPosition = minmax(
clientCoord - chooseValueByDirection(dir, orig.x, orig.y), clientCoord - chooseValueByDirection(dir, orig.x, orig.y),
0, 0,
chooseValueByDirection(dir, dims.x, dims.y), chooseValueByDirection(dir, dims.x, dims.y),
); );
setPosition(positionValue); const newValue = positionToValue(
}, newPosition,
[setPosition], maxPosition.current,
); valueRangeRef.current,
);
setValueRef.current(newValue);
}, []);
const handleMove = useCallback( const handleMove = useCallback(
(event: MouseEvent | TouchEvent) => { (event: MouseEvent | TouchEvent) => {
@@ -120,20 +141,37 @@ export function useSlider({
const dims = dimensionsRef.current; const dims = dimensionsRef.current;
const inc = chooseValueByDirection(dir, 1, -1); const inc = chooseValueByDirection(dir, 1, -1);
setPosition((prev: number) => const newPosition = minmax(
minmax(prev + inc, 0, chooseValueByDirection(dir, dims.x, dims.y)), positionRef.current + inc,
0,
chooseValueByDirection(dir, dims.x, dims.y),
); );
}, [setPosition]); const newValue = positionToValue(
newPosition,
maxPosition.current,
valueRangeRef.current,
);
setValueRef.current(newValue);
}, []);
const handleScrollDown = useCallback(() => { const handleScrollDown = useCallback(() => {
const dir = directionRef.current; const dir = directionRef.current;
const dims = dimensionsRef.current; const dims = dimensionsRef.current;
const inc = chooseValueByDirection(dir, -1, 1); const inc = chooseValueByDirection(dir, -1, 1);
setPosition((prev: number) => const newPosition = minmax(
minmax(prev + inc, 0, chooseValueByDirection(dir, dims.x, dims.y)), positionRef.current + inc,
0,
chooseValueByDirection(dir, dims.x, dims.y),
); );
}, [setPosition]); const newValue = positionToValue(
newPosition,
maxPosition.current,
valueRangeRef.current,
);
setValueRef.current(newValue);
}, []);
const { addScrollListener, removeScrollListener } = useScroll({ const { addScrollListener, removeScrollListener } = useScroll({
targetRef: sliderRef, targetRef: sliderRef,
@@ -141,6 +179,15 @@ export function useSlider({
onScrollDown: handleScrollDown, onScrollDown: handleScrollDown,
}); });
useEffect(() => {
const newPosition = valueToPosition(
value,
maxPosition.current,
valueRangeRef.current,
);
setPosition(newPosition);
}, [value, setPosition]);
// Set up entry listeners // Set up entry listeners
useEffect(() => { useEffect(() => {
const currentRef = sliderRef.current; const currentRef = sliderRef.current;
+572
View File
@@ -0,0 +1,572 @@
import * as colorlib from "colorlib";
import { beforeEach, describe, expect, test } from "vitest";
import { colorReducer, createColorActions } from "../color";
import type { ColorAction, ColorActions, ColorState } from "../color";
const mockUseReducer = <T extends object, U>(
reducer: (state: T, action: U) => T,
initialArg: T,
): [T, (value: U) => void] => {
let currentState = initialArg;
const state = new Proxy({} as T, {
get: (_, prop) => currentState[prop as keyof T],
});
const dispatch = (value: U) => {
const nextState = reducer(currentState, value);
currentState = nextState;
};
return [state, dispatch];
};
const expectRGB = (value: colorlib.RGB, expected: colorlib.RGB) => {
expect(value.r).toBe(expected.r);
expect(value.g).toBe(expected.g);
expect(value.b).toBe(expected.b);
};
const expectHSV = (value: colorlib.HSV, expected: colorlib.HSV) => {
expect(value.h).toBe(expected.h);
expect(value.s).toBe(expected.s);
expect(value.v).toBe(expected.v);
};
const expectHCL = (value: colorlib.HCL, expected: colorlib.HCL) => {
expect(value.h).toBe(expected.h);
expect(value.c).toBe(expected.c);
expect(value.l).toBe(expected.l);
};
describe("color reducer", () => {
const initialState = { color: colorlib.Color.from_hex("000") };
describe("set by color", () => {
test("set color", () => {
const nextColor = colorlib.Color.from_hex("F00");
const nextState = colorReducer(initialState, {
type: "SET_COLOR",
payload: nextColor,
});
expect(nextState.color.hex.to_code()).toBe(nextColor.hex.to_code());
});
});
describe("set by color space", () => {
test("set rgb", () => {
const nextColor = colorlib.RGB.new(1, 2, 3);
const nextState = colorReducer(initialState, {
type: "SET_RGB",
payload: nextColor,
});
expectRGB(nextState.color.rgb, nextColor);
});
test("set hsv", () => {
const nextColor = colorlib.HSV.new(1, 2, 3);
const nextState = colorReducer(initialState, {
type: "SET_HSV",
payload: nextColor,
});
expectHSV(nextState.color.hsv, nextColor);
});
test("set hcl", () => {
const nextColor = colorlib.HCL.new(1, 2, 3);
const nextState = colorReducer(initialState, {
type: "SET_HCL",
payload: nextColor,
});
expectHCL(nextState.color.hcl, nextColor);
});
test("set hex", () => {
const nextColor = colorlib.Hex.new(1, 2, 3);
const nextState = colorReducer(initialState, {
type: "SET_HEX",
payload: nextColor,
});
expect(nextState.color.hex.to_code()).toBe(nextColor.to_code());
});
});
describe("set by component", () => {
describe("rgb", () => {
test("set rgb r", () => {
const nextColor = colorlib.RGB.new(100, 0, 0);
const nextState = colorReducer(initialState, {
type: "SET_VALUE",
component: colorlib.Component.RGB_R,
payload: nextColor.r,
});
expectRGB(nextState.color.rgb, nextColor);
});
test("set rgb g", () => {
const nextColor = colorlib.RGB.new(0, 100, 0);
const nextState = colorReducer(initialState, {
type: "SET_VALUE",
component: colorlib.Component.RGB_G,
payload: nextColor.g,
});
expectRGB(nextState.color.rgb, nextColor);
});
test("set rgb b", () => {
const nextColor = colorlib.RGB.new(0, 0, 100);
const nextState = colorReducer(initialState, {
type: "SET_VALUE",
component: colorlib.Component.RGB_B,
payload: nextColor.b,
});
expectRGB(nextState.color.rgb, nextColor);
});
});
describe("hsv", () => {
test("set hsv h", () => {
const nextColor = colorlib.HSV.new(100, 0, 0);
const nextState = colorReducer(initialState, {
type: "SET_VALUE",
component: colorlib.Component.HSV_H,
payload: nextColor.h,
});
expectHSV(nextState.color.hsv, nextColor);
});
test("set hsv s", () => {
const nextColor = colorlib.HSV.new(0, 0.5, 0);
const nextState = colorReducer(initialState, {
type: "SET_VALUE",
component: colorlib.Component.HSV_S,
payload: nextColor.s,
});
expectHSV(nextState.color.hsv, nextColor);
});
test("set hsv v", () => {
const nextColor = colorlib.HSV.new(0, 0, 0.5);
const nextState = colorReducer(initialState, {
type: "SET_VALUE",
component: colorlib.Component.HSV_V,
payload: nextColor.v,
});
expectHSV(nextState.color.hsv, nextColor);
});
});
describe("hcl", () => {
test("set hcl h", () => {
const nextColor = colorlib.HCL.new(100, 0, 0);
const nextState = colorReducer(initialState, {
type: "SET_VALUE",
component: colorlib.Component.HCL_H,
payload: nextColor.h,
});
expectHCL(nextState.color.hcl, nextColor);
});
test("set hcl c", () => {
const nextColor = colorlib.HCL.new(0, 0.5, 0);
const nextState = colorReducer(initialState, {
type: "SET_VALUE",
component: colorlib.Component.HCL_C,
payload: nextColor.c,
});
expectHCL(nextState.color.hcl, nextColor);
});
test("set hcl l", () => {
const nextColor = colorlib.HCL.new(0, 0, 0.5);
const nextState = colorReducer(initialState, {
type: "SET_VALUE",
component: colorlib.Component.HCL_L,
payload: nextColor.l,
});
expectHCL(nextState.color.hcl, nextColor);
});
});
});
describe("adjust by component", () => {
describe("rgb", () => {
test("adjust rgb r", () => {
let nextColor = colorlib.RGB.new(100, 0, 0);
let nextState = colorReducer(initialState, {
type: "SET_VALUE",
component: colorlib.Component.RGB_R,
payload: (prev) => prev + 100,
});
expectRGB(nextState.color.rgb, nextColor);
nextColor = colorlib.RGB.new(50, 0, 0);
nextState = colorReducer(nextState, {
type: "SET_VALUE",
component: colorlib.Component.RGB_R,
payload: (prev) => prev - 50,
});
expectRGB(nextState.color.rgb, nextColor);
});
test("adjust rgb g", () => {
let nextColor = colorlib.RGB.new(0, 100, 0);
let nextState = colorReducer(initialState, {
type: "SET_VALUE",
component: colorlib.Component.RGB_G,
payload: (prev) => prev + 100,
});
expectRGB(nextState.color.rgb, nextColor);
nextColor = colorlib.RGB.new(0, 50, 0);
nextState = colorReducer(nextState, {
type: "SET_VALUE",
component: colorlib.Component.RGB_G,
payload: (prev) => prev - 50,
});
expectRGB(nextState.color.rgb, nextColor);
});
test("adjust rgb b", () => {
let nextColor = colorlib.RGB.new(0, 0, 100);
let nextState = colorReducer(initialState, {
type: "SET_VALUE",
component: colorlib.Component.RGB_B,
payload: (prev) => prev + 100,
});
expectRGB(nextState.color.rgb, nextColor);
nextColor = colorlib.RGB.new(0, 0, 50);
nextState = colorReducer(nextState, {
type: "SET_VALUE",
component: colorlib.Component.RGB_B,
payload: (prev) => prev - 50,
});
expectRGB(nextState.color.rgb, nextColor);
});
});
describe("hsv", () => {
test("adjust hsv h", () => {
let nextColor = colorlib.HSV.new(100, 0, 0);
let nextState = colorReducer(initialState, {
type: "SET_VALUE",
component: colorlib.Component.HSV_H,
payload: (prev) => prev + 100,
});
expectHSV(nextState.color.hsv, nextColor);
nextColor = colorlib.HSV.new(50, 0, 0);
nextState = colorReducer(nextState, {
type: "SET_VALUE",
component: colorlib.Component.HSV_H,
payload: (prev) => prev - 50,
});
expectHSV(nextState.color.hsv, nextColor);
});
test("adjust hsv s", () => {
let nextColor = colorlib.HSV.new(0, 1, 0);
let nextState = colorReducer(initialState, {
type: "SET_VALUE",
component: colorlib.Component.HSV_S,
payload: (prev) => prev + 1,
});
expectHSV(nextState.color.hsv, nextColor);
nextColor = colorlib.HSV.new(0, 0.5, 0);
nextState = colorReducer(nextState, {
type: "SET_VALUE",
component: colorlib.Component.HSV_S,
payload: (prev) => prev - 0.5,
});
expectHSV(nextState.color.hsv, nextColor);
});
test("adjust hsv v", () => {
let nextColor = colorlib.HSV.new(0, 0, 1);
let nextState = colorReducer(initialState, {
type: "SET_VALUE",
component: colorlib.Component.HSV_V,
payload: (prev) => prev + 1,
});
expectHSV(nextState.color.hsv, nextColor);
nextColor = colorlib.HSV.new(0, 0, 0.5);
nextState = colorReducer(nextState, {
type: "SET_VALUE",
component: colorlib.Component.HSV_V,
payload: (prev) => prev - 0.5,
});
expectHSV(nextState.color.hsv, nextColor);
});
});
describe("hcl", () => {
test("adjust hcl h", () => {
let nextColor = colorlib.HCL.new(100, 0, 0);
let nextState = colorReducer(initialState, {
type: "SET_VALUE",
component: colorlib.Component.HCL_H,
payload: (prev) => prev + 100,
});
expectHCL(nextState.color.hcl, nextColor);
nextColor = colorlib.HCL.new(50, 0, 0);
nextState = colorReducer(nextState, {
type: "SET_VALUE",
component: colorlib.Component.HCL_H,
payload: (prev) => prev - 50,
});
expectHCL(nextState.color.hcl, nextColor);
});
test("adjust hcl c", () => {
let nextColor = colorlib.HCL.new(0, 1, 0);
let nextState = colorReducer(initialState, {
type: "SET_VALUE",
component: colorlib.Component.HCL_C,
payload: (prev) => prev + 1,
});
expectHCL(nextState.color.hcl, nextColor);
nextColor = colorlib.HCL.new(0, 0.5, 0);
nextState = colorReducer(nextState, {
type: "SET_VALUE",
component: colorlib.Component.HCL_C,
payload: (prev) => prev - 0.5,
});
expectHCL(nextState.color.hcl, nextColor);
});
test("adjust hcl l", () => {
let nextColor = colorlib.HCL.new(0, 0, 1);
let nextState = colorReducer(initialState, {
type: "SET_VALUE",
component: colorlib.Component.HCL_L,
payload: (prev) => prev + 1,
});
expectHCL(nextState.color.hcl, nextColor);
nextColor = colorlib.HCL.new(0, 0, 0.5);
nextState = colorReducer(nextState, {
type: "SET_VALUE",
component: colorlib.Component.HCL_L,
payload: (prev) => prev - 0.5,
});
expectHCL(nextState.color.hcl, nextColor);
});
});
});
});
describe("color actions", () => {
const initialState = { color: colorlib.Color.from_hex("000") };
let state: ColorState;
let dispatch: (value: ColorAction) => void;
let actions: ColorActions;
beforeEach(() => {
[state, dispatch] = mockUseReducer(colorReducer, initialState);
actions = createColorActions(dispatch);
});
describe("set by color", () => {
test("set color", () => {
const nextColor = colorlib.Color.from_hex("FF0000");
actions.common.setColor(nextColor);
expect(state.color.hex.to_code()).toBe(nextColor.hex.to_code());
});
});
describe("set by color space", () => {
test("set rgb", () => {
const nextColor = colorlib.RGB.new(1, 2, 3);
actions.rgb.setRGB(nextColor);
expectRGB(state.color.rgb, nextColor);
});
test("set hsv", () => {
const nextColor = colorlib.HSV.new(1, 2, 3);
actions.hsv.setHSV(nextColor);
expectHSV(state.color.hsv, nextColor);
});
test("set hcl", () => {
const nextColor = colorlib.HCL.new(1, 2, 3);
actions.hcl.setHCL(nextColor);
expectHCL(state.color.hcl, nextColor);
});
test("set hex", () => {
const nextColor = colorlib.Hex.from_code("FF0000");
actions.hex.setHex(nextColor);
expect(state.color.hex.to_code()).toBe(nextColor.to_code());
});
});
describe("set by component", () => {
describe("rgb", () => {
test("set rgb r", () => {
const nextColor = colorlib.RGB.new(100, 0, 0);
actions.rgb.setR(nextColor.r);
expectRGB(state.color.rgb, nextColor);
});
test("set rgb g", () => {
const nextColor = colorlib.RGB.new(0, 100, 0);
actions.rgb.setG(nextColor.g);
expectRGB(state.color.rgb, nextColor);
});
test("set rgb b", () => {
const nextColor = colorlib.RGB.new(0, 0, 100);
actions.rgb.setB(nextColor.b);
expectRGB(state.color.rgb, nextColor);
});
});
describe("hsv", () => {
test("set hsv h", () => {
const nextColor = colorlib.HSV.new(100, 0, 0);
actions.hsv.setH(nextColor.h);
expectHSV(state.color.hsv, nextColor);
});
test("set hsv s", () => {
const nextColor = colorlib.HSV.new(0, 0.5, 0);
actions.hsv.setS(nextColor.s);
expectHSV(state.color.hsv, nextColor);
});
test("set hsv v", () => {
const nextColor = colorlib.HSV.new(0, 0, 0.5);
actions.hsv.setV(nextColor.v);
expectHSV(state.color.hsv, nextColor);
});
});
describe("hcl", () => {
test("set hcl h", () => {
const nextColor = colorlib.HCL.new(100, 0, 0);
actions.hcl.setH(nextColor.h);
expectHCL(state.color.hcl, nextColor);
});
test("set hcl c", () => {
const nextColor = colorlib.HCL.new(0, 0.5, 0);
actions.hcl.setC(nextColor.c);
expectHCL(state.color.hcl, nextColor);
});
test("set hcl l", () => {
const nextColor = colorlib.HCL.new(0, 0, 0.5);
actions.hcl.setL(nextColor.l);
expectHCL(state.color.hcl, nextColor);
});
});
});
describe("adjust by component", () => {
describe("rgb", () => {
test("adjust rgb r", () => {
let nextColor = colorlib.RGB.new(100, 0, 0);
actions.rgb.setR((prev) => prev + 100);
expectRGB(state.color.rgb, nextColor);
nextColor = colorlib.RGB.new(50, 0, 0);
actions.rgb.setR((prev) => prev - 50);
expectRGB(state.color.rgb, nextColor);
});
test("adjust rgb g", () => {
let nextColor = colorlib.RGB.new(0, 100, 0);
actions.rgb.setG((prev) => prev + 100);
expectRGB(state.color.rgb, nextColor);
nextColor = colorlib.RGB.new(0, 50, 0);
actions.rgb.setG((prev) => prev - 50);
expectRGB(state.color.rgb, nextColor);
});
test("adjust rgb b", () => {
let nextColor = colorlib.RGB.new(0, 0, 100);
actions.rgb.setB((prev) => prev + 100);
expectRGB(state.color.rgb, nextColor);
nextColor = colorlib.RGB.new(0, 0, 50);
actions.rgb.setB((prev) => prev - 50);
expectRGB(state.color.rgb, nextColor);
});
});
describe("hsv", () => {
test("adjust hsv h", () => {
let nextColor = colorlib.HSV.new(100, 0, 0);
actions.hsv.setH((prev) => prev + 100);
expectHSV(state.color.hsv, nextColor);
nextColor = colorlib.HSV.new(50, 0, 0);
actions.hsv.setH((prev) => prev - 50);
expectHSV(state.color.hsv, nextColor);
});
test("adjust hsv s", () => {
let nextColor = colorlib.HSV.new(0, 1, 0);
actions.hsv.setS((prev) => prev + 1);
expectHSV(state.color.hsv, nextColor);
nextColor = colorlib.HSV.new(0, 0.5, 0);
actions.hsv.setS((prev) => prev - 0.5);
expectHSV(state.color.hsv, nextColor);
});
test("adjust hsv v", () => {
let nextColor = colorlib.HSV.new(0, 0, 1);
actions.hsv.setV((prev) => prev + 1);
expectHSV(state.color.hsv, nextColor);
nextColor = colorlib.HSV.new(0, 0, 0.5);
actions.hsv.setV((prev) => prev - 0.5);
expectHSV(state.color.hsv, nextColor);
});
});
describe("hcl", () => {
test("adjust hcl h", () => {
let nextColor = colorlib.HCL.new(100, 0, 0);
actions.hcl.setH((prev) => prev + 100);
expectHCL(state.color.hcl, nextColor);
nextColor = colorlib.HCL.new(50, 0, 0);
actions.hcl.setH((prev) => prev - 50);
expectHCL(state.color.hcl, nextColor);
});
test("adjust hcl c", () => {
let nextColor = colorlib.HCL.new(0, 1, 0);
actions.hcl.setC((prev) => prev + 1);
expectHCL(state.color.hcl, nextColor);
nextColor = colorlib.HCL.new(0, 0.5, 0);
actions.hcl.setC((prev) => prev - 0.5);
expectHCL(state.color.hcl, nextColor);
});
test("adjust hcl l", () => {
let nextColor = colorlib.HCL.new(0, 0, 1);
actions.hcl.setL((prev) => prev + 1);
expectHCL(state.color.hcl, nextColor);
nextColor = colorlib.HCL.new(0, 0, 0.5);
actions.hcl.setL((prev) => prev - 0.5);
expectHCL(state.color.hcl, nextColor);
});
});
});
});
+14
View File
@@ -11,12 +11,22 @@ function TestSquare() {
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 }); const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
const [xPosition, setXPosition] = useState(0); const [xPosition, setXPosition] = useState(0);
const [yPosition, setYPosition] = useState(0); const [yPosition, setYPosition] = useState(0);
const [xValue, setXValue] = useState(0);
const [yValue, setYValue] = useState(0);
const xValueRange = { min: 0, max: 100 };
const yValueRange = { min: 0, max: 100 };
const { crosshairRef, isDragging } = useCrosshair({ const { crosshairRef, isDragging } = useCrosshair({
origin, origin,
dimensions, dimensions,
setXPosition, setXPosition,
setYPosition, setYPosition,
xValue,
yValue,
setXValue,
setYValue,
xValueRange,
yValueRange,
}); });
const boundaryRef = useRef<HTMLDivElement>(null); const boundaryRef = useRef<HTMLDivElement>(null);
@@ -98,6 +108,10 @@ function TestSquare() {
{isDragging ? "True" : "False"} {isDragging ? "True" : "False"}
</span> </span>
</p> </p>
<p>
X Value: <span data-cy="x-value-display">{xValue}</span>
<br />Y Value: <span data-cy="y-value-display">{yValue}</span>
</p>
</> </>
); );
} }
+29 -12
View File
@@ -1,8 +1,10 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { CartesianSpace } from "@/types"; import type { CartesianSpace } from "@/types";
import { Direction } from "@/types";
import { chooseValueByDirection, valueToPosition } from "@/util";
import { Direction, useSlider } from "../slider"; import { useSlider } from "../slider";
// Test Fixtures // Test Fixtures
@@ -13,13 +15,16 @@ function TestSlider({
}) { }) {
const [origin, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 }); const [origin, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 }); const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
const [position, setPosition] = useState(0); const [value, setValue] = useState(0);
const [sliderValue, setSliderValue] = useState(0); const position = useRef(0);
const valueRange = { min: 0, max: 100 };
const { sliderRef, isDragging } = useSlider({ const { sliderRef, isDragging } = useSlider({
direction, direction,
origin, origin,
dimensions, dimensions,
setPosition, valueRange,
value,
setValue,
}); });
const railRef = useRef<HTMLSpanElement>(null); const railRef = useRef<HTMLSpanElement>(null);
@@ -41,12 +46,23 @@ function TestSlider({
const maxValue = const maxValue =
direction == Direction.HORIZONTAL ? dimensions.x : dimensions.y; direction == Direction.HORIZONTAL ? dimensions.x : dimensions.y;
if (maxValue > 0) { if (maxValue > 0) {
const percentage = parseFloat(((position / maxValue) * 100).toFixed(3)); const percentage = parseFloat(
setSliderValue(percentage); ((position.current / maxValue) * 100).toFixed(3),
);
setValue(percentage);
} else { } else {
setSliderValue(0); setValue(0);
} }
}, [dimensions, direction, position]); }, [dimensions, direction]);
useEffect(() => {
const maxPosition = chooseValueByDirection(
direction,
dimensions.x,
dimensions.y,
);
position.current = valueToPosition(value, maxPosition, valueRange);
}, [value, direction, dimensions, valueRange]);
const isHorizontal = direction === Direction.HORIZONTAL; const isHorizontal = direction === Direction.HORIZONTAL;
@@ -82,8 +98,8 @@ function TestSlider({
style={{ style={{
position: "absolute", position: "absolute",
...(isHorizontal ...(isHorizontal
? { left: position, top: 0 } ? { left: position.current, top: 0 }
: { left: 0, top: position }), : { left: 0, top: position.current }),
width: 50, width: 50,
height: 50, height: 50,
background: "rgba(255,0,0,0.5)", background: "rgba(255,0,0,0.5)",
@@ -92,8 +108,9 @@ function TestSlider({
/> />
</div> </div>
<p> <p>
Position: <span data-cy="position-display">{position}</span>px Value: <span data-cy="value-display">{value}</span>
<br /> Value: <span data-cy="value-display">{sliderValue}</span> <br /> Position:{" "}
<span data-cy="position-display">{position.current}</span>px
</p> </p>
</> </>
); );
+17
View File
@@ -1,4 +1,21 @@
// Interfaces
export interface CartesianSpace { export interface CartesianSpace {
x: number; x: number;
y: number; y: number;
} }
export interface Range {
min: number;
max: number;
}
// Types
export type Setter<T> = (valueOrCallback: SetterValueOrCallback<T>) => void;
export type SetterValueOrCallback<T> = T | ((prev: T) => T);
export type Timeout = ReturnType<typeof setTimeout>;
// Enums
export enum Direction {
HORIZONTAL = "horizontal",
VERTICAL = "vertical",
}
+46 -1
View File
@@ -1,6 +1,7 @@
import type { RefObject } from "react"; import type { RefObject } from "react";
import type { CartesianSpace } from "./types"; import type { CartesianSpace, Range } from "./types";
import { Direction } from "./types";
export function minmax(number: number, min: number, max: number) { export function minmax(number: number, min: number, max: number) {
return Math.min(max, Math.max(min, number)); return Math.min(max, Math.max(min, number));
@@ -44,3 +45,47 @@ export function setMeasurements(
setDimensions({ x: rect.width, y: rect.height }); setDimensions({ x: rect.width, y: rect.height });
} }
} }
export function valueToPosition(
value: number,
maxPosition: number,
valueRange: Range,
) {
if (maxPosition <= 0 || !isFinite(value)) return 0;
const rangeSpan = Math.abs(valueRange.min) + Math.abs(valueRange.max);
if (rangeSpan === 0) return 0;
const position = Math.round(
maxPosition *
((value + Math.abs(valueRange.min)) /
(Math.abs(valueRange.min) + Math.abs(valueRange.max))),
);
return position;
}
export function positionToValue(
position: number,
maxPosition: number,
valueRange: Range,
) {
if (maxPosition <= 0 || !isFinite(position)) return valueRange.min;
const rangeSpan = Math.abs(valueRange.min) + Math.abs(valueRange.max);
if (rangeSpan === 0) return valueRange.min;
const value =
(position / maxPosition) *
(Math.abs(valueRange.min) + Math.abs(valueRange.max)) -
Math.abs(valueRange.min);
return value;
}
export function chooseValueByDirection(
direction: Direction,
xValue: number,
yValue: number,
) {
return direction === Direction.HORIZONTAL ? xValue : yValue;
}
+1 -1
View File
@@ -20,5 +20,5 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["vite.config.ts", "cypress.config.ts"] "include": ["vite.config.ts", "cypress.config.ts", "vitest.config.ts"]
} }
+4
View File
@@ -19,4 +19,8 @@ export default defineConfig({
server: { server: {
port: 5173, port: 5173,
}, },
test: {
environment: "jsdom",
globals: true,
},
}); });
+10
View File
@@ -0,0 +1,10 @@
import topLevelAwait from "vite-plugin-top-level-await";
import wasm from "vite-plugin-wasm";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [wasm(), topLevelAwait()],
test: {
environment: "jsdom",
},
});