Completed picker / history. Needs styling refactor.

This commit is contained in:
Jay
2025-08-16 09:10:20 -04:00
parent 7a2e4cf2ae
commit c4ece87cb6
23 changed files with 873 additions and 79 deletions
+1
View File
@@ -1 +1,2 @@
node_modules node_modules
dist
+25
View File
@@ -53,6 +53,19 @@ Cypress.Commands.add("disableTransitions", () => {
} }
`; `;
document.head.appendChild(style); document.head.appendChild(style);
// Flag to disable Framer Motion
if (window.framerMotionTestOverride) return;
window.framerMotionTestOverride = true;
window.originalRequestAnimationFrame = window.requestAnimationFrame;
window.requestAnimationFrame = (callback) => {
return window.setTimeout(() => {
if (callback) callback(0);
}, 0);
};
document.body.setAttribute("data-cy-animations-disabled", "true");
}); });
}); });
@@ -62,5 +75,17 @@ Cypress.Commands.add("enableTransitions", () => {
if (styleElement) { if (styleElement) {
styleElement.remove(); styleElement.remove();
} }
// Remove flags for Framer Motion
if (window.framerMotionTestOverride) {
window.framerMotionTestOverride = false;
if (window.originalRequestAnimationFrame) {
window.requestAnimationFrame = window.originalRequestAnimationFrame;
delete window.originalRequestAnimationFrame;
}
}
document.body.removeAttribute("data-cy-animations-disabled");
}); });
}); });
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> --> <link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Luminance</title> <title>Luminance</title>
</head> </head>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

+27 -30
View File
@@ -1,17 +1,26 @@
.appWrapper { .appWrapper {
background-color: white; background-color: white;
height: 100%; height: 100%;
width: 100%; width: 1200px;
max-width: 1200px;
overflow: hidden;
margin: 0 auto; margin: 0 auto;
box-shadow: 0 0 40px #7a7a7a; box-shadow: 0 0 40px #7a7a7a;
border-left: 2px solid #7a7a7a; border-left: 2px solid #7a7a7a;
border-right: 2px solid #7a7a7a; border-right: 2px solid #7a7a7a;
overflow: hidden;
}
.mainLayout {
height: 100%;
display: grid;
grid-template-areas:
"header header"
"picker palette";
grid-template-columns: 1fr 2fr;
grid-template-rows: 76px 1fr;
} }
.appHeader { .appHeader {
height: 40px; grid-area: header;
display: flex; display: flex;
align-items: baseline; align-items: baseline;
border-bottom: 2px solid #7a7a7a; border-bottom: 2px solid #7a7a7a;
@@ -39,48 +48,36 @@
color: #7a7a7a; color: #7a7a7a;
} }
.mobileContent,
.mainContent,
.tabWrapper,
.tabWrapper .tab {
height: 100%;
}
.tabWrapper {
/* hide scrollbar */
-ms-overflow-style: none;
scrollbar-width: none;
}
.appWrapper {
}
.mobileContent {
display: none;
}
.mainContent {
display: grid;
grid-template-columns: 1fr 2fr;
}
.firstZone { .firstZone {
grid-area: picker;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-right: 2px solid #7a7a7a; border-right: 2px solid #7a7a7a;
} }
.secondZone { .secondZone {
padding: 40px; min-width: 0;
grid-area: palette;
color: #555; color: #555;
font-style: italic; font-style: italic;
} }
.colorHistoryWrapper {
box-sizing: border-box;
border-bottom: 2px solid #7a7a7a;
position: relative;
}
.colorPickerWrapper { .colorPickerWrapper {
border-bottom: 2px solid #7a7a7a; border-bottom: 2px solid #7a7a7a;
padding: 20px 40px 40px;
} }
.colorValuesWrapper { .colorValuesWrapper {
padding: 40px;
}
.colorHistoryWrapper {
} }
.paletteEditorWrapper { .paletteEditorWrapper {
+14 -12
View File
@@ -3,6 +3,7 @@ import { useState } from "react";
import clsx from "clsx"; import clsx from "clsx";
import { Color } from "colorlib"; import { Color } from "colorlib";
import ColorHistory from "@/components/ColorHistory/ColorHistory";
import ColorPicker from "@/components/ColorPicker/ColorPicker"; import ColorPicker from "@/components/ColorPicker/ColorPicker";
import ColorValues from "@/components/ColorValues/ColorValues"; import ColorValues from "@/components/ColorValues/ColorValues";
import { LeftMenu, RightMenu } from "@/components/SideMenu"; import { LeftMenu, RightMenu } from "@/components/SideMenu";
@@ -193,10 +194,6 @@ function MobileContent({
// Desktop Layout Components // Desktop Layout Components
function TitleZone() {
return <section className={styles.TitleZone}></section>;
}
function FirstZone() { function FirstZone() {
const { selectedColor, selectedColorActions } = useSelectedColor(); const { selectedColor, selectedColorActions } = useSelectedColor();
@@ -205,7 +202,6 @@ function FirstZone() {
<div className={styles.colorPickerWrapper} aria-label="Color picker"> <div className={styles.colorPickerWrapper} aria-label="Color picker">
<ColorPicker color={selectedColor} actions={selectedColorActions} /> <ColorPicker color={selectedColor} actions={selectedColorActions} />
</div> </div>
<div className={styles.colorValuesWrapper} aria-label="Color values"> <div className={styles.colorValuesWrapper} aria-label="Color values">
<ColorValues color={selectedColor} actions={selectedColorActions} /> <ColorValues color={selectedColor} actions={selectedColorActions} />
</div> </div>
@@ -214,9 +210,17 @@ function FirstZone() {
} }
function SecondZone() { function SecondZone() {
const { selectedColor, selectedColorActions } = useSelectedColor();
return ( return (
<section className={styles.secondZone} aria-label="Palette tools"> <section className={styles.secondZone} aria-label="Palette tools">
Palette Creator Coming Soon. <div className={styles.colorHistoryWrapper} aria-label="Color History">
<ColorHistory
color={selectedColor}
setColor={selectedColorActions.common.setColor}
disabled={false}
/>
</div>
<div <div
className={styles.paletteEditorWrapper} className={styles.paletteEditorWrapper}
aria-label="Palette editor" aria-label="Palette editor"
@@ -231,16 +235,14 @@ function SecondZone() {
function DesktopContent() { function DesktopContent() {
return ( return (
<> <div className={styles.mainLayout}>
<header className={styles.appHeader}> <header className={styles.appHeader}>
<span className={styles.title}>LUMINANCE</span> <span className={styles.title}>LUMINANCE</span>
<span className={styles.subtitle}>A color picker for humans.</span> <span className={styles.subtitle}>A color picker for humans.</span>
</header> </header>
<main className={styles.mainContent}> <FirstZone />
<FirstZone /> <SecondZone />
<SecondZone /> </div>
</main>
</>
); );
} }
@@ -0,0 +1,48 @@
.colorHistory {
height: 74px;
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
width: 100%;
padding: 5px 0px 0px;
/* Improve scrolling experience */
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
scrollbar-width: thin; /* For Firefox */
}
.historyColor {
flex: 0 0 auto;
display: inline-block;
height: 50px;
width: 25px;
margin: 5px;
border: 2px solid #7a7a7a;
transition:
margin 200ms,
height 200ms,
width 200ms;
cursor: pointer;
}
.historyColor:hover {
height: 56px;
width: 31px;
margin: 2px;
}
.historyColor:first-of-type:hover {
margin-left: 12px;
}
.historyColor:first-of-type {
margin-left: 15px;
}
.historyColor:last-of-type:hover {
margin-right: 12px;
}
.historyColor:last-of-type {
margin-right: 15px;
}
@@ -0,0 +1,80 @@
import { useEffect, useState } from "react";
import { Color } from "colorlib";
import { motion } from "motion/react";
import type { Timeout } from "@/types";
import { formatCssRgb } from "@/util";
import styles from "./ColorHistory.module.css";
function ColorHistory({
color,
setColor,
disabled,
}: {
color: Color;
setColor: (newColor: Color) => void;
disabled: boolean;
}) {
const [history, setHistory] = useState<Color[]>([]);
const maxItems = 50;
useEffect(() => {
if (disabled) return;
const timer: Timeout = setTimeout(() => {
setHistory((prev) => {
if (prev.length > 0 && prev[0].hex.to_code() === color.hex.to_code())
return prev;
const newHistory = [color, ...prev];
return newHistory.slice(0, maxItems);
});
}, 1000);
return () => clearTimeout(timer);
}, [color, disabled]);
const handleClick = (historyColor: Color) => {
setColor(historyColor);
};
// Manage motion props for testing
const isTestEnvironment =
typeof window !== "undefined" && window.framerMotionTestOverride === true;
const getAnimationProps = (isFirst: boolean) => {
return isTestEnvironment
? {
initial: false,
animate: {},
exit: {},
ease: null,
transition: { duration: 0 },
}
: {
initial: { opacity: isFirst ? 0 : 1, x: -20 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0 },
ease: "easeInOut",
transition: { duration: 0.3 },
};
};
return (
<div data-cy="color-history" className={styles.colorHistory}>
{history.map((historyColor, index) => (
<motion.div
key={`${historyColor.hex.to_code()}-${index}`}
data-cy={`history-color-${index}`}
className={styles.historyColor}
style={{ backgroundColor: formatCssRgb(historyColor.hex) }}
onClick={() => handleClick(historyColor)}
{...getAnimationProps(index === 0)}
></motion.div>
))}
</div>
);
}
export default ColorHistory;
@@ -0,0 +1,95 @@
import { useReducer, useState } from "react";
import type { ChangeEvent } from "react";
import { Color } from "colorlib";
import { colorReducer, createColorActions } from "@/hooks/color";
import { HexEditor } from "../ColorValues/ValueEditor";
import ColorHistory from "./ColorHistory";
const initialState = {
color: Color.from_hex("000"),
};
function TestWrapper() {
const [state, dispatch] = useReducer(colorReducer, initialState);
const actions = createColorActions(dispatch);
const [disabled, setDisabled] = useState(false);
const handleDisabledChange = (e: ChangeEvent<HTMLInputElement>) =>
setDisabled(e.target.checked);
return (
<div>
<ColorHistory
color={state.color}
setColor={actions.common.setColor}
disabled={disabled}
/>
<HexEditor color={state.color.hex} actions={actions.hex} />
<label>
<input
data-cy="disabled-checkbox"
type="checkbox"
checked={disabled}
onChange={handleDisabledChange}
/>
Disabled
</label>
</div>
);
}
describe("color history", () => {
beforeEach(() => {
cy.disableTransitions();
cy.clock();
cy.mount(<TestWrapper />);
});
afterEach(() => {
cy.clock().then((clock) => clock.restore());
cy.enableTransitions();
});
it("adds stable color values after 1 second", () => {
// add stable values to history
cy.dataCy("hex-value-input").as("value").clear().type("#00F536");
cy.tick(1000);
cy.dataCy("color-history").children().should("have.length", 1);
cy.dataCy("history-color-0").should(
"have.css",
"background-color",
"rgb(0, 245, 54)",
);
cy.get("@value").clear().type("#E23AEC");
cy.tick(1000);
cy.dataCy("color-history").children().should("have.length", 2);
cy.dataCy("history-color-0").should(
"have.css",
"background-color",
"rgb(226, 58, 236)",
);
// click to restore value
cy.dataCy("history-color-1").click();
cy.get("@value").should("have.value", "#00F536");
// disable history
cy.dataCy("disabled-checkbox").click();
cy.get("@value").clear().type("#00C3EE");
cy.tick(1000);
cy.dataCy("color-history").children().should("have.length", 2);
// re-enable history
cy.dataCy("disabled-checkbox").click();
cy.tick(1000);
cy.dataCy("color-history").children().should("have.length", 3);
});
});
+133
View File
@@ -0,0 +1,133 @@
import { useEffect, useRef, useState } from "react";
import * as colorlib from "colorlib";
import { memory } from "colorlib/colorlib_bg.wasm";
import { useSmoothAnimation } from "@/hooks/animation";
import type { Setter } from "@/hooks/color";
import { useSlider } from "@/hooks/slider";
import { useResize } from "@/hooks/window";
import type { CartesianSpace } from "@/types";
import { Direction } from "@/types";
import { setMeasurements } from "@/util";
import styles from "./ColorPicker.module.css";
function ColorBar({
hue,
chroma,
luminance,
setChroma,
parentDimensions,
}: {
hue: number;
chroma: number;
luminance: number;
setChroma: Setter;
parentDimensions: CartesianSpace;
}) {
// State
const [colorBar, setColorBar] = useState<colorlib.ColorBar | null>(null);
const [origin, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
// Refs
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
// Hooks
const smoothAnimation = useSmoothAnimation();
// Slider interaction
const { sliderRef } = useSlider({
direction: Direction.HORIZONTAL,
origin,
dimensions,
valueRange: { min: 0, max: 1 },
value: chroma,
setValue: setChroma,
});
// Update canvas when hue/luminance changes
useEffect(() => {
if (colorBar && canvasRef.current) {
smoothAnimation(() => {
colorBar.fill_color(hue, luminance);
refreshColorBar(canvasRef.current!, colorBar);
});
}
}, [hue, luminance, colorBar]);
// Get measurements
useEffect(() => {
if (containerRef.current) {
setMeasurements(containerRef, setOrigin, setDimensions);
}
return useResize(() =>
setMeasurements(containerRef, setOrigin, setDimensions),
);
}, [containerRef.current]);
// Resize color bar
useEffect(() => {
if (containerRef.current && canvasRef.current && parentDimensions.x > 0) {
const newHeight = containerRef.current.clientHeight;
const newWidth = parentDimensions.x - 54;
const newColorBar = new colorlib.ColorBar(newWidth, newHeight);
setColorBar(newColorBar);
if (newColorBar) {
smoothAnimation(() => {
if (canvasRef.current) {
newColorBar.fill_color(hue, luminance);
refreshColorBar(canvasRef.current!, newColorBar);
}
});
}
}
}, [containerRef.current, canvasRef.current, parentDimensions]);
return (
<div className={styles.colorBarWrapper} ref={containerRef}>
<div
className={styles.colorBar}
ref={sliderRef}
style={{
width: colorBar?.get_width(),
height: colorBar?.get_height(),
}}
>
<canvas
ref={canvasRef}
width={colorBar?.get_width()}
height={colorBar?.get_height()}
/>
</div>
</div>
);
}
function refreshColorBar(
canvas: HTMLCanvasElement,
colorBar: colorlib.ColorBar,
) {
const ctx = canvas.getContext("2d");
if (ctx) {
const width = colorBar.get_width();
const height = colorBar.get_height();
const imageData = ctx.createImageData(width, height);
const pixelPointer = colorBar.get_pixels_pointer();
const pixels = new Uint8Array(
memory.buffer,
pixelPointer,
width * height * 4,
);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
}
}
export default ColorBar;
@@ -1,5 +1,4 @@
.container { .container {
padding: 40px;
display: grid; display: grid;
grid-template-columns: 25px 1fr 25px; grid-template-columns: 25px 1fr 25px;
grid-template-rows: 50px 1fr 25px; grid-template-rows: 50px 1fr 25px;
@@ -9,6 +8,14 @@
". bottomGrip ." ". bottomGrip ."
". bar ."; ". bar .";
} }
.preview {
grid-area: preview;
height: 25px;
margin-bottom: 15px;
border: 2px solid #7a7a7a;
}
.pickerSquare { .pickerSquare {
grid-area: square; grid-area: square;
position: relative; position: relative;
@@ -100,11 +107,3 @@
width: 0; width: 0;
height: 0; height: 0;
} }
/* Preview */
.preview {
grid-area: preview;
margin-bottom: 15px;
border: 2px solid #7a7a7a;
}
@@ -55,6 +55,7 @@ function ColorPicker({
valueRange={lumRange} valueRange={lumRange}
arrowDirection="right" arrowDirection="right"
invert={true} invert={true}
parentDimensions={dimensions}
/> />
</div> </div>
<div className={styles.pickerSquare}> <div className={styles.pickerSquare}>
@@ -62,6 +63,7 @@ function ColorPicker({
hue={color.hcl.h} hue={color.hcl.h}
luminance={color.hcl.l} luminance={color.hcl.l}
hex={color.hex} hex={color.hex}
parentDimensions={dimensions}
/> />
<ColorSquare <ColorSquare
chroma={color.hcl.c} chroma={color.hcl.c}
@@ -77,6 +79,7 @@ function ColorPicker({
valueRange={lumRange} valueRange={lumRange}
arrowDirection="left" arrowDirection="left"
invert={true} invert={true}
parentDimensions={dimensions}
/> />
</div> </div>
<div className={styles.horizontalGrip}> <div className={styles.horizontalGrip}>
@@ -86,6 +89,7 @@ function ColorPicker({
setValue={actions.hcl.setH} setValue={actions.hcl.setH}
valueRange={hueRange} valueRange={hueRange}
arrowDirection="up" arrowDirection="up"
parentDimensions={dimensions}
/> />
</div> </div>
<div className={styles.pickerBar}> <div className={styles.pickerBar}>
@@ -100,6 +104,7 @@ function ColorPicker({
chroma={color.hcl.c} chroma={color.hcl.c}
luminance={color.hcl.l} luminance={color.hcl.l}
hex={color.hex} hex={color.hex}
parentDimensions={dimensions}
/> />
</div> </div>
</div> </div>
+137
View File
@@ -0,0 +1,137 @@
import { useEffect, useRef, useState } from "react";
import * as colorlib from "colorlib";
import { memory } from "colorlib/colorlib_bg.wasm";
import { useSmoothAnimation } from "@/hooks/animation";
import type { HCLColorActions } from "@/hooks/color";
import { useCrosshair } from "@/hooks/crosshair";
import { useScroll } from "@/hooks/scroll";
import { useResize } from "@/hooks/window";
import type { CartesianSpace } from "@/types";
import { setMeasurements } from "@/util";
import styles from "./ColorPicker.module.css";
function ColorSquare({
chroma,
actions,
parentDimensions,
}: {
chroma: number;
actions: HCLColorActions;
parentDimensions: CartesianSpace;
}) {
// State
const [colorSquare, setColorSquare] = useState<colorlib.ColorSquare | null>(
null,
);
const [origin, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
// Refs
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
// Hooks
const smoothAnimation = useSmoothAnimation();
// Crosshair interaction
const { crosshairRef } = useCrosshair({
origin,
dimensions,
setXValue: actions.setH,
setYValue: actions.setL,
xValueRange: { min: 0, max: 359 },
yValueRange: { min: 0, max: 1 },
invertY: true,
});
// Handle chroma adjustment with scroll
const { addScrollListener } = useScroll({
targetRef: canvasRef,
onScrollUp: () => actions.setC((prev) => Math.min(1, prev + 0.01)),
onScrollDown: () => actions.setC((prev) => Math.max(0, prev - 0.01)),
});
// Update canvas when chroma changes
useEffect(() => {
if (colorSquare && canvasRef.current) {
smoothAnimation(() => {
colorSquare.fill_chroma(chroma);
refreshColorSquare(canvasRef.current!, colorSquare);
});
}
}, [chroma, colorSquare]);
// Add event listeners
useEffect(() => {
if (canvasRef.current) addScrollListener();
}, []);
// Get measurements
useEffect(() => {
if (containerRef.current) {
setMeasurements(containerRef, setOrigin, setDimensions);
}
return useResize(() =>
setMeasurements(containerRef, setOrigin, setDimensions),
);
}, [containerRef.current, parentDimensions]);
// Resize square
useEffect(() => {
if (containerRef.current && canvasRef.current && parentDimensions.x > 0) {
const newSize = parentDimensions.x - 54;
const newColorSquare = new colorlib.ColorSquare(newSize);
setColorSquare(newColorSquare);
if (newColorSquare) {
smoothAnimation(() => {
if (canvasRef.current) {
newColorSquare.fill_chroma(chroma);
refreshColorSquare(canvasRef.current, newColorSquare);
}
});
}
}
}, [containerRef.current, canvasRef.current, parentDimensions]);
return (
<div className={styles.colorSquareWrapper} ref={containerRef}>
<div
className={styles.colorSquare}
ref={crosshairRef}
style={{
width: colorSquare?.get_size(),
height: colorSquare?.get_size(),
}}
>
<canvas
ref={canvasRef}
width={colorSquare?.get_size()}
height={colorSquare?.get_size()}
/>
</div>
</div>
);
}
function refreshColorSquare(
canvas: HTMLCanvasElement,
colorSquare: colorlib.ColorSquare,
) {
const ctx = canvas.getContext("2d");
if (ctx) {
const size = colorSquare.get_size();
const imageData = ctx.createImageData(size, size);
const pixelPointer = colorSquare.get_pixels_pointer();
const pixels = new Uint8Array(memory.buffer, pixelPointer, size * size * 4);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
}
}
export default ColorSquare;
+133
View File
@@ -0,0 +1,133 @@
import { useEffect, useRef, useState } from "react";
import * as colorlib from "colorlib";
import { useResize } from "@/hooks/window";
import type { CartesianSpace } from "@/types";
import { formatCssRgb, setMeasurements, valueToPosition } from "@/util";
import styles from "./ColorPicker.module.css";
export function SquareCrosshair({
hue,
luminance,
hex,
parentDimensions,
isDragging,
}: {
hue: number;
luminance: number;
hex: colorlib.Hex;
parentDimensions: CartesianSpace;
isDragging: boolean;
}) {
const [_origin, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
const [darkCrosshairs, setDarkCrosshairs] = useState(true);
const containerRef = useRef<HTMLDivElement>(null);
const lumRange = { min: 0, max: 1 };
const hueRange = { min: 0, max: 359 };
useEffect(() => {
setDarkCrosshairs(luminance > 0.5);
}, [luminance]);
useEffect(() => {
setMeasurements(containerRef, setOrigin, setDimensions);
return useResize(() =>
setMeasurements(containerRef, setOrigin, setDimensions),
);
}, [containerRef.current, parentDimensions]);
return (
<div className={styles.crosshairWrapper} ref={containerRef}>
<div
className={styles.crosshair}
style={{
width: 1,
height: dimensions.y,
backgroundColor: darkCrosshairs ? "black" : "white",
boxShadow: darkCrosshairs ? "0 0 2px black" : "0 0 2px white",
left: valueToPosition(hue, dimensions.x - 1, hueRange),
top: 0,
}}
></div>
<div
className={styles.crosshair}
style={{
width: dimensions.x,
height: 1,
backgroundColor: darkCrosshairs ? "black" : "white",
boxShadow: darkCrosshairs ? "0 0 2px black" : "0 0 2px white",
left: 0,
top: valueToPosition(1 - luminance, dimensions.y - 1, lumRange),
}}
></div>
<div
className={styles.crossEye}
style={{
borderColor: darkCrosshairs ? "black" : "white",
boxShadow: darkCrosshairs ? "0 0 1px black" : "0 0 1px white",
backgroundColor: formatCssRgb(hex),
top: valueToPosition(1 - luminance, dimensions.y - 1, lumRange) - 6,
left: valueToPosition(hue, dimensions.x - 1, hueRange) - 6,
}}
></div>
</div>
);
}
export function BarCrosshair({
chroma,
luminance,
hex,
parentDimensions,
}: {
chroma: number;
luminance: number;
hex: colorlib.Hex;
parentDimensions: CartesianSpace;
}) {
const [_origin, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
const [darkCrosshairs, setDarkCrosshairs] = useState(true);
const containerRef = useRef<HTMLDivElement>(null);
const chromaRange = { min: 0, max: 1 };
useEffect(() => {
setDarkCrosshairs(luminance > 0.5);
}, [luminance]);
useEffect(() => {
setMeasurements(containerRef, setOrigin, setDimensions);
return useResize(() =>
setMeasurements(containerRef, setOrigin, setDimensions),
);
}, [containerRef.current, parentDimensions]);
return (
<div className={styles.crosshairWrapper} ref={containerRef}>
<div
className={styles.crosshair}
style={{
width: 1,
height: dimensions.y,
backgroundColor: darkCrosshairs ? "black" : "white",
boxShadow: darkCrosshairs ? "0 0 2px black" : "0 0 2px white",
left: valueToPosition(chroma, dimensions.x - 1, chromaRange),
top: 0,
}}
></div>
<div
className={styles.crossEye}
style={{
borderColor: darkCrosshairs ? "black" : "white",
boxShadow: darkCrosshairs ? "0 0 1px black" : "0 0 1px white",
backgroundColor: formatCssRgb(hex),
top: 6,
left: valueToPosition(chroma, dimensions.x - 1, chromaRange) - 6,
}}
></div>
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useState } from "react";
import type { Setter } from "@/hooks/color";
import { useSlider } from "@/hooks/slider";
import { useResize } from "@/hooks/window";
import { Direction } from "@/types";
import type { CartesianSpace, Range } from "@/types";
import {
chooseValueByDirection,
setMeasurements,
valueToPosition,
} from "@/util";
import styles from "./ColorPicker.module.css";
function GripSlider({
direction,
value,
setValue,
valueRange,
arrowDirection,
invert = false,
parentDimensions,
}: {
direction: Direction;
value: number;
setValue: Setter;
valueRange: Range;
arrowDirection: "up" | "left" | "right";
invert?: boolean;
parentDimensions: CartesianSpace;
}) {
const [origin, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
// Slider interaction
const { sliderRef, isDragging } = useSlider({
direction,
origin,
dimensions,
valueRange,
value,
setValue,
invert,
});
useEffect(() => {
if (sliderRef.current) {
setMeasurements(sliderRef, setOrigin, setDimensions);
}
return useResize(() =>
setMeasurements(sliderRef, setOrigin, setDimensions),
);
}, [sliderRef.current, parentDimensions]);
const upArrowStyle = {
borderLeft: "12px solid transparent",
borderRight: "12px solid transparent",
borderBottom: "25px solid black",
};
const leftArrowStyle = {
borderTop: "12px solid transparent",
borderBottom: "12px solid transparent",
borderRight: "25px solid black",
};
const rightArrowStyle = {
borderTop: "12px solid transparent",
borderBottom: "12px solid transparent",
borderLeft: "25px solid black",
};
const arrowStyle = (function () {
switch (arrowDirection) {
case "up":
return upArrowStyle;
case "left":
return leftArrowStyle;
case "right":
return rightArrowStyle;
default:
return {};
}
})();
return (
<div className={styles.gripSlider} ref={sliderRef}>
<div
className={styles.grip}
style={{
...arrowStyle,
cursor: isDragging ? "grabbing" : "grab",
top: chooseValueByDirection(
direction,
0,
-12 +
valueToPosition(
valueRange.max - value,
dimensions.y - 1,
valueRange,
),
),
left: chooseValueByDirection(
direction,
-12 + valueToPosition(value, dimensions.x - 1, valueRange),
0,
),
}}
/>
</div>
);
}
export default GripSlider;
@@ -1,5 +1,5 @@
.colorValuesWrapper { .colorValuesWrapper {
padding: 40px; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
@@ -8,6 +8,7 @@
.spaceWrapper { .spaceWrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-height: 94px;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
border: 2px solid #7a7a7a; border: 2px solid #7a7a7a;
@@ -17,7 +18,8 @@
display: flex; display: flex;
align-items: stretch; align-items: stretch;
width: 100%; width: 100%;
height: 25px; max-height: 25px;
min-height: 0;
font-family: monospace; font-family: monospace;
border-top: 1px solid #7a7a7a; border-top: 1px solid #7a7a7a;
border-bottom: 1px solid #7a7a7a; border-bottom: 1px solid #7a7a7a;
@@ -20,9 +20,10 @@ function TestWrapper() {
<div <div
style={{ style={{
width: "100%", width: "100%",
height: 300, height: 275,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: 16,
}} }}
> >
<SpaceEditor <SpaceEditor
@@ -44,16 +45,19 @@ function TestWrapper() {
<div style={{ fontFamily: "monospace" }}> <div style={{ fontFamily: "monospace" }}>
<p data-cy="hcl-value"> <p data-cy="hcl-value">
HCL ({roundTo(state.color.hcl.h, 0)}, {roundTo(state.color.hcl.c, 2)},{" "} HCL ({roundTo(state.color.hcl.h, 0, "down")},{" "}
{roundTo(state.color.hcl.l, 2)}) {roundTo(state.color.hcl.c, 2, "down")},{" "}
{roundTo(state.color.hcl.l, 2, "down")})
</p> </p>
<p data-cy="hsv-value"> <p data-cy="hsv-value">
HSV ({roundTo(state.color.hsv.h, 0)}, {roundTo(state.color.hsv.s, 2)},{" "} HSV ({roundTo(state.color.hsv.h, 0, "down")},{" "}
{roundTo(state.color.hsv.v, 2)}) {roundTo(state.color.hsv.s, 2, "down")},{" "}
{roundTo(state.color.hsv.v, 2, "down")})
</p> </p>
<p data-cy="rgb-value"> <p data-cy="rgb-value">
RGB ({roundTo(state.color.rgb.r, 0)}, {roundTo(state.color.rgb.g, 0)},{" "} RGB ({roundTo(state.color.rgb.r, 0, "down")},{" "}
{roundTo(state.color.rgb.b, 0)}) {roundTo(state.color.rgb.g, 0, "down")},{" "}
{roundTo(state.color.rgb.b, 0, "down")})
</p> </p>
<p data-cy="hex-value">HEX: #{state.color.hex.to_code()}</p> <p data-cy="hex-value">HEX: #{state.color.hex.to_code()}</p>
</div> </div>
@@ -75,7 +79,7 @@ describe("space editor tests", () => {
cy.dataCy("HSV-editor").within(() => { cy.dataCy("HSV-editor").within(() => {
cy.dataCy("H-value-input").should("have.value", 158); cy.dataCy("H-value-input").should("have.value", 158);
cy.dataCy("S-value-input").should("have.value", 79); cy.dataCy("S-value-input").should("have.value", 79);
cy.dataCy("V-value-input").should("have.value", 87); cy.dataCy("V-value-input").should("have.value", 86);
}); });
cy.dataCy("HCL-editor").within(() => { cy.dataCy("HCL-editor").within(() => {
@@ -85,7 +89,7 @@ describe("space editor tests", () => {
}); });
cy.dataCy("rgb-value").should("have.text", "RGB (46, 221, 157)"); cy.dataCy("rgb-value").should("have.text", "RGB (46, 221, 157)");
cy.dataCy("hsv-value").should("have.text", "HSV (158, 0.79, 0.87)"); cy.dataCy("hsv-value").should("have.text", "HSV (158, 0.79, 0.86)");
cy.dataCy("hcl-value").should("have.text", "HCL (158, 0.79, 0.7)"); cy.dataCy("hcl-value").should("have.text", "HCL (158, 0.79, 0.7)");
cy.dataCy("hex-value").should("have.text", "HEX: #2EDD9D"); cy.dataCy("hex-value").should("have.text", "HEX: #2EDD9D");
@@ -97,9 +101,9 @@ describe("space editor tests", () => {
cy.dataCy("L-value-input").type("25"); cy.dataCy("L-value-input").type("25");
}); });
cy.dataCy("rgb-value").should("have.text", "RGB (17, 76, 75)"); cy.dataCy("rgb-value").should("have.text", "RGB (16, 75, 74)");
cy.dataCy("hsv-value").should("have.text", "HSV (179, 0.78, 0.3)"); cy.dataCy("hsv-value").should("have.text", "HSV (178, 0.78, 0.29)");
cy.dataCy("hcl-value").should("have.text", "HCL (179, 0.78, 0.25)"); cy.dataCy("hcl-value").should("have.text", "HCL (178, 0.78, 0.25)");
cy.dataCy("hex-value").should("have.text", "HEX: #104B4A"); cy.dataCy("hex-value").should("have.text", "HEX: #104B4A");
}); });
}); });
+6 -6
View File
@@ -31,14 +31,14 @@ export function ValueEditor({
value, value,
setValue, setValue,
scale = 1, scale = 1,
onKeyDown, onKeyDown = null,
}: { }: {
componentSymbol: string; componentSymbol: string;
valueRange: Range; valueRange: Range;
value: number; value: number;
setValue: Setter<number>; setValue: Setter<number>;
scale?: number; scale?: number;
onKeyDown?: (e: React.KeyboardEvent) => void; onKeyDown?: ((e: React.KeyboardEvent) => void) | null;
}) { }) {
// Set up component state // Set up component state
const direction = Direction.HORIZONTAL; const direction = Direction.HORIZONTAL;
@@ -162,7 +162,7 @@ function Slider({
position: number; position: number;
dimensions: CartesianSpace; dimensions: CartesianSpace;
componentSymbol: string; componentSymbol: string;
onKeyDown?: (e: React.KeyboardEvent) => void; onKeyDown?: ((e: React.KeyboardEvent) => void) | null;
}) { }) {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === "Escape") {
@@ -205,7 +205,7 @@ function Button({
direction: "increase" | "decrease"; direction: "increase" | "decrease";
componentSymbol: string; componentSymbol: string;
handleValueStep: (step: number) => void; handleValueStep: (step: number) => void;
onKeyDown?: (e: React.KeyboardEvent) => void; onKeyDown?: ((e: React.KeyboardEvent) => void) | null;
}) { }) {
const isIncrease = direction === "increase"; const isIncrease = direction === "increase";
const label = isIncrease ? "Increase" : "Decrease"; const label = isIncrease ? "Increase" : "Decrease";
@@ -255,7 +255,7 @@ function Value({
componentSymbol: string; componentSymbol: string;
handleValueStep: (step: number) => void; handleValueStep: (step: number) => void;
scale: number; scale: number;
onKeyDown?: (e: React.KeyboardEvent) => void; onKeyDown?: ((e: React.KeyboardEvent) => void) | null;
}) { }) {
const valueRef = useRef(null); const valueRef = useRef(null);
const valueScroller = useScroll({ const valueScroller = useScroll({
@@ -287,7 +287,7 @@ function Value({
<input <input
type="text" type="text"
ref={valueRef} ref={valueRef}
value={Math.round(value * scale)} value={Math.floor(value * scale)}
onChange={onChange} onChange={onChange}
className={styles.value} className={styles.value}
onFocus={(e) => e.target.select()} onFocus={(e) => e.target.select()}
@@ -18,8 +18,10 @@ function TestWrapper() {
<div <div
style={{ style={{
width: 400, width: 400,
height: 25,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
border: "2px solid #7a7a7a",
}} }}
> >
<ValueEditor <ValueEditor
@@ -53,7 +55,7 @@ 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", "140px") .should("have.css", "width", "138px")
.dataCy("R-value-input") .dataCy("R-value-input")
.should("have.value", "127"); .should("have.value", "127");
@@ -78,14 +80,14 @@ 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", "110px"); .should("have.css", "width", "109px");
// 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", "100") .should("have.value", "100")
.dataCy("R-slider-bar") .dataCy("R-slider-bar")
.should("have.css", "width", "111px") .should("have.css", "width", "110px")
.wait(50); .wait(50);
// Test increment/decrement buttons // Test increment/decrement buttons
@@ -132,7 +134,7 @@ 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", "140px") .should("have.css", "width", "138px")
.dataCy("R-value-input") .dataCy("R-value-input")
.should("have.value", "127"); .should("have.value", "127");
+13 -2
View File
@@ -92,9 +92,20 @@ export function chooseValueByDirection(
return direction === Direction.HORIZONTAL ? xValue : yValue; return direction === Direction.HORIZONTAL ? xValue : yValue;
} }
export function roundTo(value: number, decimals: number = 0) { export function roundTo(
value: number,
decimals: number = 0,
direction: "up" | "down" | null = null,
) {
const factor = Math.pow(10, decimals); const factor = Math.pow(10, decimals);
return Math.round(value * factor) / factor; switch (direction) {
case "up":
return Math.ceil(value * factor) / factor;
case "down":
return Math.floor(value * factor) / factor;
default:
return Math.round(value * factor) / factor;
}
} }
export function formatCssRgb(hex: Hex) { export function formatCssRgb(hex: Hex) {
+5
View File
@@ -1 +1,6 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface Window {
framerMotionTestOverride?: boolean;
originalRequestAnimationFrame?: typeof requestAnimationFrame;
}