Completed palette editor, ui overhaul.
Test and Build / test-and-build (push) Failing after 2m44s

This commit is contained in:
Jay
2026-03-23 08:24:44 -04:00
parent 9fec89949b
commit 5f6d0f43ee
33 changed files with 2713 additions and 611 deletions
+4 -1
View File
@@ -36,7 +36,10 @@
// }
// }
Cypress.Commands.add("dataCy", (value: string) => {
Cypress.Commands.add("dataCy", (value: string, noTimeout?: boolean) => {
if (noTimeout) {
return cy.get(`[data-cy="${value}"]`, { timeout: 0 });
}
return cy.get(`[data-cy="${value}"]`);
});
+7
View File
@@ -5,6 +5,13 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>Components App</title>
<style>
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
Arial, sans-serif;
}
</style>
</head>
<body>
<div data-cy-root></div>
+1
View File
@@ -24,6 +24,7 @@ export default tseslint.config(
"warn",
{ allowConstantExport: true },
],
"react-hooks/exhaustive-deps": "off",
},
},
);
+2 -1
View File
@@ -14,7 +14,8 @@
"preview": "vite preview",
"test:wasm": "cargo test --lib --manifest-path colorlib/Cargo.toml",
"test:wasmdoc": "cargo test --doc --manifest-path colorlib/Cargo.toml",
"test": "vitest",
"test": "vitest run",
"test:watch": "vitest",
"test:component": "cypress run --component -b chromium",
"test:component:fire": "cypress run --component -b firefox",
"test:e2e": "cypress run --e2e -b chromium",
+28 -19
View File
@@ -1,30 +1,36 @@
.background {
width: 100%;
min-height: 100%;
display: flex;
}
.appWrapper {
background-color: white;
height: 100%;
background-color: #f9f9f9;
min-height: 100%;
width: 1200px;
margin: 0 auto;
box-shadow: 0 0 40px #7a7a7a;
border-left: 2px solid #7a7a7a;
border-right: 2px solid #7a7a7a;
overflow: hidden;
border-left: 1px solid #c9c9c9;
border-right: 1px solid #c9c9c9;
}
.mainLayout {
height: 100%;
height: 100%;
display: grid;
grid-template-areas:
"header header"
"picker palette";
grid-template-rows: auto 1fr;
grid-template-columns: 1fr 2fr;
grid-template-rows: 76px 1fr;
}
.appHeader {
grid-area: header;
display: flex;
align-items: baseline;
border-bottom: 2px solid #7a7a7a;
padding: 20px 30px 12px;
align-items: center;
border-bottom: 1px solid #c9c9c9;
padding: 20px 30px 22px;
}
.appHeader .title {
@@ -52,38 +58,41 @@
grid-area: picker;
display: flex;
flex-direction: column;
border-right: 2px solid #7a7a7a;
border-right: 1px solid #c9c9c9;
}
.secondZone {
min-width: 0;
grid-area: palette;
color: #555;
font-style: italic;
display: flex;
flex-direction: column;
}
.colorHistoryWrapper {
flex-shrink: 0;
box-sizing: border-box;
border-bottom: 2px solid #7a7a7a;
border-bottom: 1px solid #c9c9c9;
position: relative;
}
.colorPickerWrapper {
border-bottom: 2px solid #7a7a7a;
padding: 20px 40px 40px;
border-bottom: 1px solid #c9c9c9;
padding: 20px 40px 26px;
}
.colorValuesWrapper {
padding: 40px;
}
.colorHistoryWrapper {
padding: 24px 40px;
}
.paletteEditorWrapper {
flex: 2;
min-height: 0;
overflow-y: hidden;
border-bottom: 1px solid #c9c9c9;
}
.paletteLibraryWrapper {
flex: 1;
}
/* Large */
+56 -235
View File
@@ -1,199 +1,70 @@
import { useState } from "react";
import { useMemo } from "react";
import clsx from "clsx";
import { Color } from "colorlib";
import ColorHistory from "@/components/ColorHistory/ColorHistory";
import ColorPicker from "@/components/ColorPicker/ColorPicker";
import ColorValues from "@/components/ColorValues/ColorValues";
import { LeftMenu, RightMenu } from "@/components/SideMenu";
import { useMediaQuery } from "@/providers/hooks";
import { useSelectedColor } from "@/providers/hooks";
import styles from "./App.module.css";
import PaletteEditor from "./components/PaletteEditor/PaletteEditor";
import { deserializeCard, loadActiveCardId, loadCards } from "./hooks/storage";
import { formatCssRgb } from "./util";
// Menu Button Components
function App() {
const lum = 0.75;
const chr = 0.8;
const steps = 8;
interface MenuButtonProps {
onClick: () => void;
isOpen: boolean;
}
function LeftMenuButton({ onClick, isOpen }: MenuButtonProps) {
return (
<button
className={styles.leftMenuButton}
onClick={onClick}
aria-label="Open left menu"
aria-haspopup="dialog"
aria-expanded={isOpen}
>
</button>
const colors = useMemo(
() =>
Array.from({ length: steps }, (_, index) => {
const hue = (index * 360) / (steps - 1);
return Color.from_hcl(hue, chr, lum);
}),
[],
);
}
function RightMenuButton({ onClick, isOpen }: MenuButtonProps) {
return (
<button
className={styles.rightMenuButton}
onClick={onClick}
aria-label="Open right menu"
aria-haspopup="dialog"
aria-expanded={isOpen}
>
</button>
const colorGradient = useMemo(
() =>
colors
.map((color, index) => {
const colorString = formatCssRgb(color.hex);
const percentage = (index / (colors.length - 1)) * 100;
return `${colorString} ${percentage}%`;
})
.join(", "),
[],
);
}
// Mobile Layout Components
interface MenuStateProps {
isRightMenuOpen: boolean;
isLeftMenuOpen: boolean;
setIsRightMenuOpen: (state: boolean) => void;
setIsLeftMenuOpen: (state: boolean) => void;
}
function MobileTopNav({
onLeftMenuClick,
onRightMenuClick,
isRightMenuOpen,
isLeftMenuOpen,
}: {
onLeftMenuClick: () => void;
onRightMenuClick: () => void;
isRightMenuOpen: boolean;
isLeftMenuOpen: boolean;
}) {
return (
<nav className={styles.mobileTopNav} aria-label="Mobile top navigation">
<LeftMenuButton onClick={onLeftMenuClick} isOpen={isLeftMenuOpen} />
<RightMenuButton onClick={onRightMenuClick} isOpen={isRightMenuOpen} />
</nav>
);
}
function MobileLeftNav({ onClick, isOpen }: MenuButtonProps) {
return (
<nav className={styles.mobileLeftNav} aria-label="Mobile left navigation">
<LeftMenuButton onClick={onClick} isOpen={isOpen} />
</nav>
);
}
function MobileRightNav({ onClick, isOpen }: MenuButtonProps) {
return (
<nav className={styles.mobileRightNav} aria-label="Mobile right navigation">
<RightMenuButton onClick={onClick} isOpen={isOpen} />
</nav>
);
}
function MobileFirstZone() {
const { selectedColor, selectedColorActions } = useSelectedColor();
return (
<section className={styles.mobileFirstZone} aria-label="Color tools">
<div
className={styles.tabWrapper}
role="region"
aria-roledescription="carousel"
aria-label="Swipe left or right to view different tools"
className={styles.background}
style={{
background: `linear-gradient(180deg, ${colorGradient})`,
}}
>
<div
className={clsx(styles.tab, styles.colorPickerWrapper)}
role="group"
aria-roledescription="slide"
aria-label="Color Picker"
>
<ColorPicker color={selectedColor} actions={selectedColorActions} />
</div>
<div
className={clsx(styles.tab, styles.colorValuesWrapper)}
role="group"
aria-roledescription="slide"
aria-label="Color values"
>
<ColorValues color={selectedColor} actions={selectedColorActions} />
<div className={styles.appWrapper} role="application">
<DesktopContent />
</div>
</div>
</section>
);
}
function MobileSecondZone() {
function DesktopContent() {
return (
<section className={styles.mobileSecondZone} aria-label="Palette tools">
<div
className={styles.paletteEditorWrapper}
aria-label="Palette editor"
></div>
</section>
<div className={styles.mainLayout}>
<header className={styles.appHeader}>
<span className={styles.title}>LUMINANCE</span>
<span className={styles.subtitle}>A color picker for humans.</span>
</header>
<FirstZone />
<SecondZone />
</div>
);
}
function MobileContent({
isLeftMenuOpen,
setIsLeftMenuOpen,
isRightMenuOpen,
setIsRightMenuOpen,
}: MenuStateProps) {
const toggleRightMenu = () => setIsRightMenuOpen(!isRightMenuOpen);
const toggleLeftMenu = () => setIsLeftMenuOpen(!isLeftMenuOpen);
const { isMobilePortrait, isMobileLandscape } = useMediaQuery();
return (
<main className={styles.mobileContent}>
{isMobilePortrait && (
<MobileTopNav
onLeftMenuClick={toggleLeftMenu}
onRightMenuClick={toggleRightMenu}
isLeftMenuOpen={isLeftMenuOpen}
isRightMenuOpen={isRightMenuOpen}
/>
)}
{isMobileLandscape && (
<MobileLeftNav onClick={toggleLeftMenu} isOpen={isLeftMenuOpen} />
)}
<MobileFirstZone />
<MobileSecondZone />
{isMobileLandscape && (
<MobileRightNav onClick={toggleRightMenu} isOpen={isRightMenuOpen} />
)}
<LeftMenu
isOpen={isLeftMenuOpen}
onClose={() => setIsLeftMenuOpen(false)}
>
<div id="user-info" aria-label="User information">
User Info
</div>
</LeftMenu>
<RightMenu
isOpen={isRightMenuOpen}
onClose={() => setIsRightMenuOpen(false)}
>
<div
id="palette-library"
className={styles.paletteLibraryWrapper}
aria-label="Palette library"
>
Palette Library
</div>
</RightMenu>
</main>
);
}
// Desktop Layout Components
function FirstZone() {
const { selectedColor, selectedColorActions } = useSelectedColor();
@@ -212,6 +83,16 @@ function FirstZone() {
function SecondZone() {
const { selectedColor, selectedColorActions } = useSelectedColor();
const initialCardState = useMemo(() => {
const id = loadActiveCardId();
const cards = loadCards();
const saved = id ? cards[id] : null;
console.log(id, cards);
return saved
? { present: deserializeCard(saved), history: [], future: [] }
: undefined;
}, []);
return (
<section className={styles.secondZone} aria-label="Palette tools">
<div className={styles.colorHistoryWrapper} aria-label="Color History">
@@ -221,10 +102,13 @@ function SecondZone() {
disabled={false}
/>
</div>
<div
className={styles.paletteEditorWrapper}
aria-label="Palette editor"
></div>
<div className={styles.paletteEditorWrapper} aria-label="Palette editor">
<PaletteEditor
pickerColor={selectedColor.hex}
setPickerColor={selectedColorActions.hex.setHex}
initialCardState={initialCardState}
/>
</div>
<div
className={styles.paletteLibraryWrapper}
aria-label="Palette library"
@@ -233,67 +117,4 @@ function SecondZone() {
);
}
function DesktopContent() {
return (
<div className={styles.mainLayout}>
<header className={styles.appHeader}>
<span className={styles.title}>LUMINANCE</span>
<span className={styles.subtitle}>A color picker for humans.</span>
</header>
<FirstZone />
<SecondZone />
</div>
);
}
// Main App Component
function App() {
const [isRightMenuOpen, setIsRightMenuOpen] = useState(false);
const [isLeftMenuOpen, setIsLeftMenuOpen] = useState(false);
// const { isDesktop } = useMediaQuery();
const isDesktop = true;
const lum = 0.75;
const chr = 0.8;
const steps = 8;
const colors = Array.from({ length: steps }, (_, index) => {
const hue = (index * 360) / (steps - 1);
return Color.from_hcl(hue, chr, lum);
});
const colorGradient = colors
.map((color, index) => {
const colorString = formatCssRgb(color.hex);
const percentage = (index / (colors.length - 1)) * 100;
return `${colorString} ${percentage}%`;
})
.join(", ");
return (
<div
className={styles.background}
style={{
width: "100%",
height: "100%",
background: `linear-gradient(180deg, ${colorGradient})`,
}}
>
<div className={styles.appWrapper} role="application">
{!isDesktop && (
<MobileContent
isLeftMenuOpen={isLeftMenuOpen}
setIsLeftMenuOpen={setIsLeftMenuOpen}
isRightMenuOpen={isRightMenuOpen}
setIsRightMenuOpen={setIsRightMenuOpen}
/>
)}
{isDesktop && <DesktopContent />}
</div>
</div>
);
}
export default App;
@@ -4,7 +4,7 @@
flex-wrap: nowrap;
overflow-x: auto;
width: 100%;
padding: 5px 0px 0px;
padding: 10px 0px 0px;
/* Improve scrolling experience */
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
@@ -17,32 +17,36 @@
height: 50px;
width: 25px;
margin: 5px;
border: 2px solid #7a7a7a;
transition:
margin 200ms,
height 200ms,
width 200ms;
border: 1px solid #7a7a7a;
background-color: transparent;
cursor: pointer;
}
.historyColor:hover {
height: 56px;
width: 31px;
margin: 2px;
}
.historyColor:first-of-type:hover {
margin-left: 12px;
transition:
box-shadow 150ms ease-out,
transform 150ms ease-out;
box-shadow:
rgba(60, 64, 67, 0.25) 0px 1px 2px 0px,
rgba(60, 64, 67, 0.1) 0px 1px 3px 0px;
}
.historyColor:first-of-type {
margin-left: 15px;
}
.historyColor:last-of-type:hover {
margin-right: 12px;
}
.historyColor:last-of-type {
margin-right: 15px;
}
.historyColor:hover {
transform: translateY(-0.5px);
box-shadow:
rgba(60, 64, 67, 0.3) 0px 2px 6px 0px,
rgba(60, 64, 67, 0.15) 0px 3px 8px 2px;
}
.historyColor:active {
transform: translateY(0.5px);
box-shadow:
rgba(60, 64, 67, 0.2) 0px 0px 1px 0px,
inset 0px 1px 2px rgba(0, 0, 0, 0.2);
transition-duration: 50ms;
}
@@ -53,10 +53,11 @@ describe("color history", () => {
cy.enableTransitions();
});
it("adds stable color values after 1 second", () => {
it("adds stable color values after 3 seconds", () => {
// add stable values to history
cy.dataCy("hex-value-input").as("value").clear().type("#00F536");
cy.tick(1000);
cy.dataCy("hex-value-input").as("value").clear().type("#00F536").blur();
cy.wait(0); // let blur take effect
cy.tick(3000);
cy.dataCy("color-history").children().should("have.length", 1);
cy.dataCy("history-color-0").should(
@@ -65,8 +66,9 @@ describe("color history", () => {
"rgb(0, 245, 54)",
);
cy.get("@value").clear().type("#E23AEC");
cy.tick(1000);
cy.get("@value").clear().type("#E23AEC").blur();
cy.wait(0);
cy.tick(3000);
cy.dataCy("color-history").children().should("have.length", 2);
cy.dataCy("history-color-0").should(
@@ -81,14 +83,15 @@ describe("color history", () => {
// disable history
cy.dataCy("disabled-checkbox").click();
cy.get("@value").clear().type("#00C3EE");
cy.tick(1000);
cy.get("@value").clear().type("#00C3EE").blur();
cy.wait(0);
cy.tick(3000);
cy.dataCy("color-history").children().should("have.length", 2);
// re-enable history
cy.dataCy("disabled-checkbox").click();
cy.tick(1000);
cy.tick(3000);
cy.dataCy("color-history").children().should("have.length", 3);
});
+3 -2
View File
@@ -18,6 +18,7 @@ function ColorHistory({
disabled: boolean;
}) {
const [history, setHistory] = useState<Color[]>([]);
const colorValue = color.hex.to_code();
const maxItems = 50;
useEffect(() => {
@@ -31,10 +32,10 @@ function ColorHistory({
const newHistory = [color, ...prev];
return newHistory.slice(0, maxItems);
});
}, 1000);
}, 2000);
return () => clearTimeout(timer);
}, [color, disabled]);
}, [colorValue, disabled]);
const handleClick = (historyColor: Color) => {
setColor(historyColor);
+31 -29
View File
@@ -27,17 +27,21 @@ function ColorBar({
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);
const colorBarRef = useRef<colorlib.ColorBar | null>(null);
// Hooks
const smoothAnimation = useSmoothAnimation();
// Dimensions
const barWidth = parentDimensions.x > 0 ? parentDimensions.x - 54 : 0;
const barHeight = containerRef.current?.clientHeight;
// Slider interaction
const { sliderRef } = useSlider({
direction: Direction.HORIZONTAL,
@@ -50,13 +54,14 @@ function ColorBar({
// Update canvas when hue/luminance changes
useEffect(() => {
if (colorBar && canvasRef.current) {
const bar = colorBarRef.current;
if (bar && canvasRef.current) {
smoothAnimation(() => {
colorBar.fill_color(hue, luminance);
refreshColorBar(canvasRef.current!, colorBar);
bar.fill_color(hue, luminance);
refreshColorBar(canvasRef.current!, bar);
});
}
}, [hue, luminance, colorBar, smoothAnimation]);
}, [hue, luminance]);
// Get measurements
useEffect(() => {
@@ -71,30 +76,31 @@ function ColorBar({
// Resize color bar
useEffect(() => {
if (containerRef.current && canvasRef.current && parentDimensions.x > 0) {
if (!containerRef.current || !canvasRef.current || parentDimensions.x <= 0)
return;
colorBarRef.current?.free();
const newHeight = containerRef.current.clientHeight;
const newWidth = parentDimensions.x - 54;
const newColorBar = new colorlib.ColorBar(newWidth, newHeight);
const bar = new colorlib.ColorBar(newWidth, newHeight);
colorBarRef.current = bar;
setColorBar(newColorBar);
if (newColorBar) {
smoothAnimation(() => {
if (canvasRef.current) {
newColorBar.fill_color(hue, luminance);
refreshColorBar(canvasRef.current!, newColorBar);
bar.fill_color(hue, luminance);
refreshColorBar(canvasRef.current!, bar);
}
});
}
}
}, [
containerRef,
canvasRef,
parentDimensions,
hue,
luminance,
smoothAnimation,
]);
}, [parentDimensions]);
// free on unmount
useEffect(() => {
return () => {
colorBarRef.current?.free();
colorBarRef.current = null;
};
}, []);
return (
<div className={styles.colorBarWrapper} ref={containerRef}>
@@ -102,15 +108,11 @@ function ColorBar({
className={styles.colorBar}
ref={sliderRef}
style={{
width: colorBar?.get_width(),
height: colorBar?.get_height(),
width: barWidth,
height: barHeight,
}}
>
<canvas
ref={canvasRef}
width={colorBar?.get_width()}
height={colorBar?.get_height()}
/>
<canvas ref={canvasRef} width={barWidth} height={barHeight} />
</div>
</div>
);
@@ -1,9 +1,8 @@
.container {
display: grid;
grid-template-columns: 25px 1fr 25px;
grid-template-rows: 50px 1fr 25px;
grid-template-rows: 1fr 25px;
grid-template-areas:
". preview ."
"leftGrip square rightGrip"
". bottomGrip ."
". bar .";
@@ -13,14 +12,20 @@
grid-area: preview;
height: 25px;
margin-bottom: 15px;
border: 2px solid #7a7a7a;
border: 1px solid #7a7a7a;
box-shadow:
rgba(0, 0, 0, 0.16) 0px 3px 6px,
rgba(0, 0, 0, 0.23) 0px 3px 6px;
}
.pickerSquare {
grid-area: square;
position: relative;
aspect-ratio: 1/1;
border: 2px solid #7a7a7a;
border: 1px solid #7a7a7a;
box-shadow:
rgba(0, 0, 0, 0.16) 0px 3px 6px,
rgba(0, 0, 0, 0.23) 0px 3px 6px;
}
.pickerBar {
@@ -28,7 +33,49 @@
position: relative;
height: 25px;
margin-top: 15px;
border: 2px solid #7a7a7a;
border: 1px solid #7a7a7a;
box-shadow:
rgba(0, 0, 0, 0.16) 0px 3px 6px,
rgba(0, 0, 0, 0.23) 0px 3px 6px;
}
/* Grips */
.gripSlider {
position: relative;
width: 100%;
height: 100%;
}
.grip {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
background: #e7e7e7;
border: 1px solid #c9c9c9;
border-radius: 4px;
color: #8b8b8b;
transition:
background-color 150ms,
box-shadow 150ms;
box-shadow:
rgba(0, 0, 0, 0.12) 0px 2px 4px,
rgba(0, 0, 0, 0.2) 0px 2px 4px;
}
.horizontalGrip .grip {
height: 20px;
width: 32px;
}
.verticalGripLeft .grip,
.verticalGripRight .grip {
height: 32px;
width: 22px;
}
.horizontalGrip {
grid-area: bottomGrip;
}
.verticalGripLeft {
@@ -39,10 +86,6 @@
grid-area: rightGrip;
}
.horizontalGrip {
grid-area: bottomGrip;
}
/* Color Square */
.colorSquareWrapper {
height: 100%;
@@ -94,16 +137,3 @@
box-shadow 400ms;
pointer-events: none;
}
/* Square Grips */
.gripSlider {
position: relative;
width: 100%;
height: 100%;
}
.grip {
position: absolute;
width: 0;
height: 0;
}
+9 -9
View File
@@ -41,19 +41,19 @@ function ColorPicker({
return (
<div className={styles.container} ref={containerRef}>
<div
className={styles.preview}
style={{
backgroundColor: formatCssRgb(color.hex),
}}
></div>
{/* <div */}
{/* className={styles.preview} */}
{/* style={{ */}
{/* backgroundColor: formatCssRgb(color.hex), */}
{/* }} */}
{/* ></div> */}
<div className={styles.verticalGripLeft}>
<GripSlider
direction={Direction.VERTICAL}
value={color.hcl.l}
setValue={actions.hcl.setL}
valueRange={lumRange}
arrowDirection="right"
position="left"
invert={true}
parentDimensions={dimensions}
/>
@@ -77,7 +77,7 @@ function ColorPicker({
value={color.hcl.l}
setValue={actions.hcl.setL}
valueRange={lumRange}
arrowDirection="left"
position="right"
invert={true}
parentDimensions={dimensions}
/>
@@ -88,7 +88,7 @@ function ColorPicker({
value={color.hcl.h}
setValue={actions.hcl.setH}
valueRange={hueRange}
arrowDirection="up"
position="bottom"
parentDimensions={dimensions}
/>
</div>
+29 -22
View File
@@ -23,15 +23,13 @@ function ColorSquare({
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);
const colorSquareRef = useRef<colorlib.ColorSquare | null>(null);
// Hooks
const smoothAnimation = useSmoothAnimation();
@@ -56,13 +54,14 @@ function ColorSquare({
// Update canvas when chroma changes
useEffect(() => {
if (colorSquare && canvasRef.current) {
const square = colorSquareRef.current;
if (square && canvasRef.current) {
smoothAnimation(() => {
colorSquare.fill_chroma(chroma);
refreshColorSquare(canvasRef.current!, colorSquare);
square.fill_chroma(chroma);
refreshColorSquare(canvasRef.current!, square);
});
}
}, [chroma, colorSquare, smoothAnimation]);
}, [chroma]);
// Add event listeners
useEffect(() => {
@@ -78,26 +77,34 @@ function ColorSquare({
return onResize(() =>
setMeasurements(containerRef, setOrigin, setDimensions),
);
}, [containerRef, parentDimensions]);
}, [parentDimensions]);
// Resize square
useEffect(() => {
if (containerRef.current && canvasRef.current && parentDimensions.x > 0) {
if (!containerRef.current || !canvasRef.current || parentDimensions.x <= 0)
return;
colorSquareRef.current?.free();
const newSize = parentDimensions.x - 54;
const newColorSquare = new colorlib.ColorSquare(newSize);
const square = new colorlib.ColorSquare(newSize);
colorSquareRef.current = square;
setColorSquare(newColorSquare);
if (newColorSquare) {
smoothAnimation(() => {
if (canvasRef.current) {
newColorSquare.fill_chroma(chroma);
refreshColorSquare(canvasRef.current, newColorSquare);
square.fill_chroma(chroma);
refreshColorSquare(canvasRef.current, square);
}
});
}
}
}, [containerRef, canvasRef, parentDimensions, chroma, smoothAnimation]);
}, [containerRef, canvasRef, parentDimensions]);
// free on unmount
useEffect(() => {
return () => {
colorSquareRef.current?.free();
colorSquareRef.current = null;
};
}, []);
return (
<div className={styles.colorSquareWrapper} ref={containerRef}>
@@ -105,14 +112,14 @@ function ColorSquare({
className={styles.colorSquare}
ref={crosshairRef}
style={{
width: colorSquare?.get_size(),
height: colorSquare?.get_size(),
width: colorSquareRef.current?.get_size(),
height: colorSquareRef.current?.get_size(),
}}
>
<canvas
ref={canvasRef}
width={colorSquare?.get_size()}
height={colorSquare?.get_size()}
width={colorSquareRef.current?.get_size()}
height={colorSquareRef.current?.get_size()}
/>
</div>
</div>
+15 -20
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import * as colorlib from "colorlib";
import { useContrastToken } from "@/hooks/contrast";
import { onResize } from "@/hooks/window";
import type { CartesianSpace } from "@/types";
import { formatCssRgb, setMeasurements, valueToPosition } from "@/util";
@@ -21,15 +22,12 @@ export function SquareCrosshair({
}) {
const [, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
const [darkCrosshairs, setDarkCrosshairs] = useState(true);
const crosshairColor = { dark: "black", light: "white" };
const token = useContrastToken(() => luminance);
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 onResize(() =>
@@ -44,8 +42,8 @@ export function SquareCrosshair({
style={{
width: 1,
height: dimensions.y,
backgroundColor: darkCrosshairs ? "black" : "white",
boxShadow: darkCrosshairs ? "0 0 2px black" : "0 0 2px white",
backgroundColor: crosshairColor[token],
boxShadow: `0 0 2px ${crosshairColor[token]}`,
left: valueToPosition(hue, dimensions.x - 1, hueRange),
top: 0,
}}
@@ -55,8 +53,8 @@ export function SquareCrosshair({
style={{
width: dimensions.x,
height: 1,
backgroundColor: darkCrosshairs ? "black" : "white",
boxShadow: darkCrosshairs ? "0 0 2px black" : "0 0 2px white",
backgroundColor: crosshairColor[token],
boxShadow: `0 0 2px ${crosshairColor[token]}`,
left: 0,
top: valueToPosition(1 - luminance, dimensions.y - 1, lumRange),
}}
@@ -64,8 +62,8 @@ export function SquareCrosshair({
<div
className={styles.crossEye}
style={{
borderColor: darkCrosshairs ? "black" : "white",
boxShadow: darkCrosshairs ? "0 0 1px black" : "0 0 1px white",
borderColor: crosshairColor[token],
boxShadow: `0 0 1px ${crosshairColor[token]}`,
backgroundColor: formatCssRgb(hex),
top: valueToPosition(1 - luminance, dimensions.y - 1, lumRange) - 6,
left: valueToPosition(hue, dimensions.x - 1, hueRange) - 6,
@@ -88,14 +86,11 @@ export function BarCrosshair({
}) {
const [, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
const [darkCrosshairs, setDarkCrosshairs] = useState(true);
const crosshairColor = { dark: "black", light: "white" };
const token = useContrastToken(() => luminance);
const containerRef = useRef<HTMLDivElement>(null);
const chromaRange = { min: 0, max: 1 };
useEffect(() => {
setDarkCrosshairs(luminance > 0.5);
}, [luminance]);
useEffect(() => {
setMeasurements(containerRef, setOrigin, setDimensions);
return onResize(() =>
@@ -110,8 +105,8 @@ export function BarCrosshair({
style={{
width: 1,
height: dimensions.y,
backgroundColor: darkCrosshairs ? "black" : "white",
boxShadow: darkCrosshairs ? "0 0 2px black" : "0 0 2px white",
backgroundColor: crosshairColor[token],
boxShadow: `0 0 2px ${crosshairColor[token]}`,
left: valueToPosition(chroma, dimensions.x - 1, chromaRange),
top: 0,
}}
@@ -119,8 +114,8 @@ export function BarCrosshair({
<div
className={styles.crossEye}
style={{
borderColor: darkCrosshairs ? "black" : "white",
boxShadow: darkCrosshairs ? "0 0 1px black" : "0 0 1px white",
borderColor: crosshairColor[token],
boxShadow: `0 0 1px ${crosshairColor[token]}`,
backgroundColor: formatCssRgb(hex),
top: 6,
left: valueToPosition(chroma, dimensions.x - 1, chromaRange) - 6,
+23 -38
View File
@@ -1,5 +1,7 @@
import { useEffect, useState } from "react";
import { GripHorizontal, GripVertical } from "lucide-react";
import type { Setter } from "@/hooks/color";
import { useSlider } from "@/hooks/slider";
import { onResize } from "@/hooks/window";
@@ -18,7 +20,7 @@ function GripSlider({
value,
setValue,
valueRange,
arrowDirection,
position,
invert = false,
parentDimensions,
}: {
@@ -26,7 +28,7 @@ function GripSlider({
value: number;
setValue: Setter;
valueRange: Range;
arrowDirection: "up" | "left" | "right";
position: "bottom" | "right" | "left";
invert?: boolean;
parentDimensions: CartesianSpace;
}) {
@@ -52,48 +54,18 @@ function GripSlider({
return onResize(() => setMeasurements(sliderRef, setOrigin, setDimensions));
}, [sliderRef, 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 {};
}
})();
const isVertical = direction === Direction.VERTICAL;
return (
<div className={styles.gripSlider} ref={sliderRef}>
<div
className={styles.grip}
style={{
...arrowStyle,
cursor: isDragging ? "grabbing" : "grab",
top: chooseValueByDirection(
direction,
0,
-12 +
6,
-17 +
valueToPosition(
valueRange.max - value,
dimensions.y - 1,
@@ -102,11 +74,24 @@ function GripSlider({
),
left: chooseValueByDirection(
direction,
-12 + valueToPosition(value, dimensions.x - 1, valueRange),
0,
-16 + valueToPosition(value, dimensions.x - 1, valueRange),
(() => {
if (position === "right") {
return 6;
} else if (position === "left") {
return -4;
}
return 0;
})(),
),
}}
/>
>
{isVertical ? (
<GripVertical size={24} strokeWidth={3} />
) : (
<GripHorizontal size={24} strokeWidth={3} />
)}
</div>
</div>
);
}
@@ -11,7 +11,12 @@
max-height: 94px;
flex: 1;
min-height: 0;
border: 2px solid #7a7a7a;
background-color: #f7f7f7;
border: 1px solid #c9c9c9;
border-radius: 4px;
box-shadow:
rgba(0, 0, 0, 0.1) 0px 2px 4px,
rgba(0, 0, 0, 0.15) 0px 2px 4px;
}
.componentWrapper {
@@ -22,7 +27,6 @@
min-height: 0;
font-family: monospace;
border-top: 1px solid #7a7a7a;
border-bottom: 1px solid #7a7a7a;
}
.componentWrapper:first-of-type {
@@ -37,7 +41,7 @@
display: flex;
align-items: center;
justify-content: center;
border-right: 2px solid #7a7a7a;
border-right: 1px solid #7a7a7a;
}
.section:last-of-type {
@@ -65,7 +69,7 @@
top: 0;
left: 0;
height: 100%;
background-color: #aaa;
background-color: #c1c1c1;
pointer-events: none;
}
@@ -84,6 +88,9 @@
user-select: none;
background: none;
border: none;
transition-property: background-color;
transition-duration: 150ms;
transition-timing-function: ease-out;
}
.button:hover {
@@ -114,9 +121,14 @@
display: flex;
align-items: stretch;
font-family: monospace;
border: 2px solid #7a7a7a;
background-color: #f7f7f7;
border: 1px solid #c9c9c9;
border-radius: 4px;
height: 25px;
max-width: 150px;
box-shadow:
rgba(0, 0, 0, 0.1) 0px 2px 4px,
rgba(0, 0, 0, 0.15) 0px 2px 4px;
}
.hexLabel {
+5 -5
View File
@@ -45,6 +45,11 @@ function ColorValues({
return (
<div className={styles.colorValuesWrapper} ref={wrapperRef}>
<HexEditor
color={color.hex}
actions={actions.hex}
onKeyDown={handleKeyDown}
/>
<SpaceEditor
space="HCL"
color={color.hcl}
@@ -63,11 +68,6 @@ function ColorValues({
actions={actions.rgb}
onKeyDown={handleKeyDown}
/>
<HexEditor
color={color.hex}
actions={actions.hex}
onKeyDown={handleKeyDown}
/>
</div>
);
}
@@ -49,11 +49,12 @@ describe("hex editor tests", () => {
cy.get("@color").should("have.text", "000000");
cy.get("@value").blur();
cy.get("@value").should("have.value", "#000000");
cy.get("@value").should("have.value", "#000");
cy.get("@color").should("have.text", "000000");
// Type a new value
cy.get("@value").focus().type("{backspace}");
cy.get("@value").focus();
cy.get("@value").type("{backspace}");
cy.get("@value").should("have.value", "");
cy.get("@color").should("have.text", "000000");
@@ -62,11 +63,11 @@ describe("hex editor tests", () => {
cy.get("@color").should("have.text", "000000");
cy.get("@value").type("c");
cy.get("@value").should("have.value", "#ABC");
cy.get("@color").should("have.text", "AABBCC");
cy.get("@value").should("have.value", "abc");
cy.get("@color").should("have.text", "000000");
cy.get("@value").blur();
cy.get("@value").should("have.value", "#AABBCC");
cy.get("@value").should("have.value", "#ABC");
cy.get("@color").should("have.text", "AABBCC");
// Invalid blur resets to last valid color
@@ -102,8 +102,8 @@ describe("space editor tests", () => {
});
cy.dataCy("rgb-value").should("have.text", "RGB (16, 75, 74)");
cy.dataCy("hsv-value").should("have.text", "HSV (178, 0.78, 0.29)");
cy.dataCy("hcl-value").should("have.text", "HCL (178, 0.78, 0.25)");
cy.dataCy("hsv-value").should("have.text", "HSV (179, 0.78, 0.29)");
cy.dataCy("hcl-value").should("have.text", "HCL (179, 0.78, 0.25)");
cy.dataCy("hex-value").should("have.text", "HEX: #104B4A");
});
});
@@ -55,7 +55,7 @@ describe("component editor tests", () => {
cy.dataCy("R-slider")
.click()
.dataCy("R-slider-bar")
.should("have.css", "width", "138px")
.should("have.css", "width", "140px")
.dataCy("R-value-input")
.should("have.value", "127");
@@ -80,7 +80,7 @@ describe("component editor tests", () => {
.type("100")
.should("have.value", "100")
.dataCy("R-slider-bar")
.should("have.css", "width", "109px");
.should("have.css", "width", "110px");
// Scrolling input should update value
cy.dataCy("R-value-input")
@@ -134,7 +134,7 @@ describe("component editor tests", () => {
cy.dataCy("R-slider")
.click()
.dataCy("R-slider-bar")
.should("have.css", "width", "138px")
.should("have.css", "width", "140px")
.dataCy("R-value-input")
.should("have.value", "127");
+16 -29
View File
@@ -6,6 +6,7 @@ import * as colorlib from "colorlib";
import { ChevronLeft, ChevronRight } from "lucide-react";
import type { HexColorActions } from "@/hooks/color";
import { extractHexValue, formatHexString } from "@/hooks/hex";
import { useScroll } from "@/hooks/scroll";
import { useSlider } from "@/hooks/slider";
import { onResize } from "@/hooks/window";
@@ -340,30 +341,6 @@ function useLongPressRepeat(
// Hex Editor //
// ---------- //
const extractHexValue = (value: string): string | null => {
const match = value.match(/^#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})$/);
return match ? match[1] : null;
};
const formatHexString = (
color: colorlib.Hex,
preserveShortFormat: boolean = false,
): string => {
const hexValue = color.to_code();
if (preserveShortFormat) {
if (
hexValue[0] === hexValue[1] &&
hexValue[2] === hexValue[3] &&
hexValue[4] === hexValue[5]
) {
return `#${hexValue[0]}${hexValue[2]}${hexValue[4]}`;
}
}
return `#${color.to_code()}`;
};
export function HexEditor({
color,
actions,
@@ -375,24 +352,34 @@ export function HexEditor({
}) {
const [inputValue, setInputValue] = useState(formatHexString(color));
const [isShortHex, setIsShortHex] = useState(false);
const isFocused = useRef(false);
useEffect(() => {
if (!isFocused.current) {
setInputValue(formatHexString(color, isShortHex));
}
}, [color, isShortHex]);
const onFocus = (e: ChangeEvent<HTMLInputElement>) => {
isFocused.current = true;
e.target.select();
};
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setInputValue(value);
};
const hex = extractHexValue(value);
const onBlur = () => {
isFocused.current = false;
const hex = extractHexValue(inputValue);
if (hex) {
setIsShortHex(hex.length === 3);
const newColor = colorlib.Hex.from_code(hex);
actions.setHex(newColor);
setInputValue(formatHexString(newColor, isShortHex));
return;
}
};
const onBlur = () => {
setInputValue(formatHexString(color));
};
@@ -414,7 +401,7 @@ export function HexEditor({
value={inputValue}
onChange={onChange}
onBlur={onBlur}
onFocus={(e) => e.target.select()}
onFocus={onFocus}
onKeyDown={handleKeyDown}
/>
</div>
@@ -6,32 +6,461 @@
}
.actionBar {
height: 40px;
padding: 10px 11px;
border-bottom: 1px solid #c9c9c9;
display: flex;
}
.actionBar .actionButton {
cursor: pointer;
margin: 4px;
background-color: #e7e7e7;
border-radius: 0;
border: 1px solid #e2e2e2;
box-shadow:
rgba(60, 64, 67, 0.3) 0px 1px 2px 0px,
rgba(60, 64, 67, 0.05) 0px 2px 6px 1px;
transition-property: background-color, box-shadow;
transition-duration: 150ms;
transition-timing-function: ease-out;
}
.actionBar .actionButton:hover {
background-color: #f0f0f0;
box-shadow:
rgba(60, 64, 67, 0.25) 0px 3px 6px 0px,
rgba(60, 64, 67, 0.1) 0px 4px 8px 2px;
transform: translateY(-1px);
}
.actionBar .actionButton:active {
background-color: #d1d1d1;
box-shadow:
rgba(60, 64, 67, 0.3) 0px 1px 1px 0px,
inset 0px 1px 2px rgba(0, 0, 0, 0.2);
transform: translateY(1px);
transition-duration: 50ms;
}
.actionBar .iconButton {
aspect-ratio: 1;
}
.actionBar .wordButton {
font-size: 14px;
padding: 4px 4px;
}
.actionBar .activeButton {
background-color: #f8b800;
border-color: #e1964b;
box-shadow:
rgba(60, 64, 67, 0.25) 0px 1px 2px 0px,
0 0 8px 1px rgba(255, 163, 56, 0.5),
inset 0 0 4px rgba(255, 255, 255, 0.4);
}
.actionBar .activeButton:hover {
background-color: #fea944;
box-shadow:
rgba(60, 64, 67, 0.25) 0px 2px 4px 0px,
0 0 12px 2px rgba(255, 163, 56, 0.6),
inset 0 0 4px rgba(255, 255, 255, 0.5);
transform: translateY(-0.5px);
}
.actionBar .activeButton:active {
background-color: #eb8d16;
box-shadow:
rgba(60, 64, 67, 0.2) 0px 0px 1px 0px,
0 0 4px 0px rgba(255, 163, 56, 0.4),
inset 0px 1px 2px rgba(0, 0, 0, 0.2);
transform: translateY(0.5px);
transition-duration: 50ms;
}
.cardWrapper {
flex: 1;
padding: 20px;
margin: 32px;
display: grid;
border-radius: 8px;
grid-template-columns: 1fr 1fr 4fr;
grid-template-rows: 40px 1fr;
grid-template-rows: auto 1fr;
grid-template-areas:
". . card-header"
"sync sync card-header"
"preview selection palette";
overflow-y: auto;
/* border: 1px solid #ddd; */
box-shadow:
rgba(0, 0, 0, 0.19) 0px 10px 20px,
rgba(0, 0, 0, 0.23) 0px 6px 6px;
}
.sync {
grid-area: sync;
cursor: pointer;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
background-color: #f6f6f6;
border-right: 2px solid #aaa;
border-bottom: 2px solid #aaa;
transition-property: background-color;
transition-duration: 150ms;
transition-timing-function: ease-out;
}
.sync:hover {
background-color: #f1f1f1;
}
.sync:active {
background-color: #e7e7e7;
}
.sync .leftSpan {
flex: 1;
text-align: right;
}
.sync .middleSpan {
flex: 0 0 auto;
display: flex;
align-items: middle;
margin: 0px 8px;
}
.sync .rightSpan {
flex: 1;
text-align: left;
}
.cardHeader {
grid-area: card-header;
display: flex;
padding: 4px 8px 8px;
color: #292929;
background-color: #f6f6f6;
font-size: 20px;
font-weight: 700;
border-bottom: 2px solid #aaa;
}
.cardHeader .editableFieldWrapper {
display: flex;
align-items: center;
}
.cardHeader .editableField {
}
.cardHeader .editingField {
outline: none;
}
.cardHeader .editableFieldButton {
cursor: pointer;
color: black;
background-color: rgba(0, 0, 0, 0.1);
margin: 2px -2px 2px 8px;
padding: 5px 6px;
border: none;
border-radius: 4px;
transition-property: background-color;
transition-duration: 150ms;
transition-timing-function: ease-out;
}
.cardHeader .editableFieldButton:hover {
background-color: rgba(0, 0, 0, 0.15);
}
.cardHeader .editableFieldButton:active {
background-color: rgba(0, 0, 0, 0.2);
}
.pickerColor {
grid-area: preview;
display: flex;
align-items: center;
padding: 10px;
}
.paletteColor {
grid-area: selection;
display: flex;
align-items: center;
padding: 10px;
}
.pickerColor .arrowIndicator {
margin-left: auto;
}
@keyframes slideRight {
0% {
transform: translateX(0);
}
50% {
transform: translateX(3px);
}
100% {
transform: translateX(0);
}
}
@keyframes slideLeft {
0% {
transform: translateX(0);
}
50% {
transform: translateX(-3px);
}
100% {
transform: translateX(0);
}
}
.pickerColor:hover .arrowIndicator {
animation: slideRight 1.5s ease-in-out infinite;
}
.paletteColor:hover .arrowIndicator {
animation: slideLeft 1.5s ease-in-out infinite;
}
.previewPane .arrowIndicator {
transform: translateX(0);
}
.previewPane .arrowIndicatorDark {
color: rgba(0, 0, 0, 0.4);
}
.previewPane:hover .arrowIndicatorDark {
color: rgba(0, 0, 0, 0.7);
}
.previewPane:active .arrowIndicatorDark {
color: rgba(0, 0, 0, 0.5);
}
.previewPane .arrowIndicatorLight {
color: rgba(255, 255, 255, 0.4);
}
.previewPane:hover .arrowIndicatorLight {
color: rgba(255, 255, 255, 0.7);
}
.previewPane:active .arrowIndicatorLight {
color: rgba(255, 255, 255, 0.5);
}
.palette {
grid-area: palette;
display: flex;
flex-direction: column;
overflow-y: auto;
min-height: 0;
background-color: #f7f7f7;
}
.paletteRowWrapper {
flex: 1;
cursor: pointer;
min-height: 3em;
}
.paletteRowWrapper.draggable {
cursor: grab;
}
.paletteRowWrapper.dragging {
cursor: grabbing;
}
.paletteRowWrapper.dragging .paletteRow,
.paletteRowWrapper.multiSelected .paletteRow {
transform: scale(0.96, 0.9);
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
z-index: 10;
}
.paletteRow {
width: 100%;
height: 100%;
position: relative;
transition:
transform 200ms ease-out,
border-radius 200ms ease-out;
}
.paletteRow .selectedIndicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.paletteRow .indicatorDark {
color: rgba(0, 0, 0, 0.7);
}
.paletteRow .indicatorLight {
color: rgba(255, 255, 255, 0.7);
}
.paletteRow .targettedIndicator {
opacity: 0;
visibility: hidden;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
transition: opacity 150ms ease-out;
}
.paletteRow:hover .targettedIndicator {
opacity: 1;
visibility: visible;
}
.modeDecorator {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
}
.checkDecorator {
width: 20px;
height: 20px;
border-radius: 3px;
border: 2px solid;
display: flex;
align-items: center;
justify-content: center;
}
.checkDecoratorDark {
color: rgba(0, 0, 0, 0.8);
border-color: rgba(0, 0, 0, 0.5);
background-color: rgba(255, 255, 255, 0.15);
}
.checkDecoratorLight {
color: rgba(255, 255, 255, 0.8);
border-color: rgba(255, 255, 255, 0.5);
background-color: rgba(0, 0, 0, 0.2);
}
.gripDark {
color: rgba(0, 0, 0, 0.4);
}
.gripLight {
color: rgba(255, 255, 255, 0.4);
}
.paletteRowDataWithDecorator {
padding-right: 36px;
}
.paletteRowData {
display: flex;
padding: 10px;
align-items: center;
}
.paletteRowData .colorName {
font-size: 14px;
}
.paletteRowData .colorHex {
font-size: 14px;
font-family: monospace;
margin-left: auto;
}
.paletteRow .editableFieldWrapper {
display: flex;
align-items: center;
}
.paletteRow .editableField,
.paletteRow .field {
cursor: text;
border-radius: 4px;
margin-right: 2px;
}
.paletteRow .editableFieldDark,
.paletteRow .fieldDark {
color: rgba(0, 0, 0, 0.95);
background-color: rgba(255, 255, 255, 0.4);
}
.paletteRow .editableFieldLight,
.paletteRow .fieldLight {
color: rgba(255, 255, 255, 0.95);
background-color: rgba(0, 0, 0, 0.2);
}
.paletteRow .editableFieldButton {
transition-property: color;
transition-duration: 150ms;
transition-timing-function: ease-out;
}
.paletteRow .editableFieldButtonDark {
color: rgba(0, 0, 0, 0.5);
background-color: rgba(0, 0, 0, 0);
}
.paletteRow .editableFieldButtonDark:hover {
color: rgba(0, 0, 0, 0.8);
}
.paletteRow .editableFieldButtonDark:active {
color: rgba(0, 0, 0, 0.6);
}
.paletteRow .editableFieldButtonLight {
color: rgba(255, 255, 255, 0.5);
background-color: rgba(0, 0, 0, 0);
}
.paletteRow .editableFieldButtonLight:hover {
color: rgba(255, 255, 255, 0.8);
}
.paletteRow .editableFieldButtonLight:active {
color: rgba(255, 255, 255, 0.6);
}
.paletteRow .editingField {
outline: none;
}
.paletteRow .colorHex {
width: 124px;
}
.colorName .editableField,
.colorName .field {
padding: 1px 8px;
}
.colorHex .editableField,
.colorHex .field {
padding: 3px 8px;
}
.paletteRow .editableFieldButton {
cursor: pointer;
padding-left: 4px;
border: none;
border-radius: 4px;
}
@@ -1,18 +1,40 @@
import { useReducer } from "react";
import { Color } from "colorlib";
import { Color, Hex as HexColor } from "colorlib";
import { HexEditor } from "@/components/ColorValues/ValueEditor";
import { colorReducer, createColorActions } from "@/hooks/color";
import type { PaletteCardState } from "@/hooks/paletteCard";
import PaletteEditor from "./PaletteEditor";
const initialState = {
const initialPickerState = {
color: Color.from_hex("000"),
};
function TestWrapper() {
const [state, dispatch] = useReducer(colorReducer, initialState);
const defaultPaletteCard = {
id: "card_id",
name: "Test Palette",
colors: [
{ id: "red", name: "Red", hex: HexColor.from_code("FF0000") },
{ id: "green", name: "Green", hex: HexColor.from_code("00FF00") },
{ id: "blue", name: "Blue", hex: HexColor.from_code("0000FF") },
],
selectedColorIds: [],
};
const defaultPaletteCardState = {
present: defaultPaletteCard,
history: [],
future: [],
};
function TestWrapper({
initialCardState,
}: {
initialCardState?: PaletteCardState;
}) {
const [state, dispatch] = useReducer(colorReducer, initialPickerState);
const actions = createColorActions(dispatch);
return (
@@ -21,6 +43,7 @@ function TestWrapper() {
<PaletteEditor
pickerColor={state.color.hex}
setPickerColor={actions.hex.setHex}
initialCardState={initialCardState}
/>
</div>
<HexEditor color={state.color.hex} actions={actions.hex} />
@@ -28,12 +51,315 @@ function TestWrapper() {
);
}
describe("palette editor tests", () => {
beforeEach(() => {
it("can edit the palette header", () => {
cy.mount(<TestWrapper />);
cy.dataCy("card-header").as("header");
cy.dataCy("card-name").as("name").contains("New Palette");
// Edit the name
cy.dataCy("card-name-edit").as("edit").click();
cy.dataCy("card-name-input")
.as("input")
.should("exist")
.should("be.focused")
.type("Summer Colors")
.wait(0);
cy.dataCy("card-name-confirm").as("confirm").click();
cy.get("@name").contains("Summer Colors");
// Edit, then cancel
cy.get("@edit").click();
cy.get("@input").type("Winter Colors").wait(0);
cy.dataCy("card-name-cancel").as("cancel").click();
cy.get("@name").contains("Summer Colors");
// Enter should confirm
cy.get("@edit").click();
cy.get("@input").type("Winter Colors").type("{enter}").wait(0);
cy.get("@name").contains("Winter Colors");
// Escape should cancel
cy.get("@edit").click();
cy.get("@input").type("Fall Colors").type("{esc}").wait(0);
cy.get("@name").contains("Winter Colors");
// Input contents should reset
cy.get("@edit").click();
cy.get("@input").should("contain.text", "Winter Colors");
});
it("renders the palette editor", () => {
cy.dataCy("palette-editor").should("exist");
it("can perform actions in normal mode", () => {
cy.mount(<TestWrapper />);
// Empty palette renders no rows
cy.dataCy("palette").as("palette").contains("No colors in palette.");
cy.dataCy("delete").should("be.disabled");
cy.dataCy("duplicate").should("be.disabled");
// Add a color
cy.dataCy("add").as("add").click();
cy.dataCy("palette-row-0").as("row0").should("exist");
cy.get("@row0").contains("New Color");
cy.get("@row0").contains("#000000");
// Select the color
cy.get("@row0").click().should("have.attr", "aria-selected", "true");
cy.dataCy("selected-preview")
.as("preview")
.should("have.css", "background-color", "rgb(0, 0, 0)");
// Change the color name and value
cy.dataCy("palette-row-name-0-edit").click();
cy.dataCy("palette-row-name-0-input").type("Red{enter}").wait(0);
cy.dataCy("palette-row-hex-0-edit").click();
cy.dataCy("palette-row-hex-0-input").type("F00{enter}").wait(0);
cy.get("@row0").contains("Red");
cy.get("@row0").contains("#FF0000");
cy.get("@preview").should("have.css", "background-color", "rgb(255, 0, 0)");
// Add a second color
cy.get("@add").click();
cy.dataCy("palette-row-1").as("row1").should("exist");
// Selecting the second deselects the first
cy.get("@row1").click().should("have.attr", "aria-selected", "true");
cy.get("@row0").should("have.attr", "aria-selected", "false");
// Select none
cy.get("@row1").click("top");
// Delete and Duplicate should be disabled
cy.dataCy("delete").as("delete").should("be.disabled");
cy.dataCy("duplicate").as("duplicate").should("be.disabled");
// Delete the first row
cy.get("@row0").click();
cy.get("@delete").click();
// Second row becomes first
cy.get("@row0").contains("New Color");
// Duplicate a color
cy.get("@add").click();
cy.get("@row0").click();
cy.dataCy("palette-row-name-0-edit").click();
cy.dataCy("palette-row-name-0-input").type("Red{enter}").wait(0);
cy.dataCy("palette-row-hex-0-edit").click();
cy.dataCy("palette-row-hex-0-input").type("F00{enter}").wait(0);
// Dupliated color appears below selected row
cy.get("@duplicate").click();
cy.get("@row0").contains("#FF0000");
cy.get("@row1").contains("#FF0000");
cy.dataCy("palette-row-2").as("row2").contains("#000000");
// Undo removes duplicate
cy.dataCy("redo").as("redo").should("be.disabled");
cy.dataCy("undo").as("undo").should("be.enabled").click();
cy.get("@row0").contains("#FF0000");
cy.get("@row1").contains("#000000");
// Redo adds duplicate back
cy.get("@redo").click();
cy.get("@row0").contains("#FF0000");
cy.get("@row1").contains("#FF0000");
cy.get("@row2").contains("#000000");
});
it("can manually sync picker and palette", () => {
cy.mount(<TestWrapper initialCardState={defaultPaletteCardState} />);
cy.dataCy("hex-value-input").as("hex");
cy.dataCy("picker-preview").as("picker");
cy.dataCy("selected-preview").as("palette");
cy.dataCy("palette-row-0").as("row0");
cy.dataCy("palette-row-1").as("row1");
cy.dataCy("palette-row-2").as("row2");
// Ensure picker and preview colors are set to default
cy.get("@hex").should("have.value", "#000000");
cy.get("@picker").should("have.css", "background-color", "rgb(0, 0, 0)");
// Clicking the picker when no colors are selected does nothing
cy.get("@picker").click();
// Select a color and sync it to the picker
cy.get("@row1").click();
cy.get("@palette")
.should("have.css", "background-color", "rgb(0, 255, 0)")
.click();
cy.get("@picker").should("have.css", "background-color", "rgb(0, 255, 0)");
cy.get("@hex").should("have.value", "#00FF00");
// Select a new color, picker remains the same
cy.get("@row0").click();
cy.get("@palette").should("have.css", "background-color", "rgb(255, 0, 0)");
cy.get("@picker").should("have.css", "background-color", "rgb(0, 255, 0)");
// Change picker color, sync back to palette.
cy.get("@hex").focus().type("FFFF00{esc}").wait(0);
cy.get("@picker").should("have.css", "background-color", "rgb(255, 255, 0)");
cy.get("@picker").click();
cy.get("@row0").contains("#FFFF00");
});
it("can automatically sync picker and palette", () => {
cy.clock();
cy.mount(<TestWrapper initialCardState={defaultPaletteCardState} />);
cy.dataCy("undo").as("undo");
cy.dataCy("redo").as("redo");
cy.dataCy("sync").as("sync");
cy.dataCy("hex-value-input").as("hex");
cy.dataCy("picker-preview").as("picker");
cy.dataCy("selected-preview").as("palette");
cy.dataCy("palette-row-0").as("row0");
cy.dataCy("palette-row-1").as("row1");
cy.dataCy("palette-row-2").as("row2");
// Enable color sync
cy.get("@sync").click().should("have.attr", "aria-pressed", "true");
// No color selected, all previews are default
cy.get("@hex").should("have.value", "#000000");
cy.get("@picker").should("have.css", "background-color", "rgb(0, 0, 0)");
// Select a color, picker should sync
cy.get("@row1").click();
cy.get("@palette").should("have.css", "background-color", "rgb(0, 255, 0)");
cy.get("@picker").should("have.css", "background-color", "rgb(0, 255, 0)");
cy.get("@hex").should("have.value", "#00FF00");
// Change picker color, palette should sync
cy.get("@hex").type("#FF00FF{esc}").wait(0);
cy.get("@palette").should("have.css", "background-color", "rgb(255, 0, 255)");
cy.get("@picker").should("have.css", "background-color", "rgb(255, 0, 255)");
cy.get("@row1").contains("#FF00FF");
// Turning on sync mode should set picker to selected palette color
cy.get("@sync").click();
cy.get("@row2").click();
cy.get("@sync").click();
cy.get("@picker").should("have.css", "background-color", "rgb(0, 0, 255)");
cy.get("@hex").should("have.value", "#0000FF");
// History updates after timeout
cy.get("@hex").type("#FF00FF{esc}").wait(0);
cy.get("@picker").should("have.css", "background-color", "rgb(255, 0, 255)");
cy.tick(3000);
cy.get("@hex").type("#00FF00{esc}").wait(0);
cy.get("@picker").should("have.css", "background-color", "rgb(0, 255, 0)");
cy.tick(3000);
// undo goes back to pink, then blue
cy.get("@undo").click();
cy.get("@palette").should("have.css", "background-color", "rgb(255, 0, 255)");
cy.get("@picker").should("have.css", "background-color", "rgb(255, 0, 255)");
cy.get("@hex").should("have.value", "#FF00FF");
cy.get("@undo").click();
cy.get("@palette").should("have.css", "background-color", "rgb(0, 0, 255)");
cy.get("@picker").should("have.css", "background-color", "rgb(0, 0, 255)");
cy.get("@hex").should("have.value", "#0000FF");
// redo goes to pink, then green
cy.get("@redo").click();
cy.get("@palette").should("have.css", "background-color", "rgb(255, 0, 255)");
cy.get("@picker").should("have.css", "background-color", "rgb(255, 0, 255)");
cy.get("@redo").click();
cy.get("@palette").should("have.css", "background-color", "rgb(0, 255, 0)");
cy.get("@picker").should("have.css", "background-color", "rgb(0, 255, 0)");
// undo during timeout wipes intermediate state
cy.get("@hex").type("#0000FF{esc}").wait(0);
cy.tick(3000); // lock in blue
cy.get("@hex").type("#FF00FF{esc}").wait(0); // pink is debounced
cy.get("@hex").type("#00FF00{esc}").wait(0);
cy.tick(3000); // lock in green
// pink state is lost, undo goes back to blue
cy.get("@undo").click();
cy.get("@picker").should("have.css", "background-color", "rgb(0, 0, 255)");
cy.clock().then((clock) => clock.restore());
});
it("can perform actions in edit mode", () => {
cy.mount(<TestWrapper initialCardState={defaultPaletteCardState} />);
cy.dataCy("select").as("select");
cy.dataCy("palette-row-0").as("row0");
cy.dataCy("palette-row-1").as("row1");
cy.dataCy("palette-row-2").as("row2");
// enter select mode
cy.get("@select").click().should("have.attr", "aria-pressed", "true");
cy.dataCy("select-all").as("select-all");
cy.dataCy("clear").as("clear");
// select multiple colors
cy.get("@row0").should("have.attr", "aria-selected", "false");
cy.get("@row1").should("have.attr", "aria-selected", "false");
cy.get("@row0").click();
cy.get("@row1").click();
cy.get("@row0").should("have.attr", "aria-selected", "true");
cy.get("@row1").should("have.attr", "aria-selected", "true");
// clear selection
cy.get("@clear").click();
cy.get("@row0").should("have.attr", "aria-selected", "false");
cy.get("@row1").should("have.attr", "aria-selected", "false");
// select all
cy.get("@select-all").click();
cy.get("@row0").should("have.attr", "aria-selected", "true");
cy.get("@row1").should("have.attr", "aria-selected", "true");
cy.get("@row2").should("have.attr", "aria-selected", "true");
// leave select mode
cy.get("@select").click().should("have.attr", "aria-pressed", "false");
cy.get("@row0").should("have.attr", "aria-selected", "false");
cy.get("@row1").should("have.attr", "aria-selected", "false");
cy.get("@row2").should("have.attr", "aria-selected", "false");
});
it("can reorder colors in reorder mode", () => {
cy.mount(<TestWrapper initialCardState={defaultPaletteCardState} />);
// enter reorder mode
cy.dataCy("reorder").click().should("have.attr", "aria-pressed", "true");
// drag red down to green
cy.dataCy("palette-row-0-wrapper").trigger("mousedown", {
buttons: 1,
eventConstructor: "MouseEvent",
});
cy.dataCy("palette-row-1-wrapper").trigger("mousemove", {
buttons: 1,
eventConstructor: "MouseEvent",
});
cy.dataCy("palette-row-1-wrapper").trigger("mouseup", {
buttons: 1,
eventConstructor: "MouseEvent",
});
// green should now be first
cy.dataCy("palette-row-0").contains("Green");
cy.dataCy("palette-row-1").contains("Red");
cy.dataCy("palette-row-2").contains("Blue");
// leave reorder mode
cy.dataCy("reorder").click().should("have.attr", "aria-pressed", "false");
// order should persist
cy.dataCy("palette-row-0").contains("Green");
cy.dataCy("palette-row-1").contains("Red");
cy.dataCy("palette-row-2").contains("Blue");
});
+879 -19
View File
@@ -1,51 +1,911 @@
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
import type { KeyboardEvent, MouseEvent, RefObject } from "react";
import clsx from "clsx";
import { Hex as HexColor } from "colorlib";
import {
Check,
CheckCheck,
ChevronsLeft,
ChevronsRight,
CircleDashed,
Copy,
Crosshair,
Dot,
GripVertical,
Pencil,
Plus,
Redo2,
RefreshCw,
RefreshCwOff,
Trash2,
Undo2,
X,
} from "lucide-react";
import { motion } from "motion/react";
import type { ContrastToken } from "@/hooks/contrast";
import { luminanceFromHex, useContrastToken } from "@/hooks/contrast";
import { useDragAndDrop } from "@/hooks/dragAndDrop";
import { extractHexValue, formatHexString } from "@/hooks/hex";
import {
createPaletteCardActions,
paletteCardReducer,
} from "@/hooks/paletteCard";
import type {
PaletteCard,
PaletteCardActions,
PaletteCardState,
PaletteColor,
PaletteMode,
} from "@/hooks/paletteCard";
import {
loadCards,
saveActiveCardId,
saveCards,
serializeCard,
} from "@/hooks/storage";
import { randomId } from "@/util";
import styles from "./PaletteEditor.module.css";
type Timeout = ReturnType<typeof setTimeout>;
const SYNC_DELAY = 2000;
const DEFAULT_BG = HexColor.from_code("f6f6f6");
function defaultPaletteCard(): PaletteCardState {
const defaultCard = {
id: randomId(),
name: "New Palette",
colors: [],
selectedColorIds: [],
};
return {
present: defaultCard,
history: [],
future: [],
};
}
function PaletteEditor({
pickerColor,
setPickerColor,
initialCardState,
}: {
pickerColor: HexColor;
setPickerColor: (hex: HexColor) => void;
initialCardState?: PaletteCardState;
}) {
const [cardState, dispatch] = useReducer(
paletteCardReducer,
initialCardState || defaultPaletteCard(),
);
const actions = useMemo(() => createPaletteCardActions(dispatch), [dispatch]);
const [historyCounter, setHistoryCounter] = useState(0);
const [mode, setMode] = useState<PaletteMode>("normal");
const [isSynced, setIsSynced] = useState(false);
const snapshotRef = useRef<PaletteCard | null>(null);
const timerRef = useRef<Timeout | null>(null);
const incrementHistoryCounter = () => {
setHistoryCounter((prev) => prev + 1);
};
useEffect(() => {
saveActiveCardId(cardState.present.id);
}, []);
useEffect(() => {
const cards = loadCards();
cards[cardState.present.id] = serializeCard(cardState.present);
saveCards(cards);
}, [cardState.present]);
return (
<div className={styles.paletteEditor} data-cy="palette-editor">
<ActionBar />
<PaletteCard />
<ActionBar
mode={mode}
setMode={setMode}
actions={actions}
hasSelection={cardState.present.selectedColorIds.length > 0}
canUndo={cardState.history.length > 0}
canRedo={cardState.future.length > 0}
isSynced={isSynced}
incrementHistoryCounter={incrementHistoryCounter}
snapshotRef={snapshotRef}
syncTimerRef={timerRef}
/>
<PaletteCard
pickerColor={pickerColor}
setPickerColor={setPickerColor}
cardState={cardState.present}
actions={actions}
mode={mode}
isSynced={isSynced}
setIsSynced={setIsSynced}
snapshotRef={snapshotRef}
syncTimerRef={timerRef}
historyCounter={historyCounter}
/>
</div>
);
}
function ActionBar() {
return <div className={styles.actionBar}>actions</div>;
}
function ActionBar({
mode,
setMode,
actions,
hasSelection,
canUndo,
canRedo,
isSynced,
incrementHistoryCounter,
snapshotRef,
syncTimerRef,
}: {
mode: PaletteMode;
setMode: (mode: PaletteMode) => void;
actions: PaletteCardActions;
hasSelection: boolean;
canUndo: boolean;
canRedo: boolean;
isSynced: boolean;
incrementHistoryCounter: () => void;
snapshotRef: RefObject<PaletteCard | null>;
syncTimerRef: RefObject<Timeout | null>;
}) {
const clearSyncTimeout = () => {
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
snapshotRef.current = null;
};
const handleUndo = () => {
if (isSynced) clearSyncTimeout();
incrementHistoryCounter();
actions.undo();
};
const handleRedo = () => {
if (isSynced) clearSyncTimeout();
incrementHistoryCounter();
actions.redo();
};
const handleModeChange = (next: PaletteMode) => {
if (mode === "normal") {
if (isSynced && syncTimerRef.current) {
clearTimeout(syncTimerRef.current);
if (snapshotRef.current) {
actions.commitToHistory(snapshotRef.current);
snapshotRef.current = null;
}
}
}
actions.clearSelection();
setMode(next);
};
function PaletteCard() {
return (
<div className={styles.cardWrapper}>
<CardHeader />
<PickerColor />
<PaletteColor />
<Palette />
<div className={styles.actionBar} data-cy="action-bar">
<button
className={clsx(styles.actionButton, styles.iconButton)}
data-cy="undo"
disabled={!canUndo}
onClick={handleUndo}
title="Undo"
>
<Undo2 size={16} />
</button>
<button
className={clsx(styles.actionButton, styles.iconButton)}
data-cy="redo"
disabled={!canRedo}
onClick={handleRedo}
title="Redo"
>
<Redo2 size={16} />
</button>
<button
className={clsx(styles.actionButton, styles.iconButton)}
data-cy="add"
onClick={actions.addColor}
title="Add Color"
>
<Plus size={16} />
</button>
<button
className={clsx(styles.actionButton, styles.iconButton)}
data-cy="delete"
disabled={!hasSelection}
onClick={actions.deleteSelectedColors}
title="Delete Selected"
>
<Trash2 size={16} />
</button>
<button
className={clsx(styles.actionButton, styles.iconButton)}
data-cy="duplicate"
disabled={!hasSelection}
onClick={actions.duplicateSelectedColors}
title="Duplicate Selected"
>
<Copy size={16} />
</button>
<button
className={clsx(styles.actionButton, styles.wordButton, {
[styles.activeButton]: mode === "reorder",
})}
data-cy="reorder"
aria-pressed={mode === "reorder"}
onClick={() =>
handleModeChange(mode === "reorder" ? "normal" : "reorder")
}
title="Reorder Colors"
>
Reorder
</button>
<button
className={clsx(styles.actionButton, styles.wordButton, {
[styles.activeButton]: mode === "select",
})}
data-cy="select"
aria-pressed={mode === "select"}
onClick={() =>
handleModeChange(mode === "select" ? "normal" : "select")
}
title="Select Multiple"
>
Select
</button>
{mode === "select" && (
<>
<button
className={clsx(styles.actionButton, styles.iconButton)}
data-cy="select-all"
onClick={actions.selectAll}
title="Select All"
>
<CheckCheck size={16} />
</button>
<button
className={clsx(styles.actionButton, styles.iconButton)}
data-cy="clear"
onClick={actions.clearSelection}
title="Clear Selections"
>
<X size={16} />
</button>
</>
)}
</div>
);
}
function CardHeader() {
return <div className={styles.cardHeader}>header</div>;
function PaletteCard({
pickerColor,
setPickerColor,
cardState,
actions,
mode,
isSynced,
setIsSynced,
snapshotRef,
syncTimerRef,
historyCounter,
}: {
pickerColor: HexColor;
setPickerColor: (hex: HexColor) => void;
cardState: PaletteCard;
actions: PaletteCardActions;
mode: PaletteMode;
isSynced: boolean;
setIsSynced: (v: boolean) => void;
snapshotRef: RefObject<PaletteCard | null>;
syncTimerRef: RefObject<Timeout | null>;
historyCounter: number;
}) {
const selectedColor =
mode === "select"
? null
: cardState.selectedColorIds.length === 1
? cardState.colors.find((c) => c.id === cardState.selectedColorIds[0])
: null;
const wasSyncedRef = useRef(false);
const pickerColorValue = pickerColor.to_code();
// when sync toggles on, set picker <- palette
useEffect(() => {
if (!isSynced) {
wasSyncedRef.current = false;
return;
}
function PickerColor() {
return <div className={styles.pickerColor}>picker color</div>;
// handle toggle on
if (!wasSyncedRef.current) {
wasSyncedRef.current = true;
if (syncTimerRef.current) {
clearTimeout(syncTimerRef.current);
syncTimerRef.current = null;
if (snapshotRef.current) {
actions.commitToHistory(snapshotRef.current);
snapshotRef.current = null;
}
}
function PaletteColor() {
return <div className={styles.paletteColor}>palette color</div>;
if (selectedColor) setPickerColor(selectedColor.hex);
}
}, [selectedColor?.id, isSynced]);
// during sync, set picker -> palette
useEffect(() => {
if (!isSynced || !selectedColor || !wasSyncedRef.current) return;
if (!snapshotRef.current) {
snapshotRef.current = cardState; // capture pre-change state once
}
function Palette() {
return <div className={styles.palette}>palette</div>;
actions.setColorValueSilent(selectedColor.id, pickerColor);
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
syncTimerRef.current = setTimeout(() => {
actions.commitToHistory(snapshotRef.current!);
snapshotRef.current = null;
}, SYNC_DELAY);
}, [pickerColorValue]);
// when selection changes, set picker <- palette
useEffect(() => {
if (!isSynced || !selectedColor) return;
if (syncTimerRef.current) {
clearTimeout(syncTimerRef.current);
syncTimerRef.current = null;
if (snapshotRef.current) {
snapshotRef.current = null;
}
}
setPickerColor(selectedColor.hex);
}, [selectedColor?.id]);
// undo/redo, set picker <- palette
useEffect(() => {
if (!isSynced || !selectedColor || !wasSyncedRef.current) return;
setPickerColor(selectedColor.hex);
}, [historyCounter]);
return (
<div className={styles.cardWrapper} data-cy="palette-card">
<SyncButton isSynced={isSynced} setIsSynced={setIsSynced} />
<CardHeader name={cardState.name} onNameChange={actions.setCardName} />
<PickerColor
pickerColor={pickerColor}
paletteColorId={selectedColor?.id || null}
setPaletteColor={actions.setColorValue}
isSynced={isSynced}
/>
<PaletteColor
selectedColor={selectedColor?.hex || null}
setPickerColor={setPickerColor}
isSynced={isSynced}
/>
<Palette cardState={cardState} actions={actions} mode={mode} />
</div>
);
}
function SyncButton({
isSynced,
setIsSynced,
}: {
isSynced: boolean;
setIsSynced: (v: boolean) => void;
}) {
return (
<div
className={styles.sync}
data-cy="sync"
onClick={() => setIsSynced(!isSynced)}
aria-pressed={isSynced}
style={{
color: isSynced ? "#292929" : "#7a7a7a",
}}
title={isSynced ? "Unsync Picker & Palette" : "Sync Picker and Palette"}
>
<span className={styles.leftSpan}>Picker</span>
<span className={styles.middleSpan}>
{isSynced ? <RefreshCw size={22} /> : <RefreshCwOff size={22} />}
</span>
<span className={styles.rightSpan}>Palette</span>
</div>
);
}
function CardHeader({
name,
onNameChange,
}: {
name: string;
onNameChange: (name: string) => void;
}) {
return (
<div className={styles.cardHeader} data-cy="card-header">
<EditableField
testID="card-name"
value={name}
setValue={onNameChange}
buttonSize={12}
/>
</div>
);
}
function PickerColor({
pickerColor,
paletteColorId,
setPaletteColor,
isSynced,
}: {
pickerColor: HexColor;
paletteColorId: string | null;
setPaletteColor: (id: string, hex: HexColor) => void;
isSynced: boolean;
}) {
const arrowToken = useContrastToken(() => luminanceFromHex(pickerColor));
const handleClick = () => {
if (!isSynced && paletteColorId) {
setPaletteColor(paletteColorId, pickerColor);
}
};
return (
<div
className={clsx(styles.previewPane, styles.pickerColor)}
data-cy="picker-preview"
style={{
cursor: isSynced ? "unset" : "pointer",
backgroundColor: formatHexString(pickerColor),
}}
onClick={handleClick}
title={!isSynced ? "Send to Picker" : ""}
>
{!isSynced && (
<div
data-cy="picker-color-arrow"
className={clsx(styles.arrowIndicator, {
[styles.arrowIndicatorDark]: arrowToken === "dark",
[styles.arrowIndicatorLight]: arrowToken === "light",
})}
>
<ChevronsRight size={32} />
</div>
)}
</div>
);
}
function PaletteColor({
selectedColor,
setPickerColor,
isSynced,
}: {
selectedColor: HexColor | null;
setPickerColor: (hex: HexColor) => void;
isSynced: boolean;
}) {
const bgColor = selectedColor || DEFAULT_BG;
const arrowToken = useContrastToken(() => luminanceFromHex(bgColor));
const handleClick = () => {
if (!isSynced && selectedColor) {
setPickerColor(selectedColor);
}
};
return (
<div
className={clsx(styles.previewPane, styles.paletteColor)}
data-cy="selected-preview"
style={{
cursor: isSynced ? "unset" : "pointer",
backgroundColor: formatHexString(bgColor),
}}
onClick={handleClick}
title={!isSynced ? "Send to Palette" : ""}
>
{!isSynced && (
<div
data-cy="palette-color-arrow"
className={clsx(styles.arrowIndicator, {
[styles.arrowIndicatorDark]: arrowToken === "dark",
[styles.arrowIndicatorLight]: arrowToken === "light",
})}
>
<ChevronsLeft size={32} />
</div>
)}
</div>
);
}
function Palette({
cardState,
actions,
mode,
}: {
cardState: PaletteCard;
actions: PaletteCardActions;
mode: PaletteMode;
}) {
const {
containerRef,
setItemRef,
isDragging,
// sourceIndex,
targetIndex,
previewItems,
} = useDragAndDrop({
items: cardState.colors,
handleReorder: actions.reorderColors,
disabled: mode !== "reorder",
});
const handleNormalClick = (color: PaletteColor) => {
const ids = cardState.selectedColorIds;
const isSelected = ids.includes(color.id);
console.log(color.id, isSelected);
if (isSelected) {
actions.setSelectedColors([]);
} else {
actions.setSelectedColors([color.id]);
}
};
const handleSelectClick = (color: PaletteColor) => {
const ids = cardState.selectedColorIds;
const next = ids.includes(color.id)
? ids.filter((id) => id !== color.id)
: [...ids, color.id];
actions.setSelectedColors(next);
};
const onRowClick =
mode === "normal"
? handleNormalClick
: mode === "select"
? handleSelectClick
: undefined;
const displayColors = isDragging ? previewItems : cardState.colors;
return (
<div className={styles.palette} data-cy="palette" ref={containerRef}>
{displayColors.length > 0 ? (
displayColors.map((color, index) => (
<PaletteRow
key={color.id}
color={color}
index={index}
isSelected={cardState.selectedColorIds.includes(color.id)}
isEditable={
mode === "normal" && cardState.selectedColorIds[0] === color.id
}
isDragging={isDragging}
mode={mode}
actions={actions}
onRowClick={onRowClick}
setItemRef={setItemRef}
targetIndex={targetIndex}
/>
))
) : (
<span style={{ margin: 8 }}>
No colors in palette. Press{" "}
<Plus size={16} style={{ transform: "translateY(2px)" }} /> in the
toolbar above to add one.
</span>
)}
</div>
);
}
function PaletteRow({
color,
index,
isSelected,
isEditable,
isDragging,
mode,
actions,
onRowClick,
setItemRef,
targetIndex,
}: {
color: PaletteColor;
index: number;
isSelected: boolean;
isEditable: boolean;
isDragging: boolean;
mode: PaletteMode;
actions: PaletteCardActions;
onRowClick?: (color: PaletteColor, e: MouseEvent<HTMLDivElement>) => void;
setItemRef: (el: HTMLElement | null, id: string) => void;
targetIndex: number;
}) {
const isNormalMode = mode === "normal";
const isSelectMode = mode === "select";
const isReorderMode = mode === "reorder";
const Wrapper = isReorderMode ? motion.div : "div";
const motionProps = isReorderMode
? { layout: true, transition: { duration: 0.25 } }
: {};
const token = useContrastToken(() => luminanceFromHex(color.hex));
return (
<Wrapper
className={clsx(styles.paletteRowWrapper, {
[styles.draggable]: isReorderMode,
[styles.dragging]: isDragging && index === targetIndex,
[styles.multiSelected]: isSelectMode && isSelected,
})}
ref={(el) => setItemRef(el, color.id)}
data-cy={`palette-row-${index}-wrapper`}
data-item-id={color.id}
onClick={(e) => onRowClick?.(color, e)}
{...motionProps}
>
<div
className={styles.paletteRow}
data-cy={`palette-row-${index}`}
aria-selected={isSelected}
style={{
backgroundColor: formatHexString(color.hex),
}}
>
{isSelectMode && (
<span
className={clsx(styles.modeDecorator, styles.checkDecorator, {
[styles.checkDecoratorSelected]: isSelected,
[styles.checkDecoratorDark]: token === "dark",
[styles.checkDecoratorLight]: token === "light",
})}
>
{isSelected && <Check size={18} strokeWidth={3} />}
</span>
)}
{mode === "reorder" && (
<span
className={clsx(styles.modeDecorator, {
[styles.gripDark]: token === "dark",
[styles.gripLight]: token === "light",
})}
>
<GripVertical size={24} />
</span>
)}
{isNormalMode &&
(isSelected ? (
<Crosshair
className={clsx(styles.selectedIndicator, {
[styles.indicatorDark]: token === "dark",
[styles.indicatorLight]: token === "light",
})}
size={27}
>
<Dot />
</Crosshair>
) : (
<CircleDashed
className={clsx(styles.targettedIndicator, {
[styles.indicatorDark]: token === "dark",
[styles.indicatorLight]: token === "light",
})}
size={32}
/>
))}
<div className={styles.paletteRowData}>
<div className={styles.colorName}>
{isEditable ? (
<EditableField
testID={`palette-row-name-${index}`}
value={color.name}
setValue={(newName: string) =>
actions.setColorName(color.id, newName)
}
buttonSize={12}
contrastToken={token}
reset={!isSelected}
/>
) : (
<div>
<span
data-cy={`palette-row-name-${index}`}
className={clsx(styles.field, {
[styles.fieldDark]: token === "dark",
[styles.fieldLight]: token === "light",
})}
>
{color.name}
</span>
</div>
)}
</div>
<div className={styles.colorHex}>
{isEditable ? (
<EditableField
testID={`palette-row-hex-${index}`}
value={color.hex.to_code()}
setValue={(newHex: string) =>
actions.setColorValue(color.id, HexColor.from_code(newHex))
}
buttonSize={12}
contrastToken={token}
reset={!isSelected}
validate={(raw: string) => extractHexValue(raw)}
render={(raw: string) => `#${raw}`}
/>
) : (
<div className={styles.fieldWrapper}>
<span
data-cy={`palette-row-hex-${index}`}
className={clsx(styles.field, {
[styles.fieldDark]: token === "dark",
[styles.fieldLight]: token === "light",
})}
>
{formatHexString(color.hex)}
</span>
</div>
)}
</div>
</div>
</div>
</Wrapper>
);
}
function EditableField({
testID,
value,
setValue,
buttonSize,
contrastToken,
reset,
validate,
render,
}: {
testID: string;
value: string;
setValue: (v: string) => void;
buttonSize: number;
contrastToken?: ContrastToken;
reset?: boolean;
validate?: (raw: string) => string | null;
render?: (raw: string) => string;
}) {
const [isEditing, setIsEditing] = useState(false);
const spanRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (reset) setIsEditing(false);
}, [reset]);
useEffect(() => {
// return if not editing or not rendered
if (!isEditing || !spanRef.current) return;
// set span content
spanRef.current.textContent = render ? render(value) : value;
// focus span
spanRef.current.focus();
// select contents
const range = document.createRange();
range.selectNodeContents(spanRef.current);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
}, [isEditing, value, render]);
const onConfirm = () => {
const raw = spanRef.current?.textContent ?? "";
const validated = validate ? validate(raw) : raw;
if (validated) setValue(validated);
setIsEditing(false);
};
const onCancel = () => {
window.getSelection()?.removeAllRanges();
setIsEditing(false);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
} else if (e.key === "Enter") {
e.preventDefault();
onConfirm();
}
};
return (
<div className={styles.editableFieldWrapper}>
<div className={styles.valueWrapper}>
<span
ref={spanRef}
data-cy={isEditing ? `${testID}-input` : testID}
className={clsx(styles.editableField, {
[styles.editingField]: isEditing,
[styles.editableFieldDark]: contrastToken === "dark",
[styles.editableFieldLight]: contrastToken === "light",
})}
contentEditable={isEditing}
suppressContentEditableWarning
onClick={(e) => e.stopPropagation()}
onKeyDown={isEditing ? handleKeyDown : undefined}
>
{!isEditing && (render ? render(value) : value)}
</span>
</div>
{isEditing ? (
<div>
<button
data-cy={`${testID}-cancel`}
className={clsx(styles.editableFieldButton, {
[styles.editableFieldButtonDark]: contrastToken === "dark",
[styles.editableFieldButtonLight]: contrastToken === "light",
})}
onClick={(e) => {
e.stopPropagation();
onCancel();
}}
title="Cancel"
>
<X size={buttonSize} strokeWidth={3} />
</button>
<button
data-cy={`${testID}-confirm`}
className={clsx(styles.editableFieldButton, {
[styles.editableFieldButtonDark]: contrastToken === "dark",
[styles.editableFieldButtonLight]: contrastToken === "light",
})}
onClick={(e) => {
e.stopPropagation();
onConfirm();
}}
title="Confirm"
>
<Check size={buttonSize} strokeWidth={3} />
</button>
</div>
) : (
<div>
<button
data-cy={`${testID}-edit`}
className={clsx(styles.editableFieldButton, {
[styles.editableFieldButtonDark]: contrastToken === "dark",
[styles.editableFieldButtonLight]: contrastToken === "light",
})}
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
title="Edit"
>
<Pencil size={buttonSize} strokeWidth={3} />
</button>
</div>
)}
</div>
);
}
export default PaletteEditor;
+20
View File
@@ -0,0 +1,20 @@
import { useMemo } from "react";
import * as colorlib from "colorlib";
export type ContrastToken = "dark" | "light";
export function contrastToken(l: number, threshold = 0.5): ContrastToken {
return l < threshold ? "light" : "dark";
}
export function luminanceFromHex(hex: colorlib.Hex): number {
return colorlib.HCL.from_hex(hex.to_code()).l;
}
export function useContrastToken(getLuminance: () => number, threshold = 0.5) {
return useMemo(
() => contrastToken(getLuminance(), threshold),
[getLuminance, threshold],
);
}
+25
View File
@@ -0,0 +1,25 @@
import * as colorlib from "colorlib";
export const extractHexValue = (value: string): string | null => {
const match = value.match(/^#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})$/);
return match ? match[1] : null;
};
export const formatHexString = (
color: colorlib.Hex,
preserveShortFormat: boolean = false,
): string => {
const hexValue = color.to_code();
if (preserveShortFormat) {
if (
hexValue[0] === hexValue[1] &&
hexValue[2] === hexValue[3] &&
hexValue[4] === hexValue[5]
) {
return `#${hexValue[0]}${hexValue[2]}${hexValue[4]}`;
}
}
return `#${color.to_code()}`;
};
+175 -45
View File
@@ -1,17 +1,32 @@
import type { Dispatch } from "react";
import { Hex as HexColor } from "colorlib";
import { randomId } from "@/util";
export type PaletteMode = "normal" | "select" | "reorder";
export interface PaletteColor {
id: string;
name: string;
hex: string;
hex: HexColor;
}
export interface ColorNameUpdate {
id: string;
name: string;
}
export interface ColorValueUpdate {
id: string;
hex: HexColor;
}
export interface PaletteCard {
id: string;
name: string;
colors: PaletteColor[];
selectedColorId: string | null;
inToolkitMode: boolean;
selectedColorIds: string[];
}
export interface PaletteCardState {
@@ -22,19 +37,20 @@ export interface PaletteCardState {
export type PaletteCardAction =
| { type: "SET_CARD_NAME"; payload: string }
| { type: "SET_SELECTED_COLOR"; payload: string | null }
| { type: "SET_COLOR_NAME"; payload: ColorNameUpdate }
| { type: "SET_COLOR_VALUE"; payload: ColorValueUpdate }
| { type: "SET_COLOR_VALUE_SILENT"; payload: ColorValueUpdate }
| { type: "COMMIT_TO_HISTORY"; payload: PaletteCard }
| { type: "SET_SELECTED_COLORS"; payload: string[] }
| { type: "SELECT_ALL" }
| { type: "CLEAR_SELECTION" }
| { type: "DELETE_SELECTED_COLORS" }
| { type: "DUPLICATE_SELECTED_COLORS" }
| { type: "ADD_COLOR" }
| { type: "DELETE_SELECTED_COLOR" }
| { type: "DUPLICATE_SELECTED_COLOR" }
| { type: "REORDER_COLORS"; payload: PaletteColor[] }
| { type: "TOGGLE_TOOLKIT_MODE" }
| { type: "UNDO" }
| { type: "REDO" };
export function paletteCardReducer(
state: PaletteCardState,
action: PaletteCardAction,
): PaletteCardState {
const pushToHistory = (state: PaletteCardState, newPresent: PaletteCard) => {
return {
...state,
@@ -44,42 +60,142 @@ export function paletteCardReducer(
};
};
export function paletteCardReducer(
state: PaletteCardState,
action: PaletteCardAction,
): PaletteCardState {
switch (action.type) {
case "SET_CARD_NAME":
state = pushToHistory(state, { ...state.present, name: action.payload });
return state;
case "SET_SELECTED_COLOR":
// TODO: Implement
return state;
case "SET_COLOR_NAME": {
let changed = false;
const colors = state.present.colors.map((c) => {
if (c.id !== action.payload.id) return c;
changed = true;
return { ...c, name: action.payload.name };
});
if (!changed) return state;
return pushToHistory(state, { ...state.present, colors });
}
case "ADD_COLOR":
// TODO: Implement
return state;
case "SET_COLOR_VALUE": {
let changed = false;
const colors = state.present.colors.map((c) => {
if (c.id !== action.payload.id) return c;
changed = true;
return { ...c, hex: action.payload.hex };
});
if (!changed) return state;
return pushToHistory(state, { ...state.present, colors });
}
case "DELETE_SELECTED_COLOR":
// TODO: Implement
return state;
case "SET_COLOR_VALUE_SILENT": {
let changed = false;
const colors = state.present.colors.map((c) => {
if (c.id !== action.payload.id) return c;
changed = true;
return { ...c, hex: action.payload.hex };
});
if (!changed) return state;
return {
...state,
present: { ...state.present, colors },
};
}
case "DUPLICATE_SELECTED_COLOR":
// TODO: Implement
return state;
case "COMMIT_TO_HISTORY": {
return {
...state,
history: [action.payload, ...state.history],
};
}
case "SET_SELECTED_COLORS":
return {
...state,
present: { ...state.present, selectedColorIds: action.payload },
};
case "SELECT_ALL":
return {
...state,
present: {
...state.present,
selectedColorIds: state.present.colors.map((c) => c.id),
},
};
case "CLEAR_SELECTION":
return {
...state,
present: { ...state.present, selectedColorIds: [] },
};
case "ADD_COLOR": {
const newColor: PaletteColor = {
id: randomId(),
name: "New Color",
hex: HexColor.from_code("000000"),
};
return pushToHistory(state, {
...state.present,
colors: [...state.present.colors, newColor],
});
}
case "DELETE_SELECTED_COLORS": {
if (state.present.selectedColorIds.length === 0) return state;
const ids = new Set(state.present.selectedColorIds);
return pushToHistory(state, {
...state.present,
colors: state.present.colors.filter((c) => !ids.has(c.id)),
selectedColorIds: [],
});
}
case "DUPLICATE_SELECTED_COLORS": {
if (state.present.selectedColorIds.length === 0) return state;
const ids = new Set(state.present.selectedColorIds);
const next: PaletteColor[] = [];
for (const color of state.present.colors) {
next.push(color);
if (ids.has(color.id)) {
next.push({ ...color, id: randomId() });
}
}
return pushToHistory(state, {
...state.present,
colors: next,
});
}
case "REORDER_COLORS":
// TODO: Implement
return state;
return pushToHistory(state, {
...state.present,
colors: action.payload,
});
case "TOGGLE_TOOLKIT_MODE":
// TODO: Implement
return state;
case "UNDO": {
if (state.history.length === 0) return state;
const [prev, ...rest] = state.history;
return {
present: prev,
history: rest,
future: [state.present, ...state.future],
};
}
case "UNDO":
// TODO: Implement
return state;
case "REDO":
// TODO: Implement
return state;
case "REDO": {
if (state.future.length === 0) return state;
const [next, ...rest] = state.future;
return {
present: next,
history: [state.present, ...state.history],
future: rest,
};
}
default:
return state;
@@ -88,12 +204,17 @@ export function paletteCardReducer(
export interface PaletteCardActions {
setCardName: (name: string) => void;
setSelectedColor: (id: string | null) => void;
setColorName: (id: string, name: string) => void;
setColorValue: (id: string, hex: HexColor) => void;
setColorValueSilent: (id: string, hex: HexColor) => void;
commitToHistory: (card: PaletteCard) => void;
setSelectedColors: (id: string[]) => void;
selectAll: () => void;
clearSelection: () => void;
addColor: () => void;
deleteSelectedColor: () => void;
duplicateSelectedColor: () => void;
deleteSelectedColors: () => void;
duplicateSelectedColors: () => void;
reorderColors: (colors: PaletteColor[]) => void;
toggleToolkitMode: () => void;
undo: () => void;
redo: () => void;
}
@@ -103,15 +224,24 @@ export function createPaletteCardActions(
): PaletteCardActions {
return {
setCardName: (name) => dispatch({ type: "SET_CARD_NAME", payload: name }),
setSelectedColor: (id) =>
dispatch({ type: "SET_SELECTED_COLOR", payload: id }),
setColorName: (id, name) =>
dispatch({ type: "SET_COLOR_NAME", payload: { id, name } }),
setColorValue: (id, hex) =>
dispatch({ type: "SET_COLOR_VALUE", payload: { id, hex } }),
setColorValueSilent: (id, hex) =>
dispatch({ type: "SET_COLOR_VALUE_SILENT", payload: { id, hex } }),
commitToHistory: (card) =>
dispatch({ type: "COMMIT_TO_HISTORY", payload: card }),
setSelectedColors: (ids) =>
dispatch({ type: "SET_SELECTED_COLORS", payload: ids }),
selectAll: () => dispatch({ type: "SELECT_ALL" }),
clearSelection: () => dispatch({ type: "CLEAR_SELECTION" }),
addColor: () => dispatch({ type: "ADD_COLOR" }),
deleteSelectedColor: () => dispatch({ type: "DELETE_SELECTED_COLOR" }),
duplicateSelectedColor: () =>
dispatch({ type: "DUPLICATE_SELECTED_COLOR" }),
deleteSelectedColors: () => dispatch({ type: "DELETE_SELECTED_COLORS" }),
duplicateSelectedColors: () =>
dispatch({ type: "DUPLICATE_SELECTED_COLORS" }),
reorderColors: (colors) =>
dispatch({ type: "REORDER_COLORS", payload: colors }),
toggleToolkitMode: () => dispatch({ type: "TOGGLE_TOOLKIT_MODE" }),
undo: () => dispatch({ type: "UNDO" }),
redo: () => dispatch({ type: "REDO" }),
};
+5 -10
View File
@@ -59,7 +59,11 @@ export function useSlider({
const maxPosition = useRef(0);
// Internal position management
const [position, setPosition] = useState(0);
const position = valueToPosition(
value,
chooseValueByDirection(direction, dimensions.x, dimensions.y),
valueRange,
);
const positionRef = useRef(position);
// Hooks
@@ -201,15 +205,6 @@ export function useSlider({
onScrollDown: handleScrollDown,
});
useEffect(() => {
const newPosition = valueToPosition(
value,
maxPosition.current,
valueRangeRef.current,
);
setPosition(newPosition);
}, [value, setPosition]);
// Set up entry listeners
useEffect(() => {
const currentRef = sliderRef.current;
+70
View File
@@ -0,0 +1,70 @@
import { Hex as HexColor } from "colorlib";
import type { PaletteCard, PaletteColor } from "./paletteCard";
const CARDS_KEY = "luminance:cards";
const ACTIVE_ID_KEY = "luminance:activeCardId";
interface SerializedColor {
id: string;
name: string;
hex: string;
}
interface SerializedCard {
id: string;
name: string;
colors: SerializedColor[];
}
function serializeColor(color: PaletteColor): SerializedColor {
return {
id: color.id,
name: color.name,
hex: color.hex.to_code(),
};
}
function deserializeColor(raw: SerializedColor): PaletteColor {
return {
id: raw.id,
name: raw.name,
hex: HexColor.from_code(raw.hex),
};
}
export function serializeCard(card: PaletteCard): SerializedCard {
return {
id: card.id,
name: card.name,
colors: card.colors.map(serializeColor),
};
}
export function deserializeCard(raw: SerializedCard): PaletteCard {
return {
id: raw.id,
name: raw.name,
colors: raw.colors.map(deserializeColor),
selectedColorIds: [],
};
}
export function loadCards(): Record<string, SerializedCard> {
try {
return JSON.parse(localStorage.getItem(CARDS_KEY) ?? "{}");
} catch {
return {};
}
}
export function saveCards(cards: Record<string, SerializedCard>) {
localStorage.setItem(CARDS_KEY, JSON.stringify(cards));
}
export function loadActiveCardId(): string | null {
return localStorage.getItem(ACTIVE_ID_KEY);
}
export function saveActiveCardId(id: string): void {
localStorage.setItem(ACTIVE_ID_KEY, id);
}
+384 -19
View File
@@ -11,42 +11,407 @@ import type {
} from "../paletteCard";
import { createPaletteCardActions, paletteCardReducer } from "../paletteCard";
const createPaletteState = (
// Fixtures
const makeColor = (id: string, hex = "000000") => ({
id,
name: `Color ${id}`,
hex: HexColor.from_code(hex),
});
const makeCard = (overrides: Partial<PaletteCard> = {}): PaletteCard => ({
id: "card_1",
name: "Test Palette",
colors: [],
selectedColorIds: [],
...overrides,
});
const makeState = (
present: PaletteCard,
history: PaletteCard[] = [],
future: PaletteCard[] = [],
) => ({ present: { ...present }, history, future });
): PaletteCardState => ({ present, history, future });
const testPaletteCard = {
id: "palette_id",
name: "Test Palette",
colors: [],
selectedColorId: null,
inToolkitMode: false,
};
const testState = createPaletteState(testPaletteCard);
const emptyState = makeState(makeCard());
const WHITE = HexColor.from_code("#fff");
const GREY = HexColor.from_code("#777");
const BLACK = HexColor.from_code("#000");
const seededState = makeState(
makeCard({
colors: [makeColor("a"), makeColor("b"), makeColor("c")],
}),
);
// Helpers
describe("palette card actions", () => {
let state: PaletteCardState;
let dispatch: (value: PaletteCardAction) => void;
let actions: PaletteCardActions;
beforeEach(() => {
[state, dispatch] = mockUseReducer(paletteCardReducer, testState);
const setup = (initial: PaletteCardState) => {
[state, dispatch] = mockUseReducer(paletteCardReducer, initial);
actions = createPaletteCardActions(dispatch);
};
// Tests
describe("set card name", () => {
beforeEach(() => {
setup(emptyState);
});
test("sets card name", () => {
test("updates name", () => {
actions.setCardName("New Name");
expect(state.present.name).toBe("New Name");
});
test("pushes to history", () => {
actions.setCardName("New Name");
expect(state.history.length).toBe(1);
expect(state.future.length).toBe(0);
expect(state.history[0].name).toBe("Test Palette");
});
test("clears future", () => {
const withFuture = makeState(
makeCard(),
[],
[makeCard({ name: "Future" })],
);
setup(withFuture);
actions.setCardName("New Name");
expect(state.future.length).toBe(0);
});
});
describe("SET_COLOR_NAME", () => {
beforeEach(() => setup(seededState));
test("updates name of the target color", () => {
actions.setColorName("b", "New Name");
expect(state.present.colors.find((c) => c.id === "b")?.name).toBe(
"New Name",
);
});
test("does not affect other colors", () => {
actions.setColorName("b", "New Name");
expect(state.present.colors.find((c) => c.id === "a")?.name).toMatch(
/Color [a-z]/,
);
});
test("pushes to history", () => {
actions.setColorName("b", "New Name");
expect(state.history.length).toBe(1);
});
test("unknown id is a no-op", () => {
actions.setColorName("z", "New Name");
expect(state.present.colors.map((c) => c.name)).toEqual([
"Color a",
"Color b",
"Color c",
]);
expect(state.history.length).toBe(0);
});
});
describe("SET_COLOR_VALUE", () => {
beforeEach(() => setup(seededState));
test("updates hex of the target color", () => {
actions.setColorValue("b", HexColor.from_code("FF0000"));
expect(state.present.colors.find((c) => c.id === "b")?.hex.to_code()).toBe(
"FF0000",
);
});
test("does not affect other colors", () => {
actions.setColorValue("b", HexColor.from_code("FF0000"));
expect(state.present.colors.find((c) => c.id === "a")?.hex.to_code()).toBe(
"000000",
);
});
test("pushes to history", () => {
actions.setColorValue("b", HexColor.from_code("FF0000"));
expect(state.history.length).toBe(1);
});
test("unknown id is a no-op", () => {
actions.setColorValue("z", HexColor.from_code("FF0000"));
expect(state.present.colors.map((c) => c.hex.to_code())).toEqual([
"000000",
"000000",
"000000",
]);
expect(state.history.length).toBe(0);
});
});
describe("SET_COLOR_VALUE_SILENT", () => {
beforeEach(() => setup(seededState));
test("updates hex of the target color", () => {
actions.setColorValueSilent("b", HexColor.from_code("FF0000"));
expect(state.present.colors.find((c) => c.id === "b")?.hex.to_code()).toBe(
"FF0000",
);
});
test("does not affect other colors", () => {
actions.setColorValueSilent("b", HexColor.from_code("FF0000"));
expect(state.present.colors.find((c) => c.id === "a")?.hex.to_code()).toBe(
"000000",
);
});
test("does not push to history", () => {
actions.setColorValueSilent("b", HexColor.from_code("FF0000"));
expect(state.history.length).toBe(0);
});
test("unknown id is a no-op", () => {
actions.setColorValueSilent("z", HexColor.from_code("FF0000"));
expect(state.present.colors.map((c) => c.hex.to_code())).toEqual([
"000000",
"000000",
"000000",
]);
expect(state.history.length).toBe(0);
});
});
describe("COMMIT_TO_HISTORY", () => {
beforeEach(() => setup(seededState));
test("appends to history without affecting present", () => {
const cachedState = makeCard({ id: "cached", name: "Cached Card" });
expect(state.history.length).toBe(0);
actions.commitToHistory(cachedState);
expect(state.history.length).toBe(1);
expect(state.present.id).toBe("card_1");
expect(state.history[0].id).toBe("cached");
});
});
describe("selection", () => {
beforeEach(() => {
setup(seededState);
});
test("SET_SELECTED_COLORS replaces selection", () => {
actions.setSelectedColors(["a", "b"]);
expect(state.present.selectedColorIds).toEqual(["a", "b"]);
});
test("SET_SELECTED_COLORS with empty array clears selection", () => {
actions.setSelectedColors(["a"]);
actions.setSelectedColors([]);
expect(state.present.selectedColorIds).toEqual([]);
});
test("SELECT_ALL selects all color ids", () => {
actions.selectAll();
expect(state.present.selectedColorIds).toEqual(["a", "b", "c"]);
});
test("SELECT_ALL on empty colors produces empty selection", () => {
setup(emptyState);
actions.selectAll();
expect(state.present.selectedColorIds).toEqual([]);
});
test("CLEAR_SELECTION empties a non-empty selection", () => {
actions.setSelectedColors(["a", "b"]);
actions.clearSelection();
expect(state.present.selectedColorIds).toEqual([]);
});
test("selection actions do not push to history", () => {
actions.setSelectedColors(["a"]);
actions.selectAll();
actions.clearSelection();
expect(state.history.length).toBe(0);
});
});
describe("add colors", () => {
test("appends one color", () => {
setup(seededState);
actions.addColor();
expect(state.present.colors.length).toBe(4);
});
test("on empty card produces one color", () => {
setup(emptyState);
actions.addColor();
expect(state.present.colors.length).toBe(1);
});
test("new color has a non-empty id", () => {
setup(emptyState);
actions.addColor();
expect(state.present.colors[0].id).toBeTruthy();
});
test("pushes to history", () => {
setup(emptyState);
actions.addColor();
expect(state.history.length).toBe(1);
});
});
describe("reorder colors", () => {
beforeEach(() => setup(seededState));
test("replaces colors array", () => {
const reordered = [makeColor("c"), makeColor("a"), makeColor("b")];
actions.reorderColors(reordered);
expect(state.present.colors.map((c) => c.id)).toEqual(["c", "a", "b"]);
});
test("pushes to history", () => {
actions.reorderColors([makeColor("c"), makeColor("b"), makeColor("a")]);
expect(state.history.length).toBe(1);
});
test("does not affect selection", () => {
actions.setSelectedColors(["a"]);
actions.reorderColors([makeColor("c"), makeColor("b"), makeColor("a")]);
expect(state.present.selectedColorIds).toEqual(["a"]);
});
});
describe("delete colors", () => {
beforeEach(() => setup(seededState));
test("removes exactly the selected colors", () => {
actions.setSelectedColors(["a", "c"]);
actions.deleteSelectedColors();
expect(state.present.colors.map((c) => c.id)).toEqual(["b"]);
});
test("clears selection afterward", () => {
actions.setSelectedColors(["a"]);
actions.deleteSelectedColors();
expect(state.present.selectedColorIds).toEqual([]);
});
test("pushes one history entry", () => {
actions.setSelectedColors(["a"]);
actions.deleteSelectedColors();
expect(state.history.length).toBe(1);
});
test("with empty selection is a no-op", () => {
actions.deleteSelectedColors();
expect(state.present.colors.length).toBe(3);
expect(state.history.length).toBe(0);
});
});
describe("duplicate colors", () => {
beforeEach(() => setup(seededState));
test("appends copies after their originals", () => {
actions.setSelectedColors(["b"]);
actions.duplicateSelectedColors();
const ids = state.present.colors.map((c) => c.id);
expect(ids[0]).toBe("a");
expect(ids[1]).toBe("b");
expect(ids[2]).not.toBe("b"); // new id
expect(ids[3]).toBe("c");
expect(ids.length).toBe(4);
});
test("duplicate has the same color value", () => {
actions.setSelectedColors(["a"]);
actions.duplicateSelectedColors();
const colors = state.present.colors.map((c) => c.hex);
expect(colors[0]).toBe(colors[1]);
});
test("duplicates have new ids", () => {
actions.setSelectedColors(["a", "b", "c"]);
actions.duplicateSelectedColors();
const ids = state.present.colors.map((c) => c.id);
const unique = new Set(ids);
expect(unique.size).toBe(6);
});
test("maintains selection", () => {
actions.setSelectedColors(["a"]);
actions.duplicateSelectedColors();
expect(state.present.selectedColorIds).toEqual(["a"]);
});
test("pushes one history entry", () => {
actions.setSelectedColors(["a"]);
actions.duplicateSelectedColors();
expect(state.history.length).toBe(1);
});
test("preserves relative order of non-duplicated colors", () => {
actions.setSelectedColors(["a"]);
actions.duplicateSelectedColors();
const ids = state.present.colors.map((c) => c.id);
expect(ids.indexOf("b")).toBeLessThan(ids.indexOf("c"));
});
test("with empty selection is a no-op", () => {
actions.duplicateSelectedColors();
expect(state.present.colors.length).toBe(3);
expect(state.history.length).toBe(0);
});
});
describe("undo / redo", () => {
beforeEach(() => setup(emptyState));
test("UNDO restores previous present", () => {
actions.setCardName("A");
actions.setCardName("B");
actions.undo();
expect(state.present.name).toBe("A");
});
test("UNDO pushes current present to future", () => {
actions.setCardName("A");
actions.undo();
expect(state.future[0].name).toBe("A");
});
test("UNDO at empty history is a no-op", () => {
actions.undo();
expect(state.present.name).toBe("Test Palette");
expect(state.history.length).toBe(0);
});
test("REDO restores next future", () => {
actions.setCardName("A");
actions.undo();
actions.redo();
expect(state.present.name).toBe("A");
});
test("REDO pushes current present to history", () => {
actions.setCardName("A");
actions.undo();
actions.redo();
expect(state.history[0].name).toBe("Test Palette");
});
test("REDO at empty future is a no-op", () => {
actions.setCardName("A");
actions.redo();
expect(state.present.name).toBe("A");
expect(state.future.length).toBe(0);
});
test("mutation after undo clears future", () => {
actions.setCardName("A");
actions.undo();
actions.setCardName("B");
expect(state.future.length).toBe(0);
});
});
+10 -4
View File
@@ -1,4 +1,4 @@
import { useReducer } from "react";
import { useMemo, useReducer } from "react";
import type { ReactNode } from "react";
import * as colorlib from "colorlib";
@@ -16,12 +16,18 @@ export const SelectedColorProvider = ({
color: colorlib.Color.from_hex("00C9FA"),
};
const [colorState, colorDispatch] = useReducer(colorReducer, initialState);
const colorActions = createColorActions(colorDispatch);
const colorActions = useMemo(
() => createColorActions(colorDispatch),
[colorDispatch],
);
const value = {
const value = useMemo(
() => ({
selectedColor: colorState.color,
selectedColorActions: colorActions,
};
}),
[colorState.color, colorActions],
);
return (
<SelectedColorContext.Provider value={value}>
+1 -2
View File
@@ -1,7 +1,6 @@
import { useContext } from "react";
import { MediaQueryContext } from "./MediaQueryProvider";
import { SelectedColorContext } from "./SelectedColorProvider";
import { MediaQueryContext, SelectedColorContext } from "./context";
export function useMediaQuery() {
const context = useContext(MediaQueryContext);
+8
View File
@@ -111,3 +111,11 @@ export function roundTo(
export function formatCssRgb(hex: Hex) {
return `rgb(${hex.r},${hex.g},${hex.b})`;
}
export function formatCssRgbs(hex: Hex, alpha: number) {
return `rgb(${hex.r},${hex.g},${hex.b},${roundTo(alpha, 2)})`;
}
export function randomId(): string {
return Math.random().toString(36).slice(2, 8);
}