From 5f6d0f43ee3c228da31222ac4b46af5ec209fa27 Mon Sep 17 00:00:00 2001 From: Jay Date: Mon, 23 Mar 2026 08:24:44 -0400 Subject: [PATCH] Completed palette editor, ui overhaul. --- cypress/support/commands.ts | 5 +- cypress/support/component-index.html | 7 + eslint.config.js | 1 + package.json | 3 +- src/App.module.css | 47 +- src/App.tsx | 301 ++---- .../ColorHistory/ColorHistory.module.css | 44 +- .../ColorHistory/ColorHistory.test.cy.tsx | 19 +- src/components/ColorHistory/ColorHistory.tsx | 5 +- src/components/ColorPicker/ColorBar.tsx | 68 +- .../ColorPicker/ColorPicker.module.css | 74 +- src/components/ColorPicker/ColorPicker.tsx | 18 +- src/components/ColorPicker/ColorSquare.tsx | 57 +- src/components/ColorPicker/Crosshair.tsx | 35 +- src/components/ColorPicker/GripSlider.tsx | 61 +- .../ColorValues/ColorValues.module.css | 22 +- src/components/ColorValues/ColorValues.tsx | 10 +- .../ColorValues/HexEditor.test.cy.tsx | 11 +- .../ColorValues/SpaceEditor.test.cy.tsx | 4 +- .../ColorValues/ValueEditor.test.cy.tsx | 6 +- src/components/ColorValues/ValueEditor.tsx | 47 +- .../PaletteEditor/PaletteEditor.module.css | 437 ++++++++- .../PaletteEditor/PaletteEditor.test.cy.tsx | 346 ++++++- .../PaletteEditor/PaletteEditor.tsx | 898 +++++++++++++++++- src/hooks/contrast.ts | 20 + src/hooks/hex.ts | 25 + src/hooks/paletteCard.ts | 230 ++++- src/hooks/slider.tsx | 15 +- src/hooks/storage.ts | 70 ++ src/hooks/tests/paletteCard.test.ts | 409 +++++++- src/providers/SelectedColorProvider.tsx | 18 +- src/providers/hooks.ts | 3 +- src/util.ts | 8 + 33 files changed, 2713 insertions(+), 611 deletions(-) create mode 100644 src/hooks/contrast.ts create mode 100644 src/hooks/hex.ts create mode 100644 src/hooks/storage.ts diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index c36daa9..15bdce2 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -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}"]`); }); diff --git a/cypress/support/component-index.html b/cypress/support/component-index.html index faf3b5f..01999dd 100644 --- a/cypress/support/component-index.html +++ b/cypress/support/component-index.html @@ -5,6 +5,13 @@ Components App +
diff --git a/eslint.config.js b/eslint.config.js index 0bc6f98..077af17 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -24,6 +24,7 @@ export default tseslint.config( "warn", { allowConstantExport: true }, ], + "react-hooks/exhaustive-deps": "off", }, }, ); diff --git a/package.json b/package.json index eae929b..1a9f267 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.module.css b/src/App.module.css index be19978..e6a83de 100644 --- a/src/App.module.css +++ b/src/App.module.css @@ -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 */ diff --git a/src/App.tsx b/src/App.tsx index f8ef1c5..e8128ac 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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; -} + const colors = useMemo( + () => + Array.from({ length: steps }, (_, index) => { + const hue = (index * 360) / (steps - 1); + return Color.from_hcl(hue, chr, lum); + }), + [], + ); + + const colorGradient = useMemo( + () => + colors + .map((color, index) => { + const colorString = formatCssRgb(color.hex); + const percentage = (index / (colors.length - 1)) * 100; + return `${colorString} ${percentage}%`; + }) + .join(", "), + [], + ); -function LeftMenuButton({ onClick, isOpen }: MenuButtonProps) { return ( - - ); -} - -function RightMenuButton({ onClick, isOpen }: MenuButtonProps) { - return ( - - ); -} - -// 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 ( - - ); -} - -function MobileLeftNav({ onClick, isOpen }: MenuButtonProps) { - return ( - - ); -} - -function MobileRightNav({ onClick, isOpen }: MenuButtonProps) { - return ( - - ); -} - -function MobileFirstZone() { - const { selectedColor, selectedColorActions } = useSelectedColor(); - - return ( -
-
-
- -
-
- -
+
+
-
+ ); } -function MobileSecondZone() { +function DesktopContent() { return ( -
-
-
+
+
+ LUMINANCE + A color picker for humans. +
+ + +
); } -function MobileContent({ - isLeftMenuOpen, - setIsLeftMenuOpen, - isRightMenuOpen, - setIsRightMenuOpen, -}: MenuStateProps) { - const toggleRightMenu = () => setIsRightMenuOpen(!isRightMenuOpen); - const toggleLeftMenu = () => setIsLeftMenuOpen(!isLeftMenuOpen); - const { isMobilePortrait, isMobileLandscape } = useMediaQuery(); - - return ( -
- {isMobilePortrait && ( - - )} - - {isMobileLandscape && ( - - )} - - - - - {isMobileLandscape && ( - - )} - - setIsLeftMenuOpen(false)} - > -
- User Info -
-
- - setIsRightMenuOpen(false)} - > -
- Palette Library -
-
-
- ); -} - -// 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 (
@@ -221,10 +102,13 @@ function SecondZone() { disabled={false} />
-
+
+ +
-
- LUMINANCE - A color picker for humans. -
- - -
- ); -} - -// 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 ( -
-
- {!isDesktop && ( - - )} - - {isDesktop && } -
-
- ); -} - export default App; diff --git a/src/components/ColorHistory/ColorHistory.module.css b/src/components/ColorHistory/ColorHistory.module.css index 365f005..41a82dd 100644 --- a/src/components/ColorHistory/ColorHistory.module.css +++ b/src/components/ColorHistory/ColorHistory.module.css @@ -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; +} diff --git a/src/components/ColorHistory/ColorHistory.test.cy.tsx b/src/components/ColorHistory/ColorHistory.test.cy.tsx index 442f29a..3d2c609 100644 --- a/src/components/ColorHistory/ColorHistory.test.cy.tsx +++ b/src/components/ColorHistory/ColorHistory.test.cy.tsx @@ -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); }); diff --git a/src/components/ColorHistory/ColorHistory.tsx b/src/components/ColorHistory/ColorHistory.tsx index 1915830..953a51b 100644 --- a/src/components/ColorHistory/ColorHistory.tsx +++ b/src/components/ColorHistory/ColorHistory.tsx @@ -18,6 +18,7 @@ function ColorHistory({ disabled: boolean; }) { const [history, setHistory] = useState([]); + 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); diff --git a/src/components/ColorPicker/ColorBar.tsx b/src/components/ColorPicker/ColorBar.tsx index feb539d..76883cb 100644 --- a/src/components/ColorPicker/ColorBar.tsx +++ b/src/components/ColorPicker/ColorBar.tsx @@ -27,17 +27,21 @@ function ColorBar({ parentDimensions: CartesianSpace; }) { // State - const [colorBar, setColorBar] = useState(null); const [origin, setOrigin] = useState({ x: 0, y: 0 }); const [dimensions, setDimensions] = useState({ x: 0, y: 0 }); // Refs const containerRef = useRef(null); const canvasRef = useRef(null); + const colorBarRef = useRef(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) { - const newHeight = containerRef.current.clientHeight; - const newWidth = parentDimensions.x - 54; - const newColorBar = new colorlib.ColorBar(newWidth, newHeight); + if (!containerRef.current || !canvasRef.current || parentDimensions.x <= 0) + return; - setColorBar(newColorBar); + colorBarRef.current?.free(); - if (newColorBar) { - smoothAnimation(() => { - if (canvasRef.current) { - newColorBar.fill_color(hue, luminance); - refreshColorBar(canvasRef.current!, newColorBar); - } - }); + const newHeight = containerRef.current.clientHeight; + const newWidth = parentDimensions.x - 54; + const bar = new colorlib.ColorBar(newWidth, newHeight); + colorBarRef.current = bar; + + smoothAnimation(() => { + if (canvasRef.current) { + 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 (
@@ -102,15 +108,11 @@ function ColorBar({ className={styles.colorBar} ref={sliderRef} style={{ - width: colorBar?.get_width(), - height: colorBar?.get_height(), + width: barWidth, + height: barHeight, }} > - +
); diff --git a/src/components/ColorPicker/ColorPicker.module.css b/src/components/ColorPicker/ColorPicker.module.css index 81fed13..a346699 100644 --- a/src/components/ColorPicker/ColorPicker.module.css +++ b/src/components/ColorPicker/ColorPicker.module.css @@ -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; -} diff --git a/src/components/ColorPicker/ColorPicker.tsx b/src/components/ColorPicker/ColorPicker.tsx index 7569a40..d277701 100644 --- a/src/components/ColorPicker/ColorPicker.tsx +++ b/src/components/ColorPicker/ColorPicker.tsx @@ -41,19 +41,19 @@ function ColorPicker({ return (
-
+ {/*
*/}
@@ -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} />
diff --git a/src/components/ColorPicker/ColorSquare.tsx b/src/components/ColorPicker/ColorSquare.tsx index 1cdde88..3e6cf26 100644 --- a/src/components/ColorPicker/ColorSquare.tsx +++ b/src/components/ColorPicker/ColorSquare.tsx @@ -23,15 +23,13 @@ function ColorSquare({ parentDimensions: CartesianSpace; }) { // State - const [colorSquare, setColorSquare] = useState( - null, - ); const [origin, setOrigin] = useState({ x: 0, y: 0 }); const [dimensions, setDimensions] = useState({ x: 0, y: 0 }); // Refs const containerRef = useRef(null); const canvasRef = useRef(null); + const colorSquareRef = useRef(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) { - const newSize = parentDimensions.x - 54; - const newColorSquare = new colorlib.ColorSquare(newSize); + if (!containerRef.current || !canvasRef.current || parentDimensions.x <= 0) + return; - setColorSquare(newColorSquare); + colorSquareRef.current?.free(); - if (newColorSquare) { - smoothAnimation(() => { - if (canvasRef.current) { - newColorSquare.fill_chroma(chroma); - refreshColorSquare(canvasRef.current, newColorSquare); - } - }); + const newSize = parentDimensions.x - 54; + const square = new colorlib.ColorSquare(newSize); + colorSquareRef.current = square; + + smoothAnimation(() => { + if (canvasRef.current) { + 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 (
@@ -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(), }} >
diff --git a/src/components/ColorPicker/Crosshair.tsx b/src/components/ColorPicker/Crosshair.tsx index 1ec8350..4d43665 100644 --- a/src/components/ColorPicker/Crosshair.tsx +++ b/src/components/ColorPicker/Crosshair.tsx @@ -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({ x: 0, y: 0 }); const [dimensions, setDimensions] = useState({ x: 0, y: 0 }); - const [darkCrosshairs, setDarkCrosshairs] = useState(true); + const crosshairColor = { dark: "black", light: "white" }; + const token = useContrastToken(() => luminance); const containerRef = useRef(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({
({ x: 0, y: 0 }); const [dimensions, setDimensions] = useState({ x: 0, y: 0 }); - const [darkCrosshairs, setDarkCrosshairs] = useState(true); + const crosshairColor = { dark: "black", light: "white" }; + const token = useContrastToken(() => luminance); const containerRef = useRef(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({
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 (
{ + if (position === "right") { + return 6; + } else if (position === "left") { + return -4; + } + return 0; + })(), ), }} - /> + > + {isVertical ? ( + + ) : ( + + )} +
); } diff --git a/src/components/ColorValues/ColorValues.module.css b/src/components/ColorValues/ColorValues.module.css index 2d3bfac..a8d681b 100644 --- a/src/components/ColorValues/ColorValues.module.css +++ b/src/components/ColorValues/ColorValues.module.css @@ -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 { diff --git a/src/components/ColorValues/ColorValues.tsx b/src/components/ColorValues/ColorValues.tsx index 888f5d2..5c01f71 100644 --- a/src/components/ColorValues/ColorValues.tsx +++ b/src/components/ColorValues/ColorValues.tsx @@ -45,6 +45,11 @@ function ColorValues({ return (
+ -
); } diff --git a/src/components/ColorValues/HexEditor.test.cy.tsx b/src/components/ColorValues/HexEditor.test.cy.tsx index e0e88a1..27a29cc 100644 --- a/src/components/ColorValues/HexEditor.test.cy.tsx +++ b/src/components/ColorValues/HexEditor.test.cy.tsx @@ -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 diff --git a/src/components/ColorValues/SpaceEditor.test.cy.tsx b/src/components/ColorValues/SpaceEditor.test.cy.tsx index e7d9acc..dfcaeca 100644 --- a/src/components/ColorValues/SpaceEditor.test.cy.tsx +++ b/src/components/ColorValues/SpaceEditor.test.cy.tsx @@ -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"); }); }); diff --git a/src/components/ColorValues/ValueEditor.test.cy.tsx b/src/components/ColorValues/ValueEditor.test.cy.tsx index dd3b068..76f2706 100644 --- a/src/components/ColorValues/ValueEditor.test.cy.tsx +++ b/src/components/ColorValues/ValueEditor.test.cy.tsx @@ -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"); diff --git a/src/components/ColorValues/ValueEditor.tsx b/src/components/ColorValues/ValueEditor.tsx index 01b958f..bcb9e3a 100644 --- a/src/components/ColorValues/ValueEditor.tsx +++ b/src/components/ColorValues/ValueEditor.tsx @@ -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(() => { - setInputValue(formatHexString(color, isShortHex)); + if (!isFocused.current) { + setInputValue(formatHexString(color, isShortHex)); + } }, [color, isShortHex]); + const onFocus = (e: ChangeEvent) => { + isFocused.current = true; + e.target.select(); + }; + const onChange = (e: ChangeEvent) => { 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} />
diff --git a/src/components/PaletteEditor/PaletteEditor.module.css b/src/components/PaletteEditor/PaletteEditor.module.css index 0b6b2b6..8075943 100644 --- a/src/components/PaletteEditor/PaletteEditor.module.css +++ b/src/components/PaletteEditor/PaletteEditor.module.css @@ -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; } diff --git a/src/components/PaletteEditor/PaletteEditor.test.cy.tsx b/src/components/PaletteEditor/PaletteEditor.test.cy.tsx index 53949a7..deffed2 100644 --- a/src/components/PaletteEditor/PaletteEditor.test.cy.tsx +++ b/src/components/PaletteEditor/PaletteEditor.test.cy.tsx @@ -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() {
@@ -28,12 +51,315 @@ function TestWrapper() { ); } -describe("palette editor tests", () => { - beforeEach(() => { - cy.mount(); +it("can edit the palette header", () => { + cy.mount(); + + 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("can perform actions in normal mode", () => { + cy.mount(); + + // 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(); + + 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(); + + 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(); + + 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(); + + // 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", }); - it("renders the palette editor", () => { - cy.dataCy("palette-editor").should("exist"); - }); + // 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"); }); diff --git a/src/components/PaletteEditor/PaletteEditor.tsx b/src/components/PaletteEditor/PaletteEditor.tsx index abea41c..7ac37a4 100644 --- a/src/components/PaletteEditor/PaletteEditor.tsx +++ b/src/components/PaletteEditor/PaletteEditor.tsx @@ -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; + +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("normal"); + const [isSynced, setIsSynced] = useState(false); + const snapshotRef = useRef(null); + const timerRef = useRef(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 (
- - + 0} + canUndo={cardState.history.length > 0} + canRedo={cardState.future.length > 0} + isSynced={isSynced} + incrementHistoryCounter={incrementHistoryCounter} + snapshotRef={snapshotRef} + syncTimerRef={timerRef} + /> +
); } -function ActionBar() { - return
actions
; -} +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; + syncTimerRef: RefObject; +}) { + 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 ( -
- - - - +
+ + + + + + + + {mode === "select" && ( + <> + + + + )}
); } -function CardHeader() { - return
header
; +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; + syncTimerRef: RefObject; + 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; + } + + // 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; + } + } + + 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 + } + + 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 ( +
+ + + + + +
+ ); } -function PickerColor() { - return
picker color
; +function SyncButton({ + isSynced, + setIsSynced, +}: { + isSynced: boolean; + setIsSynced: (v: boolean) => void; +}) { + return ( +
setIsSynced(!isSynced)} + aria-pressed={isSynced} + style={{ + color: isSynced ? "#292929" : "#7a7a7a", + }} + title={isSynced ? "Unsync Picker & Palette" : "Sync Picker and Palette"} + > + Picker + + {isSynced ? : } + + Palette +
+ ); } -function PaletteColor() { - return
palette color
; +function CardHeader({ + name, + onNameChange, +}: { + name: string; + onNameChange: (name: string) => void; +}) { + return ( +
+ +
+ ); } -function Palette() { - return
palette
; +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 ( +
+ {!isSynced && ( +
+ +
+ )} +
+ ); +} + +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 ( +
+ {!isSynced && ( +
+ +
+ )} +
+ ); +} + +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 ( +
+ {displayColors.length > 0 ? ( + displayColors.map((color, index) => ( + + )) + ) : ( + + No colors in palette. Press{" "} + in the + toolbar above to add one. + + )} +
+ ); +} + +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) => 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 ( + setItemRef(el, color.id)} + data-cy={`palette-row-${index}-wrapper`} + data-item-id={color.id} + onClick={(e) => onRowClick?.(color, e)} + {...motionProps} + > +
+ {isSelectMode && ( + + {isSelected && } + + )} + {mode === "reorder" && ( + + + + )} + {isNormalMode && + (isSelected ? ( + + + + ) : ( + + ))} +
+
+ {isEditable ? ( + + actions.setColorName(color.id, newName) + } + buttonSize={12} + contrastToken={token} + reset={!isSelected} + /> + ) : ( +
+ + {color.name} + +
+ )} +
+
+ {isEditable ? ( + + actions.setColorValue(color.id, HexColor.from_code(newHex)) + } + buttonSize={12} + contrastToken={token} + reset={!isSelected} + validate={(raw: string) => extractHexValue(raw)} + render={(raw: string) => `#${raw}`} + /> + ) : ( +
+ + {formatHexString(color.hex)} + +
+ )} +
+
+
+
+ ); +} + +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(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 ( +
+
+ e.stopPropagation()} + onKeyDown={isEditing ? handleKeyDown : undefined} + > + {!isEditing && (render ? render(value) : value)} + +
+ {isEditing ? ( +
+ + +
+ ) : ( +
+ +
+ )} +
+ ); } export default PaletteEditor; diff --git a/src/hooks/contrast.ts b/src/hooks/contrast.ts new file mode 100644 index 0000000..26ba9b9 --- /dev/null +++ b/src/hooks/contrast.ts @@ -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], + ); +} diff --git a/src/hooks/hex.ts b/src/hooks/hex.ts new file mode 100644 index 0000000..d2b0671 --- /dev/null +++ b/src/hooks/hex.ts @@ -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()}`; +}; diff --git a/src/hooks/paletteCard.ts b/src/hooks/paletteCard.ts index 906d51e..bbac933 100644 --- a/src/hooks/paletteCard.ts +++ b/src/hooks/paletteCard.ts @@ -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,64 +37,165 @@ 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" }; +const pushToHistory = (state: PaletteCardState, newPresent: PaletteCard) => { + return { + ...state, + history: [state.present, ...state.history], + future: [], + present: newPresent, + }; +}; + export function paletteCardReducer( state: PaletteCardState, action: PaletteCardAction, ): PaletteCardState { - const pushToHistory = (state: PaletteCardState, newPresent: PaletteCard) => { - return { - ...state, - history: [state.present, ...state.history], - future: [], - present: newPresent, - }; - }; - 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" }), }; diff --git a/src/hooks/slider.tsx b/src/hooks/slider.tsx index 6603e08..e11df70 100644 --- a/src/hooks/slider.tsx +++ b/src/hooks/slider.tsx @@ -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; diff --git a/src/hooks/storage.ts b/src/hooks/storage.ts new file mode 100644 index 0000000..06496cf --- /dev/null +++ b/src/hooks/storage.ts @@ -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 { + try { + return JSON.parse(localStorage.getItem(CARDS_KEY) ?? "{}"); + } catch { + return {}; + } +} + +export function saveCards(cards: Record) { + 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); +} diff --git a/src/hooks/tests/paletteCard.test.ts b/src/hooks/tests/paletteCard.test.ts index 553318a..317da63 100644 --- a/src/hooks/tests/paletteCard.test.ts +++ b/src/hooks/tests/paletteCard.test.ts @@ -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 => ({ + 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 emptyState = makeState(makeCard()); + +const seededState = makeState( + makeCard({ + colors: [makeColor("a"), makeColor("b"), makeColor("c")], + }), +); + +// Helpers + +let state: PaletteCardState; +let dispatch: (value: PaletteCardAction) => void; +let actions: PaletteCardActions; + +const setup = (initial: PaletteCardState) => { + [state, dispatch] = mockUseReducer(paletteCardReducer, initial); + actions = createPaletteCardActions(dispatch); }; -const testState = createPaletteState(testPaletteCard); -const WHITE = HexColor.from_code("#fff"); -const GREY = HexColor.from_code("#777"); -const BLACK = HexColor.from_code("#000"); - -describe("palette card actions", () => { - let state: PaletteCardState; - let dispatch: (value: PaletteCardAction) => void; - let actions: PaletteCardActions; +// Tests +describe("set card name", () => { beforeEach(() => { - [state, dispatch] = mockUseReducer(paletteCardReducer, testState); - actions = createPaletteCardActions(dispatch); + 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); + }); }); diff --git a/src/providers/SelectedColorProvider.tsx b/src/providers/SelectedColorProvider.tsx index 9cc58d3..a4857a8 100644 --- a/src/providers/SelectedColorProvider.tsx +++ b/src/providers/SelectedColorProvider.tsx @@ -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 = { - selectedColor: colorState.color, - selectedColorActions: colorActions, - }; + const value = useMemo( + () => ({ + selectedColor: colorState.color, + selectedColorActions: colorActions, + }), + [colorState.color, colorActions], + ); return ( diff --git a/src/providers/hooks.ts b/src/providers/hooks.ts index 2bc43b4..e90095b 100644 --- a/src/providers/hooks.ts +++ b/src/providers/hooks.ts @@ -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); diff --git a/src/util.ts b/src/util.ts index feaa606..1088078 100644 --- a/src/util.ts +++ b/src/util.ts @@ -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); +}