Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ff6c60640 | |||
| 91ba6a2c69 | |||
| 5f6d0f43ee | |||
| 9fec89949b | |||
| 6be2d9e41a | |||
| 865214b8c3 | |||
| 5fa9d08017 |
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
latest=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
||||||
|
IFS='.' read -r major minor patch <<< "${latest#v}"
|
||||||
|
|
||||||
|
case ${1:-patch} in
|
||||||
|
major) new="v$((major+1)).0.0" ;;
|
||||||
|
minor) new="v${major}.$((minor+1)).0" ;;
|
||||||
|
patch) new="v${major}.${minor}.$((patch+1))" ;;
|
||||||
|
*) echo "Usage: bump.sh [major|minor|patch]" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
git tag -a "$new"
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
code2prompt -c -e package-lock.json -e example.json -e "colorlib/*"
|
||||||
+16
-2
@@ -167,6 +167,21 @@ impl Color {
|
|||||||
Component::HCL_L => Color::from_hcl(self.hcl.h, self.hcl.c, value),
|
Component::HCL_L => Color::from_hcl(self.hcl.h, self.hcl.c, value),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Checks if two Color objects are equal
|
||||||
|
///
|
||||||
|
/// # Example:
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use colorlib::Color;
|
||||||
|
///
|
||||||
|
/// let color1 = Color::from_hex("FF0000");
|
||||||
|
/// let color2 = Color::from_hex("FF0000");
|
||||||
|
/// assert!(color1.equals(&color2));
|
||||||
|
/// ```
|
||||||
|
pub fn equals(&self, other: &Color) -> bool {
|
||||||
|
self == other
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -252,8 +267,7 @@ mod tests {
|
|||||||
fn color_from_hcl() {
|
fn color_from_hcl() {
|
||||||
let hex_code = "4B964B";
|
let hex_code = "4B964B";
|
||||||
let (hr, hg, hb) = (75u8, 150u8, 75u8);
|
let (hr, hg, hb) = (75u8, 150u8, 75u8);
|
||||||
let (r, g, b) =
|
let (r, g, b) = (75.19744022437494, 150.3948804487499, 75.19744022437494);
|
||||||
(75.19744022437494, 150.3948804487499, 75.19744022437494);
|
|
||||||
let (h1, s, v) = (120.0, 0.5, 0.5897838448970584);
|
let (h1, s, v) = (120.0, 0.5, 0.5897838448970584);
|
||||||
let (h2, c, l) = (120.0, 0.5, 0.49);
|
let (h2, c, l) = (120.0, 0.5, 0.49);
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,21 @@ impl HCL {
|
|||||||
let c = calc::chroma(h, s, l);
|
let c = calc::chroma(h, s, l);
|
||||||
HCL::new(h, c, l)
|
HCL::new(h, c, l)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Checks if two HCL colors are equal
|
||||||
|
///
|
||||||
|
/// # Example:
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use colorlib::hcl::HCL;
|
||||||
|
///
|
||||||
|
/// let hcl1 = HCL::new(0.0, 1.0, 0.55);
|
||||||
|
/// let hcl2 = HCL::new(0.0, 1.0, 0.55);
|
||||||
|
/// assert!(hcl1.equals(&hcl2));
|
||||||
|
/// ```
|
||||||
|
pub fn equals(&self, other: &HCL) -> bool {
|
||||||
|
self == other
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -185,15 +200,7 @@ mod tests {
|
|||||||
0.864996,
|
0.864996,
|
||||||
0.521301
|
0.521301
|
||||||
);
|
);
|
||||||
from_hsv!(
|
from_hsv!(from_hsv_dark_magenta, 300.0, 0.9, 0.5, 300.0, 0.9, 0.323601);
|
||||||
from_hsv_dark_magenta,
|
|
||||||
300.0,
|
|
||||||
0.9,
|
|
||||||
0.5,
|
|
||||||
300.0,
|
|
||||||
0.9,
|
|
||||||
0.323601
|
|
||||||
);
|
|
||||||
from_hsv!(
|
from_hsv!(
|
||||||
from_hsv_light_magenta,
|
from_hsv_light_magenta,
|
||||||
300.0,
|
300.0,
|
||||||
|
|||||||
@@ -138,6 +138,21 @@ impl Hex {
|
|||||||
b: decoded[2],
|
b: decoded[2],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Checks if two Hex colors are equal
|
||||||
|
///
|
||||||
|
/// # Example:
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use colorlib::hex::Hex;
|
||||||
|
///
|
||||||
|
/// let hex1 = Hex::from_code("FF0000");
|
||||||
|
/// let hex2 = Hex::from_code("FF0000");
|
||||||
|
/// assert!(hex1.equals(&hex2));
|
||||||
|
/// ```
|
||||||
|
pub fn equals(&self, other: &Hex) -> bool {
|
||||||
|
self == other
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -114,6 +114,21 @@ impl HSV {
|
|||||||
let v = calc::value(h, s, l);
|
let v = calc::value(h, s, l);
|
||||||
HSV::new(h, s, v)
|
HSV::new(h, s, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Checks if two HSV colors are equal
|
||||||
|
///
|
||||||
|
/// # Example:
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use colorlib::hsv::HSV;
|
||||||
|
///
|
||||||
|
/// let hsv1 = HSV::new(0.0, 1.0, 1.0);
|
||||||
|
/// let hsv2 = HSV::new(0.0, 1.0, 1.0);
|
||||||
|
/// assert!(hsv1.equals(&hsv2));
|
||||||
|
/// ```
|
||||||
|
pub fn equals(&self, other: &HSV) -> bool {
|
||||||
|
self == other
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -93,6 +93,21 @@ impl RGB {
|
|||||||
let hsv = HSV::from_hcl(h, c, l);
|
let hsv = HSV::from_hcl(h, c, l);
|
||||||
RGB::from_hsv(hsv.h, hsv.s, hsv.v)
|
RGB::from_hsv(hsv.h, hsv.s, hsv.v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Checks if two RGB colors are equal
|
||||||
|
///
|
||||||
|
/// # Example:
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use colorlib::rgb::RGB;
|
||||||
|
///
|
||||||
|
/// let rgb1 = RGB::new(255.0, 0.0, 0.0);
|
||||||
|
/// let rgb2 = RGB::new(255.0, 0.0, 0.0);
|
||||||
|
/// assert!(rgb1.equals(&rgb2));
|
||||||
|
/// ```
|
||||||
|
pub fn equals(&self, other: &RGB) -> bool {
|
||||||
|
self == other
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -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}"]`);
|
return cy.get(`[data-cy="${value}"]`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,13 @@
|
|||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||||
<title>Components App</title>
|
<title>Components App</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
|
||||||
|
Arial, sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div data-cy-root></div>
|
<div data-cy-root></div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export default tseslint.config(
|
|||||||
"warn",
|
"warn",
|
||||||
{ allowConstantExport: true },
|
{ allowConstantExport: true },
|
||||||
],
|
],
|
||||||
|
"react-hooks/exhaustive-deps": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
git tag -a "$(cat VERSION)" -m "Version $(cat VERSION)"
|
|
||||||
Generated
+811
-962
File diff suppressed because it is too large
Load Diff
+8
-7
@@ -7,21 +7,22 @@
|
|||||||
"build:wasm": "wasm-pack build colorlib -t bundler -d pkg --release",
|
"build:wasm": "wasm-pack build colorlib -t bundler -d pkg --release",
|
||||||
"check": "tsc --noEmit -p tsconfig.app.json",
|
"check": "tsc --noEmit -p tsconfig.app.json",
|
||||||
"clean": "rm -rf dist colorlib/pkg*",
|
"clean": "rm -rf dist colorlib/pkg*",
|
||||||
|
"cypress:component": "cypress run --component",
|
||||||
"cypress:open": "1>/dev/null 2>/dev/null cypress open -d &",
|
"cypress:open": "1>/dev/null 2>/dev/null cypress open -d &",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test:wasm": "cargo test --lib --manifest-path colorlib/Cargo.toml",
|
"test:wasm": "cargo test --lib --manifest-path colorlib/Cargo.toml",
|
||||||
"test:wasmdoc": "cargo test --doc --manifest-path colorlib/Cargo.toml",
|
"test:wasmdoc": "cargo test --doc --manifest-path colorlib/Cargo.toml",
|
||||||
"test": "vitest",
|
"test": "vitest run",
|
||||||
"test:component:chrome": "cypress run --component -b chromium",
|
"test:watch": "vitest",
|
||||||
"test:component:fire": "cypress run --component -b firefox"
|
"test:component": "cypress run --component -b chromium",
|
||||||
|
"test:component:fire": "cypress run --component -b firefox",
|
||||||
|
"test:e2e": "cypress run --e2e -b chromium",
|
||||||
|
"test:e2e:firefox": "cypress run --e2e -b firefox"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.0.0",
|
"lucide-react": "^0.542.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^7.0.0",
|
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.0.0",
|
|
||||||
"@fortawesome/react-fontawesome": "^0.2.3",
|
|
||||||
"motion": "^12.23.12",
|
"motion": "^12.23.12",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0"
|
||||||
|
|||||||
+28
-19
@@ -1,30 +1,36 @@
|
|||||||
|
.background {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
.appWrapper {
|
.appWrapper {
|
||||||
background-color: white;
|
background-color: #f9f9f9;
|
||||||
height: 100%;
|
min-height: 100%;
|
||||||
width: 1200px;
|
width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
box-shadow: 0 0 40px #7a7a7a;
|
box-shadow: 0 0 40px #7a7a7a;
|
||||||
border-left: 2px solid #7a7a7a;
|
border-left: 1px solid #c9c9c9;
|
||||||
border-right: 2px solid #7a7a7a;
|
border-right: 1px solid #c9c9c9;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mainLayout {
|
.mainLayout {
|
||||||
|
height: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"header header"
|
"header header"
|
||||||
"picker palette";
|
"picker palette";
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
grid-template-columns: 1fr 2fr;
|
grid-template-columns: 1fr 2fr;
|
||||||
grid-template-rows: 76px 1fr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.appHeader {
|
.appHeader {
|
||||||
grid-area: header;
|
grid-area: header;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
border-bottom: 2px solid #7a7a7a;
|
border-bottom: 1px solid #c9c9c9;
|
||||||
padding: 20px 30px 12px;
|
padding: 20px 30px 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.appHeader .title {
|
.appHeader .title {
|
||||||
@@ -52,38 +58,41 @@
|
|||||||
grid-area: picker;
|
grid-area: picker;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-right: 2px solid #7a7a7a;
|
border-right: 1px solid #c9c9c9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondZone {
|
.secondZone {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
grid-area: palette;
|
grid-area: palette;
|
||||||
color: #555;
|
display: flex;
|
||||||
font-style: italic;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.colorHistoryWrapper {
|
.colorHistoryWrapper {
|
||||||
|
flex-shrink: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-bottom: 2px solid #7a7a7a;
|
border-bottom: 1px solid #c9c9c9;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.colorPickerWrapper {
|
.colorPickerWrapper {
|
||||||
border-bottom: 2px solid #7a7a7a;
|
border-bottom: 1px solid #c9c9c9;
|
||||||
padding: 20px 40px 40px;
|
padding: 20px 40px 26px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.colorValuesWrapper {
|
.colorValuesWrapper {
|
||||||
padding: 40px;
|
padding: 24px 40px;
|
||||||
}
|
|
||||||
|
|
||||||
.colorHistoryWrapper {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.paletteEditorWrapper {
|
.paletteEditorWrapper {
|
||||||
|
flex: 2;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: hidden;
|
||||||
|
border-bottom: 1px solid #c9c9c9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paletteLibraryWrapper {
|
.paletteLibraryWrapper {
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Large */
|
/* Large */
|
||||||
|
|||||||
+56
-235
@@ -1,199 +1,70 @@
|
|||||||
import { useState } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { Color } from "colorlib";
|
import { Color } from "colorlib";
|
||||||
|
|
||||||
import ColorHistory from "@/components/ColorHistory/ColorHistory";
|
import ColorHistory from "@/components/ColorHistory/ColorHistory";
|
||||||
import ColorPicker from "@/components/ColorPicker/ColorPicker";
|
import ColorPicker from "@/components/ColorPicker/ColorPicker";
|
||||||
import ColorValues from "@/components/ColorValues/ColorValues";
|
import ColorValues from "@/components/ColorValues/ColorValues";
|
||||||
import { LeftMenu, RightMenu } from "@/components/SideMenu";
|
|
||||||
import { useMediaQuery } from "@/providers/hooks";
|
|
||||||
import { useSelectedColor } from "@/providers/hooks";
|
import { useSelectedColor } from "@/providers/hooks";
|
||||||
|
|
||||||
import styles from "./App.module.css";
|
import styles from "./App.module.css";
|
||||||
|
import PaletteEditor from "./components/PaletteEditor/PaletteEditor";
|
||||||
|
import { deserializeCard, loadActiveCardId, loadCards } from "./hooks/storage";
|
||||||
import { formatCssRgb } from "./util";
|
import { formatCssRgb } from "./util";
|
||||||
|
|
||||||
// Menu Button Components
|
function App() {
|
||||||
|
const lum = 0.75;
|
||||||
|
const chr = 0.8;
|
||||||
|
const steps = 8;
|
||||||
|
|
||||||
interface MenuButtonProps {
|
const colors = useMemo(
|
||||||
onClick: () => void;
|
() =>
|
||||||
isOpen: boolean;
|
Array.from({ length: steps }, (_, index) => {
|
||||||
}
|
const hue = (index * 360) / (steps - 1);
|
||||||
|
return Color.from_hcl(hue, chr, lum);
|
||||||
function LeftMenuButton({ onClick, isOpen }: MenuButtonProps) {
|
}),
|
||||||
return (
|
[],
|
||||||
<button
|
|
||||||
className={styles.leftMenuButton}
|
|
||||||
onClick={onClick}
|
|
||||||
aria-label="Open left menu"
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
aria-expanded={isOpen}
|
|
||||||
>
|
|
||||||
☰
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function RightMenuButton({ onClick, isOpen }: MenuButtonProps) {
|
const colorGradient = useMemo(
|
||||||
return (
|
() =>
|
||||||
<button
|
colors
|
||||||
className={styles.rightMenuButton}
|
.map((color, index) => {
|
||||||
onClick={onClick}
|
const colorString = formatCssRgb(color.hex);
|
||||||
aria-label="Open right menu"
|
const percentage = (index / (colors.length - 1)) * 100;
|
||||||
aria-haspopup="dialog"
|
return `${colorString} ${percentage}%`;
|
||||||
aria-expanded={isOpen}
|
})
|
||||||
>
|
.join(", "),
|
||||||
☰
|
[],
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// Mobile Layout Components
|
|
||||||
|
|
||||||
interface MenuStateProps {
|
|
||||||
isRightMenuOpen: boolean;
|
|
||||||
isLeftMenuOpen: boolean;
|
|
||||||
setIsRightMenuOpen: (state: boolean) => void;
|
|
||||||
setIsLeftMenuOpen: (state: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MobileTopNav({
|
|
||||||
onLeftMenuClick,
|
|
||||||
onRightMenuClick,
|
|
||||||
isRightMenuOpen,
|
|
||||||
isLeftMenuOpen,
|
|
||||||
}: {
|
|
||||||
onLeftMenuClick: () => void;
|
|
||||||
onRightMenuClick: () => void;
|
|
||||||
isRightMenuOpen: boolean;
|
|
||||||
isLeftMenuOpen: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<nav className={styles.mobileTopNav} aria-label="Mobile top navigation">
|
|
||||||
<LeftMenuButton onClick={onLeftMenuClick} isOpen={isLeftMenuOpen} />
|
|
||||||
<RightMenuButton onClick={onRightMenuClick} isOpen={isRightMenuOpen} />
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MobileLeftNav({ onClick, isOpen }: MenuButtonProps) {
|
|
||||||
return (
|
|
||||||
<nav className={styles.mobileLeftNav} aria-label="Mobile left navigation">
|
|
||||||
<LeftMenuButton onClick={onClick} isOpen={isOpen} />
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MobileRightNav({ onClick, isOpen }: MenuButtonProps) {
|
|
||||||
return (
|
|
||||||
<nav className={styles.mobileRightNav} aria-label="Mobile right navigation">
|
|
||||||
<RightMenuButton onClick={onClick} isOpen={isOpen} />
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MobileFirstZone() {
|
|
||||||
const { selectedColor, selectedColorActions } = useSelectedColor();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.mobileFirstZone} aria-label="Color tools">
|
|
||||||
<div
|
<div
|
||||||
className={styles.tabWrapper}
|
className={styles.background}
|
||||||
role="region"
|
style={{
|
||||||
aria-roledescription="carousel"
|
background: `linear-gradient(180deg, ${colorGradient})`,
|
||||||
aria-label="Swipe left or right to view different tools"
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div className={styles.appWrapper} role="application">
|
||||||
className={clsx(styles.tab, styles.colorPickerWrapper)}
|
<DesktopContent />
|
||||||
role="group"
|
|
||||||
aria-roledescription="slide"
|
|
||||||
aria-label="Color Picker"
|
|
||||||
>
|
|
||||||
<ColorPicker color={selectedColor} actions={selectedColorActions} />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={clsx(styles.tab, styles.colorValuesWrapper)}
|
|
||||||
role="group"
|
|
||||||
aria-roledescription="slide"
|
|
||||||
aria-label="Color values"
|
|
||||||
>
|
|
||||||
<ColorValues color={selectedColor} actions={selectedColorActions} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileSecondZone() {
|
function DesktopContent() {
|
||||||
return (
|
return (
|
||||||
<section className={styles.mobileSecondZone} aria-label="Palette tools">
|
<div className={styles.mainLayout}>
|
||||||
<div
|
<header className={styles.appHeader}>
|
||||||
className={styles.paletteEditorWrapper}
|
<span className={styles.title}>LUMINANCE</span>
|
||||||
aria-label="Palette editor"
|
<span className={styles.subtitle}>A color picker for humans.</span>
|
||||||
></div>
|
</header>
|
||||||
</section>
|
<FirstZone />
|
||||||
|
<SecondZone />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileContent({
|
|
||||||
isLeftMenuOpen,
|
|
||||||
setIsLeftMenuOpen,
|
|
||||||
isRightMenuOpen,
|
|
||||||
setIsRightMenuOpen,
|
|
||||||
}: MenuStateProps) {
|
|
||||||
const toggleRightMenu = () => setIsRightMenuOpen(!isRightMenuOpen);
|
|
||||||
const toggleLeftMenu = () => setIsLeftMenuOpen(!isLeftMenuOpen);
|
|
||||||
const { isMobilePortrait, isMobileLandscape } = useMediaQuery();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className={styles.mobileContent}>
|
|
||||||
{isMobilePortrait && (
|
|
||||||
<MobileTopNav
|
|
||||||
onLeftMenuClick={toggleLeftMenu}
|
|
||||||
onRightMenuClick={toggleRightMenu}
|
|
||||||
isLeftMenuOpen={isLeftMenuOpen}
|
|
||||||
isRightMenuOpen={isRightMenuOpen}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isMobileLandscape && (
|
|
||||||
<MobileLeftNav onClick={toggleLeftMenu} isOpen={isLeftMenuOpen} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MobileFirstZone />
|
|
||||||
<MobileSecondZone />
|
|
||||||
|
|
||||||
{isMobileLandscape && (
|
|
||||||
<MobileRightNav onClick={toggleRightMenu} isOpen={isRightMenuOpen} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<LeftMenu
|
|
||||||
isOpen={isLeftMenuOpen}
|
|
||||||
onClose={() => setIsLeftMenuOpen(false)}
|
|
||||||
>
|
|
||||||
<div id="user-info" aria-label="User information">
|
|
||||||
User Info
|
|
||||||
</div>
|
|
||||||
</LeftMenu>
|
|
||||||
|
|
||||||
<RightMenu
|
|
||||||
isOpen={isRightMenuOpen}
|
|
||||||
onClose={() => setIsRightMenuOpen(false)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
id="palette-library"
|
|
||||||
className={styles.paletteLibraryWrapper}
|
|
||||||
aria-label="Palette library"
|
|
||||||
>
|
|
||||||
Palette Library
|
|
||||||
</div>
|
|
||||||
</RightMenu>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Desktop Layout Components
|
|
||||||
|
|
||||||
function FirstZone() {
|
function FirstZone() {
|
||||||
const { selectedColor, selectedColorActions } = useSelectedColor();
|
const { selectedColor, selectedColorActions } = useSelectedColor();
|
||||||
|
|
||||||
@@ -212,6 +83,16 @@ function FirstZone() {
|
|||||||
function SecondZone() {
|
function SecondZone() {
|
||||||
const { selectedColor, selectedColorActions } = useSelectedColor();
|
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 (
|
return (
|
||||||
<section className={styles.secondZone} aria-label="Palette tools">
|
<section className={styles.secondZone} aria-label="Palette tools">
|
||||||
<div className={styles.colorHistoryWrapper} aria-label="Color History">
|
<div className={styles.colorHistoryWrapper} aria-label="Color History">
|
||||||
@@ -221,10 +102,13 @@ function SecondZone() {
|
|||||||
disabled={false}
|
disabled={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className={styles.paletteEditorWrapper} aria-label="Palette editor">
|
||||||
className={styles.paletteEditorWrapper}
|
<PaletteEditor
|
||||||
aria-label="Palette editor"
|
pickerColor={selectedColor.hex}
|
||||||
></div>
|
setPickerColor={selectedColorActions.hex.setHex}
|
||||||
|
initialCardState={initialCardState}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className={styles.paletteLibraryWrapper}
|
className={styles.paletteLibraryWrapper}
|
||||||
aria-label="Palette library"
|
aria-label="Palette library"
|
||||||
@@ -233,67 +117,4 @@ function SecondZone() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DesktopContent() {
|
|
||||||
return (
|
|
||||||
<div className={styles.mainLayout}>
|
|
||||||
<header className={styles.appHeader}>
|
|
||||||
<span className={styles.title}>LUMINANCE</span>
|
|
||||||
<span className={styles.subtitle}>A color picker for humans.</span>
|
|
||||||
</header>
|
|
||||||
<FirstZone />
|
|
||||||
<SecondZone />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main App Component
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [isRightMenuOpen, setIsRightMenuOpen] = useState(false);
|
|
||||||
const [isLeftMenuOpen, setIsLeftMenuOpen] = useState(false);
|
|
||||||
// const { isDesktop } = useMediaQuery();
|
|
||||||
const isDesktop = true;
|
|
||||||
|
|
||||||
const lum = 0.75;
|
|
||||||
const chr = 0.8;
|
|
||||||
const steps = 8;
|
|
||||||
|
|
||||||
const colors = Array.from({ length: steps }, (_, index) => {
|
|
||||||
const hue = (index * 360) / (steps - 1);
|
|
||||||
return Color.from_hcl(hue, chr, lum);
|
|
||||||
});
|
|
||||||
|
|
||||||
const colorGradient = colors
|
|
||||||
.map((color, index) => {
|
|
||||||
const colorString = formatCssRgb(color.hex);
|
|
||||||
const percentage = (index / (colors.length - 1)) * 100;
|
|
||||||
return `${colorString} ${percentage}%`;
|
|
||||||
})
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={styles.background}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
background: `linear-gradient(180deg, ${colorGradient})`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={styles.appWrapper} role="application">
|
|
||||||
{!isDesktop && (
|
|
||||||
<MobileContent
|
|
||||||
isLeftMenuOpen={isLeftMenuOpen}
|
|
||||||
setIsLeftMenuOpen={setIsLeftMenuOpen}
|
|
||||||
isRightMenuOpen={isRightMenuOpen}
|
|
||||||
setIsRightMenuOpen={setIsRightMenuOpen}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isDesktop && <DesktopContent />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 5px 0px 0px;
|
padding: 10px 0px 0px;
|
||||||
|
|
||||||
/* Improve scrolling experience */
|
/* Improve scrolling experience */
|
||||||
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
|
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
|
||||||
@@ -17,32 +17,36 @@
|
|||||||
height: 50px;
|
height: 50px;
|
||||||
width: 25px;
|
width: 25px;
|
||||||
margin: 5px;
|
margin: 5px;
|
||||||
border: 2px solid #7a7a7a;
|
border: 1px solid #7a7a7a;
|
||||||
transition:
|
background-color: transparent;
|
||||||
margin 200ms,
|
|
||||||
height 200ms,
|
|
||||||
width 200ms;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
transition:
|
||||||
|
box-shadow 150ms ease-out,
|
||||||
.historyColor:hover {
|
transform 150ms ease-out;
|
||||||
height: 56px;
|
box-shadow:
|
||||||
width: 31px;
|
rgba(60, 64, 67, 0.25) 0px 1px 2px 0px,
|
||||||
margin: 2px;
|
rgba(60, 64, 67, 0.1) 0px 1px 3px 0px;
|
||||||
}
|
|
||||||
|
|
||||||
.historyColor:first-of-type:hover {
|
|
||||||
margin-left: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.historyColor:first-of-type {
|
.historyColor:first-of-type {
|
||||||
margin-left: 15px;
|
margin-left: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.historyColor:last-of-type:hover {
|
|
||||||
margin-right: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.historyColor:last-of-type {
|
.historyColor:last-of-type {
|
||||||
margin-right: 15px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+11
-8
@@ -53,10 +53,11 @@ describe("color history", () => {
|
|||||||
cy.enableTransitions();
|
cy.enableTransitions();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds stable color values after 1 second", () => {
|
it("adds stable color values after 3 seconds", () => {
|
||||||
// add stable values to history
|
// add stable values to history
|
||||||
cy.dataCy("hex-value-input").as("value").clear().type("#00F536");
|
cy.dataCy("hex-value-input").as("value").clear().type("#00F536").blur();
|
||||||
cy.tick(1000);
|
cy.wait(0); // let blur take effect
|
||||||
|
cy.tick(3000);
|
||||||
|
|
||||||
cy.dataCy("color-history").children().should("have.length", 1);
|
cy.dataCy("color-history").children().should("have.length", 1);
|
||||||
cy.dataCy("history-color-0").should(
|
cy.dataCy("history-color-0").should(
|
||||||
@@ -65,8 +66,9 @@ describe("color history", () => {
|
|||||||
"rgb(0, 245, 54)",
|
"rgb(0, 245, 54)",
|
||||||
);
|
);
|
||||||
|
|
||||||
cy.get("@value").clear().type("#E23AEC");
|
cy.get("@value").clear().type("#E23AEC").blur();
|
||||||
cy.tick(1000);
|
cy.wait(0);
|
||||||
|
cy.tick(3000);
|
||||||
|
|
||||||
cy.dataCy("color-history").children().should("have.length", 2);
|
cy.dataCy("color-history").children().should("have.length", 2);
|
||||||
cy.dataCy("history-color-0").should(
|
cy.dataCy("history-color-0").should(
|
||||||
@@ -81,14 +83,15 @@ describe("color history", () => {
|
|||||||
|
|
||||||
// disable history
|
// disable history
|
||||||
cy.dataCy("disabled-checkbox").click();
|
cy.dataCy("disabled-checkbox").click();
|
||||||
cy.get("@value").clear().type("#00C3EE");
|
cy.get("@value").clear().type("#00C3EE").blur();
|
||||||
cy.tick(1000);
|
cy.wait(0);
|
||||||
|
cy.tick(3000);
|
||||||
|
|
||||||
cy.dataCy("color-history").children().should("have.length", 2);
|
cy.dataCy("color-history").children().should("have.length", 2);
|
||||||
|
|
||||||
// re-enable history
|
// re-enable history
|
||||||
cy.dataCy("disabled-checkbox").click();
|
cy.dataCy("disabled-checkbox").click();
|
||||||
cy.tick(1000);
|
cy.tick(3000);
|
||||||
|
|
||||||
cy.dataCy("color-history").children().should("have.length", 3);
|
cy.dataCy("color-history").children().should("have.length", 3);
|
||||||
});
|
});
|
||||||
@@ -18,6 +18,7 @@ function ColorHistory({
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [history, setHistory] = useState<Color[]>([]);
|
const [history, setHistory] = useState<Color[]>([]);
|
||||||
|
const colorValue = color.hex.to_code();
|
||||||
const maxItems = 50;
|
const maxItems = 50;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -31,10 +32,10 @@ function ColorHistory({
|
|||||||
const newHistory = [color, ...prev];
|
const newHistory = [color, ...prev];
|
||||||
return newHistory.slice(0, maxItems);
|
return newHistory.slice(0, maxItems);
|
||||||
});
|
});
|
||||||
}, 1000);
|
}, 2000);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [color, disabled]);
|
}, [colorValue, disabled]);
|
||||||
|
|
||||||
const handleClick = (historyColor: Color) => {
|
const handleClick = (historyColor: Color) => {
|
||||||
setColor(historyColor);
|
setColor(historyColor);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { memory } from "colorlib/colorlib_bg.wasm";
|
|||||||
import { useSmoothAnimation } from "@/hooks/animation";
|
import { useSmoothAnimation } from "@/hooks/animation";
|
||||||
import type { Setter } from "@/hooks/color";
|
import type { Setter } from "@/hooks/color";
|
||||||
import { useSlider } from "@/hooks/slider";
|
import { useSlider } from "@/hooks/slider";
|
||||||
import { useResize } from "@/hooks/window";
|
import { onResize } from "@/hooks/window";
|
||||||
import type { CartesianSpace } from "@/types";
|
import type { CartesianSpace } from "@/types";
|
||||||
import { Direction } from "@/types";
|
import { Direction } from "@/types";
|
||||||
import { setMeasurements } from "@/util";
|
import { setMeasurements } from "@/util";
|
||||||
@@ -27,17 +27,21 @@ function ColorBar({
|
|||||||
parentDimensions: CartesianSpace;
|
parentDimensions: CartesianSpace;
|
||||||
}) {
|
}) {
|
||||||
// State
|
// State
|
||||||
const [colorBar, setColorBar] = useState<colorlib.ColorBar | null>(null);
|
|
||||||
const [origin, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
|
const [origin, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
|
||||||
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
|
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
|
||||||
|
|
||||||
// Refs
|
// Refs
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const colorBarRef = useRef<colorlib.ColorBar | null>(null);
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
const smoothAnimation = useSmoothAnimation();
|
const smoothAnimation = useSmoothAnimation();
|
||||||
|
|
||||||
|
// Dimensions
|
||||||
|
const barWidth = parentDimensions.x > 0 ? parentDimensions.x - 54 : 0;
|
||||||
|
const barHeight = containerRef.current?.clientHeight;
|
||||||
|
|
||||||
// Slider interaction
|
// Slider interaction
|
||||||
const { sliderRef } = useSlider({
|
const { sliderRef } = useSlider({
|
||||||
direction: Direction.HORIZONTAL,
|
direction: Direction.HORIZONTAL,
|
||||||
@@ -50,13 +54,14 @@ function ColorBar({
|
|||||||
|
|
||||||
// Update canvas when hue/luminance changes
|
// Update canvas when hue/luminance changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (colorBar && canvasRef.current) {
|
const bar = colorBarRef.current;
|
||||||
|
if (bar && canvasRef.current) {
|
||||||
smoothAnimation(() => {
|
smoothAnimation(() => {
|
||||||
colorBar.fill_color(hue, luminance);
|
bar.fill_color(hue, luminance);
|
||||||
refreshColorBar(canvasRef.current!, colorBar);
|
refreshColorBar(canvasRef.current!, bar);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [hue, luminance, colorBar]);
|
}, [hue, luminance]);
|
||||||
|
|
||||||
// Get measurements
|
// Get measurements
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -64,30 +69,38 @@ function ColorBar({
|
|||||||
setMeasurements(containerRef, setOrigin, setDimensions);
|
setMeasurements(containerRef, setOrigin, setDimensions);
|
||||||
}
|
}
|
||||||
|
|
||||||
return useResize(() =>
|
return onResize(() =>
|
||||||
setMeasurements(containerRef, setOrigin, setDimensions),
|
setMeasurements(containerRef, setOrigin, setDimensions),
|
||||||
);
|
);
|
||||||
}, [containerRef.current]);
|
}, [containerRef]);
|
||||||
|
|
||||||
// Resize color bar
|
// Resize color bar
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (containerRef.current && canvasRef.current && parentDimensions.x > 0) {
|
if (!containerRef.current || !canvasRef.current || parentDimensions.x <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
colorBarRef.current?.free();
|
||||||
|
|
||||||
const newHeight = containerRef.current.clientHeight;
|
const newHeight = containerRef.current.clientHeight;
|
||||||
const newWidth = parentDimensions.x - 54;
|
const newWidth = parentDimensions.x - 54;
|
||||||
const newColorBar = new colorlib.ColorBar(newWidth, newHeight);
|
const bar = new colorlib.ColorBar(newWidth, newHeight);
|
||||||
|
colorBarRef.current = bar;
|
||||||
|
|
||||||
setColorBar(newColorBar);
|
|
||||||
|
|
||||||
if (newColorBar) {
|
|
||||||
smoothAnimation(() => {
|
smoothAnimation(() => {
|
||||||
if (canvasRef.current) {
|
if (canvasRef.current) {
|
||||||
newColorBar.fill_color(hue, luminance);
|
bar.fill_color(hue, luminance);
|
||||||
refreshColorBar(canvasRef.current!, newColorBar);
|
refreshColorBar(canvasRef.current!, bar);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}, [parentDimensions]);
|
||||||
}
|
|
||||||
}, [containerRef.current, canvasRef.current, parentDimensions]);
|
// free on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
colorBarRef.current?.free();
|
||||||
|
colorBarRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.colorBarWrapper} ref={containerRef}>
|
<div className={styles.colorBarWrapper} ref={containerRef}>
|
||||||
@@ -95,15 +108,11 @@ function ColorBar({
|
|||||||
className={styles.colorBar}
|
className={styles.colorBar}
|
||||||
ref={sliderRef}
|
ref={sliderRef}
|
||||||
style={{
|
style={{
|
||||||
width: colorBar?.get_width(),
|
width: barWidth,
|
||||||
height: colorBar?.get_height(),
|
height: barHeight,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<canvas
|
<canvas ref={canvasRef} width={barWidth} height={barHeight} />
|
||||||
ref={canvasRef}
|
|
||||||
width={colorBar?.get_width()}
|
|
||||||
height={colorBar?.get_height()}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
.container {
|
.container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 25px 1fr 25px;
|
grid-template-columns: 25px 1fr 25px;
|
||||||
grid-template-rows: 50px 1fr 25px;
|
grid-template-rows: 1fr 25px;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
". preview ."
|
|
||||||
"leftGrip square rightGrip"
|
"leftGrip square rightGrip"
|
||||||
". bottomGrip ."
|
". bottomGrip ."
|
||||||
". bar .";
|
". bar .";
|
||||||
@@ -13,14 +12,20 @@
|
|||||||
grid-area: preview;
|
grid-area: preview;
|
||||||
height: 25px;
|
height: 25px;
|
||||||
margin-bottom: 15px;
|
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 {
|
.pickerSquare {
|
||||||
grid-area: square;
|
grid-area: square;
|
||||||
position: relative;
|
position: relative;
|
||||||
aspect-ratio: 1/1;
|
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 {
|
.pickerBar {
|
||||||
@@ -28,7 +33,49 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
height: 25px;
|
height: 25px;
|
||||||
margin-top: 15px;
|
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 {
|
.verticalGripLeft {
|
||||||
@@ -39,10 +86,6 @@
|
|||||||
grid-area: rightGrip;
|
grid-area: rightGrip;
|
||||||
}
|
}
|
||||||
|
|
||||||
.horizontalGrip {
|
|
||||||
grid-area: bottomGrip;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Color Square */
|
/* Color Square */
|
||||||
.colorSquareWrapper {
|
.colorSquareWrapper {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -94,16 +137,3 @@
|
|||||||
box-shadow 400ms;
|
box-shadow 400ms;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Square Grips */
|
|
||||||
.gripSlider {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grip {
|
|
||||||
position: absolute;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import * as colorlib from "colorlib";
|
import * as colorlib from "colorlib";
|
||||||
|
|
||||||
import type { ColorActions } from "@/hooks/color";
|
import type { ColorActions } from "@/hooks/color";
|
||||||
import { useResize } from "@/hooks/window";
|
import { onResize } from "@/hooks/window";
|
||||||
import { Direction } from "@/types";
|
import { Direction } from "@/types";
|
||||||
import type { CartesianSpace } from "@/types";
|
import type { CartesianSpace } from "@/types";
|
||||||
import { formatCssRgb, setMeasurements } from "@/util";
|
import { setMeasurements } from "@/util";
|
||||||
|
|
||||||
import ColorBar from "./ColorBar";
|
import ColorBar from "./ColorBar";
|
||||||
import styles from "./ColorPicker.module.css";
|
import styles from "./ColorPicker.module.css";
|
||||||
@@ -25,7 +25,7 @@ function ColorPicker({
|
|||||||
const hueRange = { min: 0, max: 359 };
|
const hueRange = { min: 0, max: 359 };
|
||||||
const lumRange = { min: 0, max: 1 };
|
const lumRange = { min: 0, max: 1 };
|
||||||
|
|
||||||
const [_origin, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
|
const [, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
|
||||||
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
|
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
|
||||||
|
|
||||||
// Get measurements
|
// Get measurements
|
||||||
@@ -34,26 +34,20 @@ function ColorPicker({
|
|||||||
setMeasurements(containerRef, setOrigin, setDimensions);
|
setMeasurements(containerRef, setOrigin, setDimensions);
|
||||||
}
|
}
|
||||||
|
|
||||||
return useResize(() => {
|
return onResize(() => {
|
||||||
setMeasurements(containerRef, setOrigin, setDimensions);
|
setMeasurements(containerRef, setOrigin, setDimensions);
|
||||||
});
|
});
|
||||||
}, [containerRef.current]);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container} ref={containerRef}>
|
<div className={styles.container} ref={containerRef}>
|
||||||
<div
|
|
||||||
className={styles.preview}
|
|
||||||
style={{
|
|
||||||
backgroundColor: formatCssRgb(color.hex),
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
<div className={styles.verticalGripLeft}>
|
<div className={styles.verticalGripLeft}>
|
||||||
<GripSlider
|
<GripSlider
|
||||||
direction={Direction.VERTICAL}
|
direction={Direction.VERTICAL}
|
||||||
value={color.hcl.l}
|
value={color.hcl.l}
|
||||||
setValue={actions.hcl.setL}
|
setValue={actions.hcl.setL}
|
||||||
valueRange={lumRange}
|
valueRange={lumRange}
|
||||||
arrowDirection="right"
|
position="left"
|
||||||
invert={true}
|
invert={true}
|
||||||
parentDimensions={dimensions}
|
parentDimensions={dimensions}
|
||||||
/>
|
/>
|
||||||
@@ -77,7 +71,7 @@ function ColorPicker({
|
|||||||
value={color.hcl.l}
|
value={color.hcl.l}
|
||||||
setValue={actions.hcl.setL}
|
setValue={actions.hcl.setL}
|
||||||
valueRange={lumRange}
|
valueRange={lumRange}
|
||||||
arrowDirection="left"
|
position="right"
|
||||||
invert={true}
|
invert={true}
|
||||||
parentDimensions={dimensions}
|
parentDimensions={dimensions}
|
||||||
/>
|
/>
|
||||||
@@ -88,7 +82,7 @@ function ColorPicker({
|
|||||||
value={color.hcl.h}
|
value={color.hcl.h}
|
||||||
setValue={actions.hcl.setH}
|
setValue={actions.hcl.setH}
|
||||||
valueRange={hueRange}
|
valueRange={hueRange}
|
||||||
arrowDirection="up"
|
position="bottom"
|
||||||
parentDimensions={dimensions}
|
parentDimensions={dimensions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useSmoothAnimation } from "@/hooks/animation";
|
|||||||
import type { HCLColorActions } from "@/hooks/color";
|
import type { HCLColorActions } from "@/hooks/color";
|
||||||
import { useCrosshair } from "@/hooks/crosshair";
|
import { useCrosshair } from "@/hooks/crosshair";
|
||||||
import { useScroll } from "@/hooks/scroll";
|
import { useScroll } from "@/hooks/scroll";
|
||||||
import { useResize } from "@/hooks/window";
|
import { onResize } from "@/hooks/window";
|
||||||
import type { CartesianSpace } from "@/types";
|
import type { CartesianSpace } from "@/types";
|
||||||
import { setMeasurements } from "@/util";
|
import { setMeasurements } from "@/util";
|
||||||
|
|
||||||
@@ -23,15 +23,13 @@ function ColorSquare({
|
|||||||
parentDimensions: CartesianSpace;
|
parentDimensions: CartesianSpace;
|
||||||
}) {
|
}) {
|
||||||
// State
|
// State
|
||||||
const [colorSquare, setColorSquare] = useState<colorlib.ColorSquare | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [origin, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
|
const [origin, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
|
||||||
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
|
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
|
||||||
|
|
||||||
// Refs
|
// Refs
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const colorSquareRef = useRef<colorlib.ColorSquare | null>(null);
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
const smoothAnimation = useSmoothAnimation();
|
const smoothAnimation = useSmoothAnimation();
|
||||||
@@ -56,18 +54,19 @@ function ColorSquare({
|
|||||||
|
|
||||||
// Update canvas when chroma changes
|
// Update canvas when chroma changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (colorSquare && canvasRef.current) {
|
const square = colorSquareRef.current;
|
||||||
|
if (square && canvasRef.current) {
|
||||||
smoothAnimation(() => {
|
smoothAnimation(() => {
|
||||||
colorSquare.fill_chroma(chroma);
|
square.fill_chroma(chroma);
|
||||||
refreshColorSquare(canvasRef.current!, colorSquare);
|
refreshColorSquare(canvasRef.current!, square);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [chroma, colorSquare]);
|
}, [chroma]);
|
||||||
|
|
||||||
// Add event listeners
|
// Add event listeners
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (canvasRef.current) addScrollListener();
|
if (canvasRef.current) addScrollListener();
|
||||||
}, []);
|
}, [addScrollListener]);
|
||||||
|
|
||||||
// Get measurements
|
// Get measurements
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -75,29 +74,37 @@ function ColorSquare({
|
|||||||
setMeasurements(containerRef, setOrigin, setDimensions);
|
setMeasurements(containerRef, setOrigin, setDimensions);
|
||||||
}
|
}
|
||||||
|
|
||||||
return useResize(() =>
|
return onResize(() =>
|
||||||
setMeasurements(containerRef, setOrigin, setDimensions),
|
setMeasurements(containerRef, setOrigin, setDimensions),
|
||||||
);
|
);
|
||||||
}, [containerRef.current, parentDimensions]);
|
}, [parentDimensions]);
|
||||||
|
|
||||||
// Resize square
|
// Resize square
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (containerRef.current && canvasRef.current && parentDimensions.x > 0) {
|
if (!containerRef.current || !canvasRef.current || parentDimensions.x <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
colorSquareRef.current?.free();
|
||||||
|
|
||||||
const newSize = parentDimensions.x - 54;
|
const newSize = parentDimensions.x - 54;
|
||||||
const newColorSquare = new colorlib.ColorSquare(newSize);
|
const square = new colorlib.ColorSquare(newSize);
|
||||||
|
colorSquareRef.current = square;
|
||||||
|
|
||||||
setColorSquare(newColorSquare);
|
|
||||||
|
|
||||||
if (newColorSquare) {
|
|
||||||
smoothAnimation(() => {
|
smoothAnimation(() => {
|
||||||
if (canvasRef.current) {
|
if (canvasRef.current) {
|
||||||
newColorSquare.fill_chroma(chroma);
|
square.fill_chroma(chroma);
|
||||||
refreshColorSquare(canvasRef.current, newColorSquare);
|
refreshColorSquare(canvasRef.current, square);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}, [containerRef, canvasRef, parentDimensions]);
|
||||||
}
|
|
||||||
}, [containerRef.current, canvasRef.current, parentDimensions]);
|
// free on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
colorSquareRef.current?.free();
|
||||||
|
colorSquareRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.colorSquareWrapper} ref={containerRef}>
|
<div className={styles.colorSquareWrapper} ref={containerRef}>
|
||||||
@@ -105,14 +112,14 @@ function ColorSquare({
|
|||||||
className={styles.colorSquare}
|
className={styles.colorSquare}
|
||||||
ref={crosshairRef}
|
ref={crosshairRef}
|
||||||
style={{
|
style={{
|
||||||
width: colorSquare?.get_size(),
|
width: colorSquareRef.current?.get_size(),
|
||||||
height: colorSquare?.get_size(),
|
height: colorSquareRef.current?.get_size(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
width={colorSquare?.get_size()}
|
width={colorSquareRef.current?.get_size()}
|
||||||
height={colorSquare?.get_size()}
|
height={colorSquareRef.current?.get_size()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
|
|
||||||
import * as colorlib from "colorlib";
|
import * as colorlib from "colorlib";
|
||||||
|
|
||||||
import { useResize } from "@/hooks/window";
|
import { useContrastToken } from "@/hooks/contrast";
|
||||||
|
import { onResize } from "@/hooks/window";
|
||||||
import type { CartesianSpace } from "@/types";
|
import type { CartesianSpace } from "@/types";
|
||||||
import { formatCssRgb, setMeasurements, valueToPosition } from "@/util";
|
import { formatCssRgb, setMeasurements, valueToPosition } from "@/util";
|
||||||
|
|
||||||
@@ -19,23 +20,20 @@ export function SquareCrosshair({
|
|||||||
hex: colorlib.Hex;
|
hex: colorlib.Hex;
|
||||||
parentDimensions: CartesianSpace;
|
parentDimensions: CartesianSpace;
|
||||||
}) {
|
}) {
|
||||||
const [_origin, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
|
const [, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
|
||||||
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
|
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
|
||||||
const [darkCrosshairs, setDarkCrosshairs] = useState(true);
|
const crosshairColor = { dark: "black", light: "white" };
|
||||||
|
const token = useContrastToken(() => luminance);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const lumRange = { min: 0, max: 1 };
|
const lumRange = { min: 0, max: 1 };
|
||||||
const hueRange = { min: 0, max: 359 };
|
const hueRange = { min: 0, max: 359 };
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setDarkCrosshairs(luminance > 0.5);
|
|
||||||
}, [luminance]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMeasurements(containerRef, setOrigin, setDimensions);
|
setMeasurements(containerRef, setOrigin, setDimensions);
|
||||||
return useResize(() =>
|
return onResize(() =>
|
||||||
setMeasurements(containerRef, setOrigin, setDimensions),
|
setMeasurements(containerRef, setOrigin, setDimensions),
|
||||||
);
|
);
|
||||||
}, [containerRef.current, parentDimensions]);
|
}, [containerRef, parentDimensions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.crosshairWrapper} ref={containerRef}>
|
<div className={styles.crosshairWrapper} ref={containerRef}>
|
||||||
@@ -44,8 +42,8 @@ export function SquareCrosshair({
|
|||||||
style={{
|
style={{
|
||||||
width: 1,
|
width: 1,
|
||||||
height: dimensions.y,
|
height: dimensions.y,
|
||||||
backgroundColor: darkCrosshairs ? "black" : "white",
|
backgroundColor: crosshairColor[token],
|
||||||
boxShadow: darkCrosshairs ? "0 0 2px black" : "0 0 2px white",
|
boxShadow: `0 0 2px ${crosshairColor[token]}`,
|
||||||
left: valueToPosition(hue, dimensions.x - 1, hueRange),
|
left: valueToPosition(hue, dimensions.x - 1, hueRange),
|
||||||
top: 0,
|
top: 0,
|
||||||
}}
|
}}
|
||||||
@@ -55,8 +53,8 @@ export function SquareCrosshair({
|
|||||||
style={{
|
style={{
|
||||||
width: dimensions.x,
|
width: dimensions.x,
|
||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: darkCrosshairs ? "black" : "white",
|
backgroundColor: crosshairColor[token],
|
||||||
boxShadow: darkCrosshairs ? "0 0 2px black" : "0 0 2px white",
|
boxShadow: `0 0 2px ${crosshairColor[token]}`,
|
||||||
left: 0,
|
left: 0,
|
||||||
top: valueToPosition(1 - luminance, dimensions.y - 1, lumRange),
|
top: valueToPosition(1 - luminance, dimensions.y - 1, lumRange),
|
||||||
}}
|
}}
|
||||||
@@ -64,8 +62,8 @@ export function SquareCrosshair({
|
|||||||
<div
|
<div
|
||||||
className={styles.crossEye}
|
className={styles.crossEye}
|
||||||
style={{
|
style={{
|
||||||
borderColor: darkCrosshairs ? "black" : "white",
|
borderColor: crosshairColor[token],
|
||||||
boxShadow: darkCrosshairs ? "0 0 1px black" : "0 0 1px white",
|
boxShadow: `0 0 1px ${crosshairColor[token]}`,
|
||||||
backgroundColor: formatCssRgb(hex),
|
backgroundColor: formatCssRgb(hex),
|
||||||
top: valueToPosition(1 - luminance, dimensions.y - 1, lumRange) - 6,
|
top: valueToPosition(1 - luminance, dimensions.y - 1, lumRange) - 6,
|
||||||
left: valueToPosition(hue, dimensions.x - 1, hueRange) - 6,
|
left: valueToPosition(hue, dimensions.x - 1, hueRange) - 6,
|
||||||
@@ -86,22 +84,19 @@ export function BarCrosshair({
|
|||||||
hex: colorlib.Hex;
|
hex: colorlib.Hex;
|
||||||
parentDimensions: CartesianSpace;
|
parentDimensions: CartesianSpace;
|
||||||
}) {
|
}) {
|
||||||
const [_origin, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
|
const [, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
|
||||||
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
|
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
|
||||||
const [darkCrosshairs, setDarkCrosshairs] = useState(true);
|
const crosshairColor = { dark: "black", light: "white" };
|
||||||
|
const token = useContrastToken(() => luminance);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const chromaRange = { min: 0, max: 1 };
|
const chromaRange = { min: 0, max: 1 };
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setDarkCrosshairs(luminance > 0.5);
|
|
||||||
}, [luminance]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMeasurements(containerRef, setOrigin, setDimensions);
|
setMeasurements(containerRef, setOrigin, setDimensions);
|
||||||
return useResize(() =>
|
return onResize(() =>
|
||||||
setMeasurements(containerRef, setOrigin, setDimensions),
|
setMeasurements(containerRef, setOrigin, setDimensions),
|
||||||
);
|
);
|
||||||
}, [containerRef.current, parentDimensions]);
|
}, [containerRef, parentDimensions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.crosshairWrapper} ref={containerRef}>
|
<div className={styles.crosshairWrapper} ref={containerRef}>
|
||||||
@@ -110,8 +105,8 @@ export function BarCrosshair({
|
|||||||
style={{
|
style={{
|
||||||
width: 1,
|
width: 1,
|
||||||
height: dimensions.y,
|
height: dimensions.y,
|
||||||
backgroundColor: darkCrosshairs ? "black" : "white",
|
backgroundColor: crosshairColor[token],
|
||||||
boxShadow: darkCrosshairs ? "0 0 2px black" : "0 0 2px white",
|
boxShadow: `0 0 2px ${crosshairColor[token]}`,
|
||||||
left: valueToPosition(chroma, dimensions.x - 1, chromaRange),
|
left: valueToPosition(chroma, dimensions.x - 1, chromaRange),
|
||||||
top: 0,
|
top: 0,
|
||||||
}}
|
}}
|
||||||
@@ -119,8 +114,8 @@ export function BarCrosshair({
|
|||||||
<div
|
<div
|
||||||
className={styles.crossEye}
|
className={styles.crossEye}
|
||||||
style={{
|
style={{
|
||||||
borderColor: darkCrosshairs ? "black" : "white",
|
borderColor: crosshairColor[token],
|
||||||
boxShadow: darkCrosshairs ? "0 0 1px black" : "0 0 1px white",
|
boxShadow: `0 0 1px ${crosshairColor[token]}`,
|
||||||
backgroundColor: formatCssRgb(hex),
|
backgroundColor: formatCssRgb(hex),
|
||||||
top: 6,
|
top: 6,
|
||||||
left: valueToPosition(chroma, dimensions.x - 1, chromaRange) - 6,
|
left: valueToPosition(chroma, dimensions.x - 1, chromaRange) - 6,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { GripHorizontal, GripVertical } from "lucide-react";
|
||||||
|
|
||||||
import type { Setter } from "@/hooks/color";
|
import type { Setter } from "@/hooks/color";
|
||||||
import { useSlider } from "@/hooks/slider";
|
import { useSlider } from "@/hooks/slider";
|
||||||
import { useResize } from "@/hooks/window";
|
import { onResize } from "@/hooks/window";
|
||||||
import { Direction } from "@/types";
|
import { Direction } from "@/types";
|
||||||
import type { CartesianSpace, Range } from "@/types";
|
import type { CartesianSpace, Range } from "@/types";
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +20,7 @@ function GripSlider({
|
|||||||
value,
|
value,
|
||||||
setValue,
|
setValue,
|
||||||
valueRange,
|
valueRange,
|
||||||
arrowDirection,
|
position,
|
||||||
invert = false,
|
invert = false,
|
||||||
parentDimensions,
|
parentDimensions,
|
||||||
}: {
|
}: {
|
||||||
@@ -26,7 +28,7 @@ function GripSlider({
|
|||||||
value: number;
|
value: number;
|
||||||
setValue: Setter;
|
setValue: Setter;
|
||||||
valueRange: Range;
|
valueRange: Range;
|
||||||
arrowDirection: "up" | "left" | "right";
|
position: "bottom" | "right" | "left";
|
||||||
invert?: boolean;
|
invert?: boolean;
|
||||||
parentDimensions: CartesianSpace;
|
parentDimensions: CartesianSpace;
|
||||||
}) {
|
}) {
|
||||||
@@ -49,53 +51,21 @@ function GripSlider({
|
|||||||
setMeasurements(sliderRef, setOrigin, setDimensions);
|
setMeasurements(sliderRef, setOrigin, setDimensions);
|
||||||
}
|
}
|
||||||
|
|
||||||
return useResize(() =>
|
return onResize(() => setMeasurements(sliderRef, setOrigin, setDimensions));
|
||||||
setMeasurements(sliderRef, setOrigin, setDimensions),
|
}, [sliderRef, parentDimensions]);
|
||||||
);
|
|
||||||
}, [sliderRef.current, parentDimensions]);
|
|
||||||
|
|
||||||
const upArrowStyle = {
|
const isVertical = direction === Direction.VERTICAL;
|
||||||
borderLeft: "12px solid transparent",
|
|
||||||
borderRight: "12px solid transparent",
|
|
||||||
borderBottom: "25px solid black",
|
|
||||||
};
|
|
||||||
|
|
||||||
const leftArrowStyle = {
|
|
||||||
borderTop: "12px solid transparent",
|
|
||||||
borderBottom: "12px solid transparent",
|
|
||||||
borderRight: "25px solid black",
|
|
||||||
};
|
|
||||||
|
|
||||||
const rightArrowStyle = {
|
|
||||||
borderTop: "12px solid transparent",
|
|
||||||
borderBottom: "12px solid transparent",
|
|
||||||
borderLeft: "25px solid black",
|
|
||||||
};
|
|
||||||
|
|
||||||
const arrowStyle = (function () {
|
|
||||||
switch (arrowDirection) {
|
|
||||||
case "up":
|
|
||||||
return upArrowStyle;
|
|
||||||
case "left":
|
|
||||||
return leftArrowStyle;
|
|
||||||
case "right":
|
|
||||||
return rightArrowStyle;
|
|
||||||
default:
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.gripSlider} ref={sliderRef}>
|
<div className={styles.gripSlider} ref={sliderRef}>
|
||||||
<div
|
<div
|
||||||
className={styles.grip}
|
className={styles.grip}
|
||||||
style={{
|
style={{
|
||||||
...arrowStyle,
|
|
||||||
cursor: isDragging ? "grabbing" : "grab",
|
cursor: isDragging ? "grabbing" : "grab",
|
||||||
top: chooseValueByDirection(
|
top: chooseValueByDirection(
|
||||||
direction,
|
direction,
|
||||||
0,
|
6,
|
||||||
-12 +
|
-17 +
|
||||||
valueToPosition(
|
valueToPosition(
|
||||||
valueRange.max - value,
|
valueRange.max - value,
|
||||||
dimensions.y - 1,
|
dimensions.y - 1,
|
||||||
@@ -104,11 +74,24 @@ function GripSlider({
|
|||||||
),
|
),
|
||||||
left: chooseValueByDirection(
|
left: chooseValueByDirection(
|
||||||
direction,
|
direction,
|
||||||
-12 + valueToPosition(value, dimensions.x - 1, valueRange),
|
-16 + valueToPosition(value, dimensions.x - 1, valueRange),
|
||||||
0,
|
(() => {
|
||||||
|
if (position === "right") {
|
||||||
|
return 6;
|
||||||
|
} else if (position === "left") {
|
||||||
|
return -4;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
})(),
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{isVertical ? (
|
||||||
|
<GripVertical size={24} strokeWidth={3} />
|
||||||
|
) : (
|
||||||
|
<GripHorizontal size={24} strokeWidth={3} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,12 @@
|
|||||||
max-height: 94px;
|
max-height: 94px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
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 {
|
.componentWrapper {
|
||||||
@@ -22,7 +27,6 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
border-top: 1px solid #7a7a7a;
|
border-top: 1px solid #7a7a7a;
|
||||||
border-bottom: 1px solid #7a7a7a;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.componentWrapper:first-of-type {
|
.componentWrapper:first-of-type {
|
||||||
@@ -37,7 +41,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-right: 2px solid #7a7a7a;
|
border-right: 1px solid #7a7a7a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section:last-of-type {
|
.section:last-of-type {
|
||||||
@@ -65,7 +69,7 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: #aaa;
|
background-color: #c1c1c1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +78,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -81,6 +88,9 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
transition-property: background-color;
|
||||||
|
transition-duration: 150ms;
|
||||||
|
transition-timing-function: ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:hover {
|
.button:hover {
|
||||||
@@ -111,9 +121,14 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
border: 2px solid #7a7a7a;
|
background-color: #f7f7f7;
|
||||||
|
border: 1px solid #c9c9c9;
|
||||||
|
border-radius: 4px;
|
||||||
height: 25px;
|
height: 25px;
|
||||||
max-width: 150px;
|
max-width: 150px;
|
||||||
|
box-shadow:
|
||||||
|
rgba(0, 0, 0, 0.1) 0px 2px 4px,
|
||||||
|
rgba(0, 0, 0, 0.15) 0px 2px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hexLabel {
|
.hexLabel {
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ function ColorValues({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.colorValuesWrapper} ref={wrapperRef}>
|
<div className={styles.colorValuesWrapper} ref={wrapperRef}>
|
||||||
|
<HexEditor
|
||||||
|
color={color.hex}
|
||||||
|
actions={actions.hex}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
<SpaceEditor
|
<SpaceEditor
|
||||||
space="HCL"
|
space="HCL"
|
||||||
color={color.hcl}
|
color={color.hcl}
|
||||||
@@ -63,11 +68,6 @@ function ColorValues({
|
|||||||
actions={actions.rgb}
|
actions={actions.rgb}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
/>
|
/>
|
||||||
<HexEditor
|
|
||||||
color={color.hex}
|
|
||||||
actions={actions.hex}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-5
@@ -49,11 +49,12 @@ describe("hex editor tests", () => {
|
|||||||
cy.get("@color").should("have.text", "000000");
|
cy.get("@color").should("have.text", "000000");
|
||||||
|
|
||||||
cy.get("@value").blur();
|
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");
|
cy.get("@color").should("have.text", "000000");
|
||||||
|
|
||||||
// Type a new value
|
// 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("@value").should("have.value", "");
|
||||||
cy.get("@color").should("have.text", "000000");
|
cy.get("@color").should("have.text", "000000");
|
||||||
|
|
||||||
@@ -62,11 +63,11 @@ describe("hex editor tests", () => {
|
|||||||
cy.get("@color").should("have.text", "000000");
|
cy.get("@color").should("have.text", "000000");
|
||||||
|
|
||||||
cy.get("@value").type("c");
|
cy.get("@value").type("c");
|
||||||
cy.get("@value").should("have.value", "#ABC");
|
cy.get("@value").should("have.value", "abc");
|
||||||
cy.get("@color").should("have.text", "AABBCC");
|
cy.get("@color").should("have.text", "000000");
|
||||||
|
|
||||||
cy.get("@value").blur();
|
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");
|
cy.get("@color").should("have.text", "AABBCC");
|
||||||
|
|
||||||
// Invalid blur resets to last valid color
|
// Invalid blur resets to last valid color
|
||||||
+2
-2
@@ -102,8 +102,8 @@ describe("space editor tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
cy.dataCy("rgb-value").should("have.text", "RGB (16, 75, 74)");
|
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("hsv-value").should("have.text", "HSV (179, 0.78, 0.29)");
|
||||||
cy.dataCy("hcl-value").should("have.text", "HCL (178, 0.78, 0.25)");
|
cy.dataCy("hcl-value").should("have.text", "HCL (179, 0.78, 0.25)");
|
||||||
cy.dataCy("hex-value").should("have.text", "HEX: #104B4A");
|
cy.dataCy("hex-value").should("have.text", "HEX: #104B4A");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+3
-3
@@ -55,7 +55,7 @@ describe("component editor tests", () => {
|
|||||||
cy.dataCy("R-slider")
|
cy.dataCy("R-slider")
|
||||||
.click()
|
.click()
|
||||||
.dataCy("R-slider-bar")
|
.dataCy("R-slider-bar")
|
||||||
.should("have.css", "width", "138px")
|
.should("have.css", "width", "140px")
|
||||||
.dataCy("R-value-input")
|
.dataCy("R-value-input")
|
||||||
.should("have.value", "127");
|
.should("have.value", "127");
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ describe("component editor tests", () => {
|
|||||||
.type("100")
|
.type("100")
|
||||||
.should("have.value", "100")
|
.should("have.value", "100")
|
||||||
.dataCy("R-slider-bar")
|
.dataCy("R-slider-bar")
|
||||||
.should("have.css", "width", "109px");
|
.should("have.css", "width", "110px");
|
||||||
|
|
||||||
// Scrolling input should update value
|
// Scrolling input should update value
|
||||||
cy.dataCy("R-value-input")
|
cy.dataCy("R-value-input")
|
||||||
@@ -134,7 +134,7 @@ describe("component editor tests", () => {
|
|||||||
cy.dataCy("R-slider")
|
cy.dataCy("R-slider")
|
||||||
.click()
|
.click()
|
||||||
.dataCy("R-slider-bar")
|
.dataCy("R-slider-bar")
|
||||||
.should("have.css", "width", "138px")
|
.should("have.css", "width", "140px")
|
||||||
.dataCy("R-value-input")
|
.dataCy("R-value-input")
|
||||||
.should("have.value", "127");
|
.should("have.value", "127");
|
||||||
|
|
||||||
@@ -1,19 +1,15 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { ChangeEvent, KeyboardEvent, RefObject } from "react";
|
import type { ChangeEvent, KeyboardEvent, RefObject } from "react";
|
||||||
|
|
||||||
import {
|
|
||||||
faChevronLeft,
|
|
||||||
faChevronRight,
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import * as colorlib from "colorlib";
|
import * as colorlib from "colorlib";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
import type { HexColorActions } from "@/hooks/color";
|
import type { HexColorActions } from "@/hooks/color";
|
||||||
|
import { extractHexValue, formatHexString } from "@/hooks/hex";
|
||||||
import { useScroll } from "@/hooks/scroll";
|
import { useScroll } from "@/hooks/scroll";
|
||||||
import { useSlider } from "@/hooks/slider";
|
import { useSlider } from "@/hooks/slider";
|
||||||
import { useResize } from "@/hooks/window";
|
import { onResize } from "@/hooks/window";
|
||||||
import type { CartesianSpace, Range, Setter, Timeout } from "@/types";
|
import type { CartesianSpace, Range, Setter, Timeout } from "@/types";
|
||||||
import { Direction } from "@/types";
|
import { Direction } from "@/types";
|
||||||
import { minmax, roundTo, setMeasurements, valueToPosition } from "@/util";
|
import { minmax, roundTo, setMeasurements, valueToPosition } from "@/util";
|
||||||
@@ -89,9 +85,7 @@ export function ValueEditor({
|
|||||||
// Set component dimensions for slider hook
|
// Set component dimensions for slider hook
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMeasurements(sliderRef, setOrigin, setDimensions);
|
setMeasurements(sliderRef, setOrigin, setDimensions);
|
||||||
return useResize(() =>
|
return onResize(() => setMeasurements(sliderRef, setOrigin, setDimensions));
|
||||||
setMeasurements(sliderRef, setOrigin, setDimensions),
|
|
||||||
);
|
|
||||||
}, [sliderRef, setOrigin, setDimensions]);
|
}, [sliderRef, setOrigin, setDimensions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -209,7 +203,7 @@ function Button({
|
|||||||
}) {
|
}) {
|
||||||
const isIncrease = direction === "increase";
|
const isIncrease = direction === "increase";
|
||||||
const label = isIncrease ? "Increase" : "Decrease";
|
const label = isIncrease ? "Increase" : "Decrease";
|
||||||
const icon = isIncrease ? faChevronRight : faChevronLeft;
|
const Icon = isIncrease ? ChevronRight : ChevronLeft;
|
||||||
const dataCy = `${componentSymbol}-${isIncrease ? "increment" : "decrement"}-button`;
|
const dataCy = `${componentSymbol}-${isIncrease ? "increment" : "decrement"}-button`;
|
||||||
|
|
||||||
const step = isIncrease ? 1 : -1;
|
const step = isIncrease ? 1 : -1;
|
||||||
@@ -234,7 +228,7 @@ function Button({
|
|||||||
data-cy={dataCy}
|
data-cy={dataCy}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={icon} transform="shrink-2 down-1" />
|
<Icon size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -338,8 +332,8 @@ function useLongPressRepeat(
|
|||||||
onMouseLeave: stop,
|
onMouseLeave: stop,
|
||||||
onTouchStart: start,
|
onTouchStart: start,
|
||||||
onTouchEnd: stop,
|
onTouchEnd: stop,
|
||||||
// Intentional 'any' to avoid overly complex typing
|
// @ts-expect-error: Avoid overly complex typing
|
||||||
onContextMenu: (e: Event | any) => e.preventDefault(),
|
onContextMenu: (e) => e.preventDefault(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,30 +341,6 @@ function useLongPressRepeat(
|
|||||||
// Hex Editor //
|
// 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({
|
export function HexEditor({
|
||||||
color,
|
color,
|
||||||
actions,
|
actions,
|
||||||
@@ -382,24 +352,34 @@ export function HexEditor({
|
|||||||
}) {
|
}) {
|
||||||
const [inputValue, setInputValue] = useState(formatHexString(color));
|
const [inputValue, setInputValue] = useState(formatHexString(color));
|
||||||
const [isShortHex, setIsShortHex] = useState(false);
|
const [isShortHex, setIsShortHex] = useState(false);
|
||||||
|
const isFocused = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isFocused.current) {
|
||||||
setInputValue(formatHexString(color, isShortHex));
|
setInputValue(formatHexString(color, isShortHex));
|
||||||
}, [color]);
|
}
|
||||||
|
}, [color, isShortHex]);
|
||||||
|
|
||||||
|
const onFocus = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
isFocused.current = true;
|
||||||
|
e.target.select();
|
||||||
|
};
|
||||||
|
|
||||||
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
|
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
setInputValue(value);
|
setInputValue(value);
|
||||||
|
};
|
||||||
|
|
||||||
const hex = extractHexValue(value);
|
const onBlur = () => {
|
||||||
|
isFocused.current = false;
|
||||||
|
const hex = extractHexValue(inputValue);
|
||||||
if (hex) {
|
if (hex) {
|
||||||
setIsShortHex(hex.length === 3);
|
setIsShortHex(hex.length === 3);
|
||||||
const newColor = colorlib.Hex.from_code(hex);
|
const newColor = colorlib.Hex.from_code(hex);
|
||||||
actions.setHex(newColor);
|
actions.setHex(newColor);
|
||||||
|
setInputValue(formatHexString(newColor, isShortHex));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const onBlur = () => {
|
|
||||||
setInputValue(formatHexString(color));
|
setInputValue(formatHexString(color));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -421,7 +401,7 @@ export function HexEditor({
|
|||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onFocus={(e) => e.target.select()}
|
onFocus={onFocus}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,466 @@
|
|||||||
/* Large - Landscape Tablets / Desktops */
|
.paletteEditor {
|
||||||
/* Medium - Portrait Tablets */
|
height: 100%;
|
||||||
/* Horizontal layout, vertically scrolling picker and palette content */
|
width: 100%;
|
||||||
@media (min-width: 992px),
|
display: flex;
|
||||||
(min-width: 568px) and (max-width: 991px) and (orientation: portrait) {
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Medium - Landscape Phones */
|
.actionBar {
|
||||||
/* Horizontal layout, side menu, vertical tabbed picker */
|
padding: 10px 11px;
|
||||||
@media (min-width: 568px) and (max-width: 991px) and (orientation: landscape) {
|
border-bottom: 1px solid #c9c9c9;
|
||||||
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Small - Portrait Phones*/
|
.actionBar .actionButton {
|
||||||
/* Vertical layout, side menu, horizontal tabbed picker */
|
cursor: pointer;
|
||||||
@media (max-width: 567px) {
|
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;
|
||||||
|
margin: 32px;
|
||||||
|
display: grid;
|
||||||
|
border-radius: 8px;
|
||||||
|
grid-template-columns: 1fr 1fr 4fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
grid-template-areas:
|
||||||
|
"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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import { useReducer } from "react";
|
||||||
|
|
||||||
|
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 initialPickerState = {
|
||||||
|
color: Color.from_hex("000"),
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div style={{ width: "100%", height: 400 }}>
|
||||||
|
<PaletteEditor
|
||||||
|
pickerColor={state.color.hex}
|
||||||
|
setPickerColor={actions.hex.setHex}
|
||||||
|
initialCardState={initialCardState}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<HexEditor color={state.color.hex} actions={actions.hex} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("can edit the palette header", () => {
|
||||||
|
cy.mount(<TestWrapper />);
|
||||||
|
|
||||||
|
cy.dataCy("card-header").as("header");
|
||||||
|
cy.dataCy("card-name").as("name").contains("New Palette");
|
||||||
|
|
||||||
|
// Edit the name
|
||||||
|
cy.dataCy("card-name-edit").as("edit").click();
|
||||||
|
cy.dataCy("card-name-input")
|
||||||
|
.as("input")
|
||||||
|
.should("exist")
|
||||||
|
.should("be.focused")
|
||||||
|
.type("Summer Colors")
|
||||||
|
.wait(0);
|
||||||
|
|
||||||
|
cy.dataCy("card-name-confirm").as("confirm").click();
|
||||||
|
cy.get("@name").contains("Summer Colors");
|
||||||
|
|
||||||
|
// Edit, then cancel
|
||||||
|
cy.get("@edit").click();
|
||||||
|
cy.get("@input").type("Winter Colors").wait(0);
|
||||||
|
|
||||||
|
cy.dataCy("card-name-cancel").as("cancel").click();
|
||||||
|
cy.get("@name").contains("Summer Colors");
|
||||||
|
|
||||||
|
// Enter should confirm
|
||||||
|
cy.get("@edit").click();
|
||||||
|
cy.get("@input").type("Winter Colors").type("{enter}").wait(0);
|
||||||
|
cy.get("@name").contains("Winter Colors");
|
||||||
|
|
||||||
|
// Escape should cancel
|
||||||
|
cy.get("@edit").click();
|
||||||
|
cy.get("@input").type("Fall Colors").type("{esc}").wait(0);
|
||||||
|
cy.get("@name").contains("Winter Colors");
|
||||||
|
|
||||||
|
// Input contents should reset
|
||||||
|
cy.get("@edit").click();
|
||||||
|
cy.get("@input").should("contain.text", "Winter Colors");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can perform actions in normal mode", () => {
|
||||||
|
cy.mount(<TestWrapper />);
|
||||||
|
|
||||||
|
// Empty palette renders no rows
|
||||||
|
cy.dataCy("palette").as("palette").contains("No colors in palette.");
|
||||||
|
cy.dataCy("delete").should("be.disabled");
|
||||||
|
cy.dataCy("duplicate").should("be.disabled");
|
||||||
|
|
||||||
|
// Add a color
|
||||||
|
cy.dataCy("add").as("add").click();
|
||||||
|
cy.dataCy("palette-row-0").as("row0").should("exist");
|
||||||
|
cy.get("@row0").contains("New Color");
|
||||||
|
cy.get("@row0").contains("#000000");
|
||||||
|
|
||||||
|
// Select the color
|
||||||
|
cy.get("@row0").click().should("have.attr", "aria-selected", "true");
|
||||||
|
cy.dataCy("selected-preview")
|
||||||
|
.as("preview")
|
||||||
|
.should("have.css", "background-color", "rgb(0, 0, 0)");
|
||||||
|
|
||||||
|
// Change the color name and value
|
||||||
|
cy.dataCy("palette-row-name-0-edit").click();
|
||||||
|
cy.dataCy("palette-row-name-0-input").type("Red{enter}").wait(0);
|
||||||
|
cy.dataCy("palette-row-hex-0-edit").click();
|
||||||
|
cy.dataCy("palette-row-hex-0-input").type("F00{enter}").wait(0);
|
||||||
|
cy.get("@row0").contains("Red");
|
||||||
|
cy.get("@row0").contains("#FF0000");
|
||||||
|
cy.get("@preview").should("have.css", "background-color", "rgb(255, 0, 0)");
|
||||||
|
|
||||||
|
// Add a second color
|
||||||
|
cy.get("@add").click();
|
||||||
|
cy.dataCy("palette-row-1").as("row1").should("exist");
|
||||||
|
|
||||||
|
// Selecting the second deselects the first
|
||||||
|
cy.get("@row1").click().should("have.attr", "aria-selected", "true");
|
||||||
|
cy.get("@row0").should("have.attr", "aria-selected", "false");
|
||||||
|
|
||||||
|
// Select none
|
||||||
|
cy.get("@row1").click("top");
|
||||||
|
|
||||||
|
// Delete and Duplicate should be disabled
|
||||||
|
cy.dataCy("delete").as("delete").should("be.disabled");
|
||||||
|
cy.dataCy("duplicate").as("duplicate").should("be.disabled");
|
||||||
|
|
||||||
|
// Delete the first row
|
||||||
|
cy.get("@row0").click();
|
||||||
|
cy.get("@delete").click();
|
||||||
|
|
||||||
|
// Second row becomes first
|
||||||
|
cy.get("@row0").contains("New Color");
|
||||||
|
|
||||||
|
// Duplicate a color
|
||||||
|
cy.get("@add").click();
|
||||||
|
cy.get("@row0").click();
|
||||||
|
cy.dataCy("palette-row-name-0-edit").click();
|
||||||
|
cy.dataCy("palette-row-name-0-input").type("Red{enter}").wait(0);
|
||||||
|
cy.dataCy("palette-row-hex-0-edit").click();
|
||||||
|
cy.dataCy("palette-row-hex-0-input").type("F00{enter}").wait(0);
|
||||||
|
|
||||||
|
// Dupliated color appears below selected row
|
||||||
|
cy.get("@duplicate").click();
|
||||||
|
cy.get("@row0").contains("#FF0000");
|
||||||
|
cy.get("@row1").contains("#FF0000");
|
||||||
|
cy.dataCy("palette-row-2").as("row2").contains("#000000");
|
||||||
|
|
||||||
|
// Undo removes duplicate
|
||||||
|
cy.dataCy("redo").as("redo").should("be.disabled");
|
||||||
|
cy.dataCy("undo").as("undo").should("be.enabled").click();
|
||||||
|
|
||||||
|
cy.get("@row0").contains("#FF0000");
|
||||||
|
cy.get("@row1").contains("#000000");
|
||||||
|
|
||||||
|
// Redo adds duplicate back
|
||||||
|
cy.get("@redo").click();
|
||||||
|
cy.get("@row0").contains("#FF0000");
|
||||||
|
cy.get("@row1").contains("#FF0000");
|
||||||
|
cy.get("@row2").contains("#000000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.only("can manually sync picker and palette", () => {
|
||||||
|
cy.mount(<TestWrapper initialCardState={defaultPaletteCardState} />);
|
||||||
|
|
||||||
|
cy.dataCy("hex-value-input").as("hex");
|
||||||
|
cy.dataCy("picker-preview").as("picker");
|
||||||
|
cy.dataCy("selected-preview").as("palette");
|
||||||
|
cy.dataCy("palette-row-0").as("row0");
|
||||||
|
cy.dataCy("palette-row-1").as("row1");
|
||||||
|
cy.dataCy("palette-row-2").as("row2");
|
||||||
|
|
||||||
|
// Ensure picker and preview colors are set to default
|
||||||
|
cy.get("@hex").should("have.value", "#000000");
|
||||||
|
cy.get("@picker").should("have.css", "background-color", "rgb(0, 0, 0)");
|
||||||
|
|
||||||
|
// Clicking the picker when no colors are selected does nothing
|
||||||
|
cy.get("@picker").click();
|
||||||
|
|
||||||
|
// Select a color and sync it to the picker
|
||||||
|
cy.get("@row1").click();
|
||||||
|
cy.get("@palette").should("have.css", "background-color", "rgb(0, 255, 0)");
|
||||||
|
cy.get("@picker")
|
||||||
|
.click()
|
||||||
|
.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("@palette").click();
|
||||||
|
cy.get("@row0").contains("#FFFF00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can automatically sync picker and palette", () => {
|
||||||
|
cy.clock();
|
||||||
|
cy.mount(<TestWrapper initialCardState={defaultPaletteCardState} />);
|
||||||
|
|
||||||
|
cy.dataCy("undo").as("undo");
|
||||||
|
cy.dataCy("redo").as("redo");
|
||||||
|
cy.dataCy("sync").as("sync");
|
||||||
|
cy.dataCy("hex-value-input").as("hex");
|
||||||
|
cy.dataCy("picker-preview").as("picker");
|
||||||
|
cy.dataCy("selected-preview").as("palette");
|
||||||
|
cy.dataCy("palette-row-0").as("row0");
|
||||||
|
cy.dataCy("palette-row-1").as("row1");
|
||||||
|
cy.dataCy("palette-row-2").as("row2");
|
||||||
|
|
||||||
|
// Enable color sync
|
||||||
|
cy.get("@sync").click().should("have.attr", "aria-pressed", "true");
|
||||||
|
|
||||||
|
// No color selected, all previews are default
|
||||||
|
cy.get("@hex").should("have.value", "#000000");
|
||||||
|
cy.get("@picker").should("have.css", "background-color", "rgb(0, 0, 0)");
|
||||||
|
|
||||||
|
// Select a color, picker should sync
|
||||||
|
cy.get("@row1").click();
|
||||||
|
cy.get("@palette").should("have.css", "background-color", "rgb(0, 255, 0)");
|
||||||
|
cy.get("@picker").should("have.css", "background-color", "rgb(0, 255, 0)");
|
||||||
|
cy.get("@hex").should("have.value", "#00FF00");
|
||||||
|
|
||||||
|
// Change picker color, palette should sync
|
||||||
|
cy.get("@hex").type("#FF00FF{esc}").wait(0);
|
||||||
|
cy.get("@palette").should("have.css", "background-color", "rgb(255, 0, 255)");
|
||||||
|
cy.get("@picker").should("have.css", "background-color", "rgb(255, 0, 255)");
|
||||||
|
cy.get("@row1").contains("#FF00FF");
|
||||||
|
|
||||||
|
// Turning on sync mode should set picker to selected palette color
|
||||||
|
cy.get("@sync").click();
|
||||||
|
cy.get("@row2").click();
|
||||||
|
cy.get("@sync").click();
|
||||||
|
cy.get("@picker").should("have.css", "background-color", "rgb(0, 0, 255)");
|
||||||
|
cy.get("@hex").should("have.value", "#0000FF");
|
||||||
|
|
||||||
|
// History updates after timeout
|
||||||
|
|
||||||
|
cy.get("@hex").type("#FF00FF{esc}").wait(0);
|
||||||
|
cy.get("@picker").should("have.css", "background-color", "rgb(255, 0, 255)");
|
||||||
|
cy.tick(3000);
|
||||||
|
cy.get("@hex").type("#00FF00{esc}").wait(0);
|
||||||
|
cy.get("@picker").should("have.css", "background-color", "rgb(0, 255, 0)");
|
||||||
|
cy.tick(3000);
|
||||||
|
|
||||||
|
// undo goes back to pink, then blue
|
||||||
|
cy.get("@undo").click();
|
||||||
|
cy.get("@palette").should("have.css", "background-color", "rgb(255, 0, 255)");
|
||||||
|
cy.get("@picker").should("have.css", "background-color", "rgb(255, 0, 255)");
|
||||||
|
cy.get("@hex").should("have.value", "#FF00FF");
|
||||||
|
cy.get("@undo").click();
|
||||||
|
cy.get("@palette").should("have.css", "background-color", "rgb(0, 0, 255)");
|
||||||
|
cy.get("@picker").should("have.css", "background-color", "rgb(0, 0, 255)");
|
||||||
|
cy.get("@hex").should("have.value", "#0000FF");
|
||||||
|
|
||||||
|
// redo goes to pink, then green
|
||||||
|
cy.get("@redo").click();
|
||||||
|
cy.get("@palette").should("have.css", "background-color", "rgb(255, 0, 255)");
|
||||||
|
cy.get("@picker").should("have.css", "background-color", "rgb(255, 0, 255)");
|
||||||
|
cy.get("@redo").click();
|
||||||
|
cy.get("@palette").should("have.css", "background-color", "rgb(0, 255, 0)");
|
||||||
|
cy.get("@picker").should("have.css", "background-color", "rgb(0, 255, 0)");
|
||||||
|
|
||||||
|
// undo during timeout wipes intermediate state
|
||||||
|
cy.get("@hex").type("#0000FF{esc}").wait(0);
|
||||||
|
cy.tick(3000); // lock in blue
|
||||||
|
cy.get("@hex").type("#FF00FF{esc}").wait(0); // pink is debounced
|
||||||
|
cy.get("@hex").type("#00FF00{esc}").wait(0);
|
||||||
|
cy.tick(3000); // lock in green
|
||||||
|
|
||||||
|
// pink state is lost, undo goes back to blue
|
||||||
|
cy.get("@undo").click();
|
||||||
|
cy.get("@picker").should("have.css", "background-color", "rgb(0, 0, 255)");
|
||||||
|
|
||||||
|
cy.clock().then((clock) => clock.restore());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can perform actions in edit mode", () => {
|
||||||
|
cy.mount(<TestWrapper initialCardState={defaultPaletteCardState} />);
|
||||||
|
|
||||||
|
cy.dataCy("select").as("select");
|
||||||
|
cy.dataCy("palette-row-0").as("row0");
|
||||||
|
cy.dataCy("palette-row-1").as("row1");
|
||||||
|
cy.dataCy("palette-row-2").as("row2");
|
||||||
|
|
||||||
|
// enter select mode
|
||||||
|
cy.get("@select").click().should("have.attr", "aria-pressed", "true");
|
||||||
|
|
||||||
|
cy.dataCy("select-all").as("select-all");
|
||||||
|
cy.dataCy("clear").as("clear");
|
||||||
|
|
||||||
|
// select multiple colors
|
||||||
|
cy.get("@row0").should("have.attr", "aria-selected", "false");
|
||||||
|
cy.get("@row1").should("have.attr", "aria-selected", "false");
|
||||||
|
|
||||||
|
cy.get("@row0").click();
|
||||||
|
cy.get("@row1").click();
|
||||||
|
|
||||||
|
cy.get("@row0").should("have.attr", "aria-selected", "true");
|
||||||
|
cy.get("@row1").should("have.attr", "aria-selected", "true");
|
||||||
|
|
||||||
|
// clear selection
|
||||||
|
cy.get("@clear").click();
|
||||||
|
cy.get("@row0").should("have.attr", "aria-selected", "false");
|
||||||
|
cy.get("@row1").should("have.attr", "aria-selected", "false");
|
||||||
|
|
||||||
|
// select all
|
||||||
|
cy.get("@select-all").click();
|
||||||
|
cy.get("@row0").should("have.attr", "aria-selected", "true");
|
||||||
|
cy.get("@row1").should("have.attr", "aria-selected", "true");
|
||||||
|
cy.get("@row2").should("have.attr", "aria-selected", "true");
|
||||||
|
|
||||||
|
// leave select mode
|
||||||
|
cy.get("@select").click().should("have.attr", "aria-pressed", "false");
|
||||||
|
cy.get("@row0").should("have.attr", "aria-selected", "false");
|
||||||
|
cy.get("@row1").should("have.attr", "aria-selected", "false");
|
||||||
|
cy.get("@row2").should("have.attr", "aria-selected", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can reorder colors in reorder mode", () => {
|
||||||
|
cy.mount(<TestWrapper initialCardState={defaultPaletteCardState} />);
|
||||||
|
|
||||||
|
// enter reorder mode
|
||||||
|
cy.dataCy("reorder").click().should("have.attr", "aria-pressed", "true");
|
||||||
|
|
||||||
|
// drag red down to green
|
||||||
|
cy.dataCy("palette-row-0-wrapper").trigger("mousedown", {
|
||||||
|
buttons: 1,
|
||||||
|
eventConstructor: "MouseEvent",
|
||||||
|
});
|
||||||
|
cy.dataCy("palette-row-1-wrapper").trigger("mousemove", {
|
||||||
|
buttons: 1,
|
||||||
|
eventConstructor: "MouseEvent",
|
||||||
|
});
|
||||||
|
cy.dataCy("palette-row-1-wrapper").trigger("mouseup", {
|
||||||
|
buttons: 1,
|
||||||
|
eventConstructor: "MouseEvent",
|
||||||
|
});
|
||||||
|
|
||||||
|
// green should now be first
|
||||||
|
cy.dataCy("palette-row-0").contains("Green");
|
||||||
|
cy.dataCy("palette-row-1").contains("Red");
|
||||||
|
cy.dataCy("palette-row-2").contains("Blue");
|
||||||
|
|
||||||
|
// leave reorder mode
|
||||||
|
cy.dataCy("reorder").click().should("have.attr", "aria-pressed", "false");
|
||||||
|
|
||||||
|
// order should persist
|
||||||
|
cy.dataCy("palette-row-0").contains("Green");
|
||||||
|
cy.dataCy("palette-row-1").contains("Red");
|
||||||
|
cy.dataCy("palette-row-2").contains("Blue");
|
||||||
|
});
|
||||||
@@ -0,0 +1,917 @@
|
|||||||
|
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||||
|
import type { KeyboardEvent, MouseEvent, RefObject } from "react";
|
||||||
|
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { Hex as HexColor } from "colorlib";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
CheckCheck,
|
||||||
|
ChevronsLeft,
|
||||||
|
ChevronsRight,
|
||||||
|
CircleDashed,
|
||||||
|
Copy,
|
||||||
|
Crosshair,
|
||||||
|
Dot,
|
||||||
|
GripVertical,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Redo2,
|
||||||
|
RefreshCw,
|
||||||
|
RefreshCwOff,
|
||||||
|
Trash2,
|
||||||
|
Undo2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
|
||||||
|
import type { ContrastToken } from "@/hooks/contrast";
|
||||||
|
import { luminanceFromHex, useContrastToken } from "@/hooks/contrast";
|
||||||
|
import { useDragAndDrop } from "@/hooks/dragAndDrop";
|
||||||
|
import { extractHexValue, formatHexString } from "@/hooks/hex";
|
||||||
|
import {
|
||||||
|
createPaletteCardActions,
|
||||||
|
paletteCardReducer,
|
||||||
|
} from "@/hooks/paletteCard";
|
||||||
|
import type {
|
||||||
|
PaletteCard,
|
||||||
|
PaletteCardActions,
|
||||||
|
PaletteCardState,
|
||||||
|
PaletteColor,
|
||||||
|
PaletteMode,
|
||||||
|
} from "@/hooks/paletteCard";
|
||||||
|
import {
|
||||||
|
loadCards,
|
||||||
|
saveActiveCardId,
|
||||||
|
saveCards,
|
||||||
|
serializeCard,
|
||||||
|
} from "@/hooks/storage";
|
||||||
|
import { randomId } from "@/util";
|
||||||
|
|
||||||
|
import styles from "./PaletteEditor.module.css";
|
||||||
|
|
||||||
|
type Timeout = ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
const SYNC_DELAY = 2000;
|
||||||
|
const DEFAULT_BG = HexColor.from_code("f6f6f6");
|
||||||
|
|
||||||
|
function defaultPaletteCard(): PaletteCardState {
|
||||||
|
const defaultCard = {
|
||||||
|
id: randomId(),
|
||||||
|
name: "New Palette",
|
||||||
|
colors: [],
|
||||||
|
selectedColorIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
present: defaultCard,
|
||||||
|
history: [],
|
||||||
|
future: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaletteEditor({
|
||||||
|
pickerColor,
|
||||||
|
setPickerColor,
|
||||||
|
initialCardState,
|
||||||
|
}: {
|
||||||
|
pickerColor: HexColor;
|
||||||
|
setPickerColor: (hex: HexColor) => void;
|
||||||
|
initialCardState?: PaletteCardState;
|
||||||
|
}) {
|
||||||
|
const [cardState, dispatch] = useReducer(
|
||||||
|
paletteCardReducer,
|
||||||
|
initialCardState || defaultPaletteCard(),
|
||||||
|
);
|
||||||
|
const actions = useMemo(() => createPaletteCardActions(dispatch), [dispatch]);
|
||||||
|
const [historyCounter, setHistoryCounter] = useState(0);
|
||||||
|
const [mode, setMode] = useState<PaletteMode>("normal");
|
||||||
|
const [isSynced, setIsSynced] = useState(false);
|
||||||
|
const snapshotRef = useRef<PaletteCard | null>(null);
|
||||||
|
const timerRef = useRef<Timeout | null>(null);
|
||||||
|
|
||||||
|
const incrementHistoryCounter = () => {
|
||||||
|
setHistoryCounter((prev) => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
saveActiveCardId(cardState.present.id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cards = loadCards();
|
||||||
|
cards[cardState.present.id] = serializeCard(cardState.present);
|
||||||
|
saveCards(cards);
|
||||||
|
}, [cardState.present]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.paletteEditor} data-cy="palette-editor">
|
||||||
|
<ActionBar
|
||||||
|
mode={mode}
|
||||||
|
setMode={setMode}
|
||||||
|
actions={actions}
|
||||||
|
hasSelection={cardState.present.selectedColorIds.length > 0}
|
||||||
|
canUndo={cardState.history.length > 0}
|
||||||
|
canRedo={cardState.future.length > 0}
|
||||||
|
isSynced={isSynced}
|
||||||
|
incrementHistoryCounter={incrementHistoryCounter}
|
||||||
|
snapshotRef={snapshotRef}
|
||||||
|
syncTimerRef={timerRef}
|
||||||
|
/>
|
||||||
|
<PaletteCard
|
||||||
|
pickerColor={pickerColor}
|
||||||
|
setPickerColor={setPickerColor}
|
||||||
|
cardState={cardState.present}
|
||||||
|
actions={actions}
|
||||||
|
mode={mode}
|
||||||
|
isSynced={isSynced}
|
||||||
|
setIsSynced={setIsSynced}
|
||||||
|
snapshotRef={snapshotRef}
|
||||||
|
syncTimerRef={timerRef}
|
||||||
|
historyCounter={historyCounter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionBar({
|
||||||
|
mode,
|
||||||
|
setMode,
|
||||||
|
actions,
|
||||||
|
hasSelection,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
isSynced,
|
||||||
|
incrementHistoryCounter,
|
||||||
|
snapshotRef,
|
||||||
|
syncTimerRef,
|
||||||
|
}: {
|
||||||
|
mode: PaletteMode;
|
||||||
|
setMode: (mode: PaletteMode) => void;
|
||||||
|
actions: PaletteCardActions;
|
||||||
|
hasSelection: boolean;
|
||||||
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
|
isSynced: boolean;
|
||||||
|
incrementHistoryCounter: () => void;
|
||||||
|
snapshotRef: RefObject<PaletteCard | null>;
|
||||||
|
syncTimerRef: RefObject<Timeout | null>;
|
||||||
|
}) {
|
||||||
|
const clearSyncTimeout = () => {
|
||||||
|
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
|
||||||
|
snapshotRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUndo = () => {
|
||||||
|
if (isSynced) clearSyncTimeout();
|
||||||
|
incrementHistoryCounter();
|
||||||
|
actions.undo();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRedo = () => {
|
||||||
|
if (isSynced) clearSyncTimeout();
|
||||||
|
incrementHistoryCounter();
|
||||||
|
actions.redo();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModeChange = (next: PaletteMode) => {
|
||||||
|
if (mode === "normal") {
|
||||||
|
if (isSynced && syncTimerRef.current) {
|
||||||
|
clearTimeout(syncTimerRef.current);
|
||||||
|
if (snapshotRef.current) {
|
||||||
|
actions.commitToHistory(snapshotRef.current);
|
||||||
|
snapshotRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actions.clearSelection();
|
||||||
|
setMode(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.actionBar} data-cy="action-bar">
|
||||||
|
<button
|
||||||
|
className={clsx(styles.actionButton, styles.iconButton)}
|
||||||
|
data-cy="undo"
|
||||||
|
disabled={!canUndo}
|
||||||
|
onClick={handleUndo}
|
||||||
|
title="Undo"
|
||||||
|
>
|
||||||
|
<Undo2 size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={clsx(styles.actionButton, styles.iconButton)}
|
||||||
|
data-cy="redo"
|
||||||
|
disabled={!canRedo}
|
||||||
|
onClick={handleRedo}
|
||||||
|
title="Redo"
|
||||||
|
>
|
||||||
|
<Redo2 size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={clsx(styles.actionButton, styles.iconButton)}
|
||||||
|
data-cy="add"
|
||||||
|
onClick={actions.addColor}
|
||||||
|
title="Add Color"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={clsx(styles.actionButton, styles.iconButton)}
|
||||||
|
data-cy="delete"
|
||||||
|
disabled={!hasSelection}
|
||||||
|
onClick={actions.deleteSelectedColors}
|
||||||
|
title="Delete Selected"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={clsx(styles.actionButton, styles.iconButton)}
|
||||||
|
data-cy="duplicate"
|
||||||
|
disabled={!hasSelection}
|
||||||
|
onClick={actions.duplicateSelectedColors}
|
||||||
|
title="Duplicate Selected"
|
||||||
|
>
|
||||||
|
<Copy size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={clsx(styles.actionButton, styles.wordButton, {
|
||||||
|
[styles.activeButton]: mode === "reorder",
|
||||||
|
})}
|
||||||
|
data-cy="reorder"
|
||||||
|
aria-pressed={mode === "reorder"}
|
||||||
|
onClick={() =>
|
||||||
|
handleModeChange(mode === "reorder" ? "normal" : "reorder")
|
||||||
|
}
|
||||||
|
title="Reorder Colors"
|
||||||
|
>
|
||||||
|
Reorder
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={clsx(styles.actionButton, styles.wordButton, {
|
||||||
|
[styles.activeButton]: mode === "select",
|
||||||
|
})}
|
||||||
|
data-cy="select"
|
||||||
|
aria-pressed={mode === "select"}
|
||||||
|
onClick={() =>
|
||||||
|
handleModeChange(mode === "select" ? "normal" : "select")
|
||||||
|
}
|
||||||
|
title="Select Multiple"
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</button>
|
||||||
|
{mode === "select" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className={clsx(styles.actionButton, styles.iconButton)}
|
||||||
|
data-cy="select-all"
|
||||||
|
onClick={actions.selectAll}
|
||||||
|
title="Select All"
|
||||||
|
>
|
||||||
|
<CheckCheck size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={clsx(styles.actionButton, styles.iconButton)}
|
||||||
|
data-cy="clear"
|
||||||
|
onClick={actions.clearSelection}
|
||||||
|
title="Clear Selections"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaletteCard({
|
||||||
|
pickerColor,
|
||||||
|
setPickerColor,
|
||||||
|
cardState,
|
||||||
|
actions,
|
||||||
|
mode,
|
||||||
|
isSynced,
|
||||||
|
setIsSynced,
|
||||||
|
snapshotRef,
|
||||||
|
syncTimerRef,
|
||||||
|
historyCounter,
|
||||||
|
}: {
|
||||||
|
pickerColor: HexColor;
|
||||||
|
setPickerColor: (hex: HexColor) => void;
|
||||||
|
cardState: PaletteCard;
|
||||||
|
actions: PaletteCardActions;
|
||||||
|
mode: PaletteMode;
|
||||||
|
isSynced: boolean;
|
||||||
|
setIsSynced: (v: boolean) => void;
|
||||||
|
snapshotRef: RefObject<PaletteCard | null>;
|
||||||
|
syncTimerRef: RefObject<Timeout | null>;
|
||||||
|
historyCounter: number;
|
||||||
|
}) {
|
||||||
|
const selectedColor =
|
||||||
|
mode === "select"
|
||||||
|
? null
|
||||||
|
: cardState.selectedColorIds.length === 1
|
||||||
|
? cardState.colors.find((c) => c.id === cardState.selectedColorIds[0])
|
||||||
|
: null;
|
||||||
|
const wasSyncedRef = useRef(false);
|
||||||
|
const pickerColorValue = pickerColor.to_code();
|
||||||
|
|
||||||
|
// when sync toggles on, set picker <- palette
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSynced) {
|
||||||
|
wasSyncedRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className={styles.cardWrapper} data-cy="palette-card">
|
||||||
|
<SyncButton isSynced={isSynced} setIsSynced={setIsSynced} />
|
||||||
|
<CardHeader name={cardState.name} onNameChange={actions.setCardName} />
|
||||||
|
<PickerColor
|
||||||
|
pickerColor={pickerColor}
|
||||||
|
setPickerColor={setPickerColor}
|
||||||
|
selectedColor={selectedColor?.hex || null}
|
||||||
|
isSynced={isSynced}
|
||||||
|
/>
|
||||||
|
<PaletteColor
|
||||||
|
selectedColor={selectedColor?.hex || null}
|
||||||
|
pickerColor={pickerColor}
|
||||||
|
paletteColorId={selectedColor?.id || null}
|
||||||
|
setPaletteColor={actions.setColorValue}
|
||||||
|
isSynced={isSynced}
|
||||||
|
/>
|
||||||
|
<Palette cardState={cardState} actions={actions} mode={mode} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SyncButton({
|
||||||
|
isSynced,
|
||||||
|
setIsSynced,
|
||||||
|
}: {
|
||||||
|
isSynced: boolean;
|
||||||
|
setIsSynced: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.sync}
|
||||||
|
data-cy="sync"
|
||||||
|
onClick={() => setIsSynced(!isSynced)}
|
||||||
|
aria-pressed={isSynced}
|
||||||
|
style={{
|
||||||
|
color: isSynced ? "#292929" : "#7a7a7a",
|
||||||
|
}}
|
||||||
|
title={isSynced ? "Unsync Picker & Palette" : "Sync Picker and Palette"}
|
||||||
|
>
|
||||||
|
<span className={styles.leftSpan}>Picker</span>
|
||||||
|
<span className={styles.middleSpan}>
|
||||||
|
{isSynced ? <RefreshCw size={22} /> : <RefreshCwOff size={22} />}
|
||||||
|
</span>
|
||||||
|
<span className={styles.rightSpan}>Palette</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({
|
||||||
|
name,
|
||||||
|
onNameChange,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
onNameChange: (name: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={styles.cardHeader} data-cy="card-header">
|
||||||
|
<EditableField
|
||||||
|
testID="card-name"
|
||||||
|
value={name}
|
||||||
|
setValue={onNameChange}
|
||||||
|
buttonSize={12}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PickerColor({
|
||||||
|
pickerColor,
|
||||||
|
setPickerColor,
|
||||||
|
selectedColor,
|
||||||
|
isSynced,
|
||||||
|
}: {
|
||||||
|
pickerColor: HexColor;
|
||||||
|
setPickerColor: (hex: HexColor) => void;
|
||||||
|
selectedColor: HexColor | null;
|
||||||
|
isSynced: boolean;
|
||||||
|
}) {
|
||||||
|
const arrowToken = useContrastToken(() => luminanceFromHex(pickerColor));
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (!isSynced && selectedColor) {
|
||||||
|
setPickerColor(selectedColor);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(styles.previewPane, styles.pickerColor)}
|
||||||
|
data-cy="picker-preview"
|
||||||
|
style={{
|
||||||
|
cursor: isSynced ? "unset" : "pointer",
|
||||||
|
backgroundColor: formatHexString(pickerColor),
|
||||||
|
}}
|
||||||
|
onClick={handleClick}
|
||||||
|
title={!isSynced ? "Set to Palette Color" : ""}
|
||||||
|
>
|
||||||
|
{!isSynced && (
|
||||||
|
<div
|
||||||
|
data-cy="picker-color-arrow"
|
||||||
|
className={clsx(styles.arrowIndicator, {
|
||||||
|
[styles.arrowIndicatorDark]: arrowToken === "dark",
|
||||||
|
[styles.arrowIndicatorLight]: arrowToken === "light",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ChevronsLeft size={32} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaletteColor({
|
||||||
|
selectedColor,
|
||||||
|
pickerColor,
|
||||||
|
paletteColorId,
|
||||||
|
setPaletteColor,
|
||||||
|
isSynced,
|
||||||
|
}: {
|
||||||
|
selectedColor: HexColor | null;
|
||||||
|
pickerColor: HexColor;
|
||||||
|
paletteColorId: string | null;
|
||||||
|
setPaletteColor: (id: string, hex: HexColor) => void;
|
||||||
|
isSynced: boolean;
|
||||||
|
}) {
|
||||||
|
const bgColor = selectedColor || DEFAULT_BG;
|
||||||
|
const arrowToken = useContrastToken(() => luminanceFromHex(bgColor));
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (!isSynced && paletteColorId) {
|
||||||
|
setPaletteColor(paletteColorId, pickerColor);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(styles.previewPane, styles.paletteColor)}
|
||||||
|
data-cy="selected-preview"
|
||||||
|
style={{
|
||||||
|
cursor: isSynced ? "unset" : "pointer",
|
||||||
|
backgroundColor: formatHexString(bgColor),
|
||||||
|
}}
|
||||||
|
onClick={handleClick}
|
||||||
|
title={!isSynced ? "Set to Picker Color" : ""}
|
||||||
|
>
|
||||||
|
{!isSynced && (
|
||||||
|
<div
|
||||||
|
data-cy="palette-color-arrow"
|
||||||
|
className={clsx(styles.arrowIndicator, {
|
||||||
|
[styles.arrowIndicatorDark]: arrowToken === "dark",
|
||||||
|
[styles.arrowIndicatorLight]: arrowToken === "light",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ChevronsRight size={32} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Palette({
|
||||||
|
cardState,
|
||||||
|
actions,
|
||||||
|
mode,
|
||||||
|
}: {
|
||||||
|
cardState: PaletteCard;
|
||||||
|
actions: PaletteCardActions;
|
||||||
|
mode: PaletteMode;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
containerRef,
|
||||||
|
setItemRef,
|
||||||
|
isDragging,
|
||||||
|
// sourceIndex,
|
||||||
|
targetIndex,
|
||||||
|
previewItems,
|
||||||
|
} = useDragAndDrop({
|
||||||
|
items: cardState.colors,
|
||||||
|
handleReorder: actions.reorderColors,
|
||||||
|
disabled: mode !== "reorder",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleNormalClick = (color: PaletteColor) => {
|
||||||
|
const ids = cardState.selectedColorIds;
|
||||||
|
const isSelected = ids.includes(color.id);
|
||||||
|
console.log(color.id, isSelected);
|
||||||
|
if (isSelected) {
|
||||||
|
actions.setSelectedColors([]);
|
||||||
|
} else {
|
||||||
|
actions.setSelectedColors([color.id]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectClick = (color: PaletteColor) => {
|
||||||
|
const ids = cardState.selectedColorIds;
|
||||||
|
const next = ids.includes(color.id)
|
||||||
|
? ids.filter((id) => id !== color.id)
|
||||||
|
: [...ids, color.id];
|
||||||
|
actions.setSelectedColors(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRowClick =
|
||||||
|
mode === "normal"
|
||||||
|
? handleNormalClick
|
||||||
|
: mode === "select"
|
||||||
|
? handleSelectClick
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const displayColors = isDragging ? previewItems : cardState.colors;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.palette} data-cy="palette" ref={containerRef}>
|
||||||
|
{displayColors.length > 0 ? (
|
||||||
|
displayColors.map((color, index) => (
|
||||||
|
<PaletteRow
|
||||||
|
key={color.id}
|
||||||
|
color={color}
|
||||||
|
index={index}
|
||||||
|
isSelected={cardState.selectedColorIds.includes(color.id)}
|
||||||
|
isEditable={
|
||||||
|
mode === "normal" && cardState.selectedColorIds[0] === color.id
|
||||||
|
}
|
||||||
|
isDragging={isDragging}
|
||||||
|
mode={mode}
|
||||||
|
actions={actions}
|
||||||
|
onRowClick={onRowClick}
|
||||||
|
setItemRef={setItemRef}
|
||||||
|
targetIndex={targetIndex}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span style={{ margin: 8 }}>
|
||||||
|
No colors in palette. Press{" "}
|
||||||
|
<Plus size={16} style={{ transform: "translateY(2px)" }} /> in the
|
||||||
|
toolbar above to add one.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaletteRow({
|
||||||
|
color,
|
||||||
|
index,
|
||||||
|
isSelected,
|
||||||
|
isEditable,
|
||||||
|
isDragging,
|
||||||
|
mode,
|
||||||
|
actions,
|
||||||
|
onRowClick,
|
||||||
|
setItemRef,
|
||||||
|
targetIndex,
|
||||||
|
}: {
|
||||||
|
color: PaletteColor;
|
||||||
|
index: number;
|
||||||
|
isSelected: boolean;
|
||||||
|
isEditable: boolean;
|
||||||
|
isDragging: boolean;
|
||||||
|
mode: PaletteMode;
|
||||||
|
actions: PaletteCardActions;
|
||||||
|
onRowClick?: (color: PaletteColor, e: MouseEvent<HTMLDivElement>) => void;
|
||||||
|
setItemRef: (el: HTMLElement | null, id: string) => void;
|
||||||
|
targetIndex: number;
|
||||||
|
}) {
|
||||||
|
const isNormalMode = mode === "normal";
|
||||||
|
const isSelectMode = mode === "select";
|
||||||
|
const isReorderMode = mode === "reorder";
|
||||||
|
|
||||||
|
const Wrapper = isReorderMode ? motion.div : "div";
|
||||||
|
const motionProps = isReorderMode
|
||||||
|
? { layout: true, transition: { duration: 0.25 } }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const token = useContrastToken(() => luminanceFromHex(color.hex));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper
|
||||||
|
className={clsx(styles.paletteRowWrapper, {
|
||||||
|
[styles.draggable]: isReorderMode,
|
||||||
|
[styles.dragging]: isDragging && index === targetIndex,
|
||||||
|
[styles.multiSelected]: isSelectMode && isSelected,
|
||||||
|
})}
|
||||||
|
ref={(el) => setItemRef(el, color.id)}
|
||||||
|
data-cy={`palette-row-${index}-wrapper`}
|
||||||
|
data-item-id={color.id}
|
||||||
|
onClick={(e) => onRowClick?.(color, e)}
|
||||||
|
{...motionProps}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={styles.paletteRow}
|
||||||
|
data-cy={`palette-row-${index}`}
|
||||||
|
aria-selected={isSelected}
|
||||||
|
style={{
|
||||||
|
backgroundColor: formatHexString(color.hex),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSelectMode && (
|
||||||
|
<span
|
||||||
|
className={clsx(styles.modeDecorator, styles.checkDecorator, {
|
||||||
|
[styles.checkDecoratorSelected]: isSelected,
|
||||||
|
[styles.checkDecoratorDark]: token === "dark",
|
||||||
|
[styles.checkDecoratorLight]: token === "light",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{isSelected && <Check size={18} strokeWidth={3} />}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{mode === "reorder" && (
|
||||||
|
<span
|
||||||
|
className={clsx(styles.modeDecorator, {
|
||||||
|
[styles.gripDark]: token === "dark",
|
||||||
|
[styles.gripLight]: token === "light",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<GripVertical size={24} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isNormalMode &&
|
||||||
|
(isSelected ? (
|
||||||
|
<Crosshair
|
||||||
|
className={clsx(styles.selectedIndicator, {
|
||||||
|
[styles.indicatorDark]: token === "dark",
|
||||||
|
[styles.indicatorLight]: token === "light",
|
||||||
|
})}
|
||||||
|
size={27}
|
||||||
|
>
|
||||||
|
<Dot />
|
||||||
|
</Crosshair>
|
||||||
|
) : (
|
||||||
|
<CircleDashed
|
||||||
|
className={clsx(styles.targettedIndicator, {
|
||||||
|
[styles.indicatorDark]: token === "dark",
|
||||||
|
[styles.indicatorLight]: token === "light",
|
||||||
|
})}
|
||||||
|
size={32}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div className={styles.paletteRowData}>
|
||||||
|
<div className={styles.colorName}>
|
||||||
|
{isEditable ? (
|
||||||
|
<EditableField
|
||||||
|
testID={`palette-row-name-${index}`}
|
||||||
|
value={color.name}
|
||||||
|
setValue={(newName: string) =>
|
||||||
|
actions.setColorName(color.id, newName)
|
||||||
|
}
|
||||||
|
buttonSize={12}
|
||||||
|
contrastToken={token}
|
||||||
|
reset={!isSelected}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
data-cy={`palette-row-name-${index}`}
|
||||||
|
className={clsx(styles.field, {
|
||||||
|
[styles.fieldDark]: token === "dark",
|
||||||
|
[styles.fieldLight]: token === "light",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{color.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.colorHex}>
|
||||||
|
{isEditable ? (
|
||||||
|
<EditableField
|
||||||
|
testID={`palette-row-hex-${index}`}
|
||||||
|
value={color.hex.to_code()}
|
||||||
|
setValue={(newHex: string) =>
|
||||||
|
actions.setColorValue(color.id, HexColor.from_code(newHex))
|
||||||
|
}
|
||||||
|
buttonSize={12}
|
||||||
|
contrastToken={token}
|
||||||
|
reset={!isSelected}
|
||||||
|
validate={(raw: string) => extractHexValue(raw)}
|
||||||
|
render={(raw: string) => `#${raw}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={styles.fieldWrapper}>
|
||||||
|
<span
|
||||||
|
data-cy={`palette-row-hex-${index}`}
|
||||||
|
className={clsx(styles.field, {
|
||||||
|
[styles.fieldDark]: token === "dark",
|
||||||
|
[styles.fieldLight]: token === "light",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{formatHexString(color.hex)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditableField({
|
||||||
|
testID,
|
||||||
|
value,
|
||||||
|
setValue,
|
||||||
|
buttonSize,
|
||||||
|
contrastToken,
|
||||||
|
reset,
|
||||||
|
validate,
|
||||||
|
render,
|
||||||
|
}: {
|
||||||
|
testID: string;
|
||||||
|
value: string;
|
||||||
|
setValue: (v: string) => void;
|
||||||
|
buttonSize: number;
|
||||||
|
contrastToken?: ContrastToken;
|
||||||
|
reset?: boolean;
|
||||||
|
validate?: (raw: string) => string | null;
|
||||||
|
render?: (raw: string) => string;
|
||||||
|
}) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const spanRef = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (reset) setIsEditing(false);
|
||||||
|
}, [reset]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// return if not editing or not rendered
|
||||||
|
if (!isEditing || !spanRef.current) return;
|
||||||
|
|
||||||
|
// set span content
|
||||||
|
spanRef.current.textContent = render ? render(value) : value;
|
||||||
|
|
||||||
|
// focus span
|
||||||
|
spanRef.current.focus();
|
||||||
|
|
||||||
|
// select contents
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(spanRef.current);
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
window.getSelection()?.addRange(range);
|
||||||
|
}, [isEditing, value, render]);
|
||||||
|
|
||||||
|
const onConfirm = () => {
|
||||||
|
const raw = spanRef.current?.textContent ?? "";
|
||||||
|
const validated = validate ? validate(raw) : raw;
|
||||||
|
if (validated) setValue(validated);
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
onConfirm();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.editableFieldWrapper}>
|
||||||
|
<div className={styles.valueWrapper}>
|
||||||
|
<span
|
||||||
|
ref={spanRef}
|
||||||
|
data-cy={isEditing ? `${testID}-input` : testID}
|
||||||
|
className={clsx(styles.editableField, {
|
||||||
|
[styles.editingField]: isEditing,
|
||||||
|
[styles.editableFieldDark]: contrastToken === "dark",
|
||||||
|
[styles.editableFieldLight]: contrastToken === "light",
|
||||||
|
})}
|
||||||
|
contentEditable={isEditing}
|
||||||
|
suppressContentEditableWarning
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={isEditing ? handleKeyDown : undefined}
|
||||||
|
>
|
||||||
|
{!isEditing && (render ? render(value) : value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isEditing ? (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
data-cy={`${testID}-cancel`}
|
||||||
|
className={clsx(styles.editableFieldButton, {
|
||||||
|
[styles.editableFieldButtonDark]: contrastToken === "dark",
|
||||||
|
[styles.editableFieldButtonLight]: contrastToken === "light",
|
||||||
|
})}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCancel();
|
||||||
|
}}
|
||||||
|
title="Cancel"
|
||||||
|
>
|
||||||
|
<X size={buttonSize} strokeWidth={3} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-cy={`${testID}-confirm`}
|
||||||
|
className={clsx(styles.editableFieldButton, {
|
||||||
|
[styles.editableFieldButtonDark]: contrastToken === "dark",
|
||||||
|
[styles.editableFieldButtonLight]: contrastToken === "light",
|
||||||
|
})}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onConfirm();
|
||||||
|
}}
|
||||||
|
title="Confirm"
|
||||||
|
>
|
||||||
|
<Check size={buttonSize} strokeWidth={3} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
data-cy={`${testID}-edit`}
|
||||||
|
className={clsx(styles.editableFieldButton, {
|
||||||
|
[styles.editableFieldButtonDark]: contrastToken === "dark",
|
||||||
|
[styles.editableFieldButtonLight]: contrastToken === "light",
|
||||||
|
})}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsEditing(true);
|
||||||
|
}}
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Pencil size={buttonSize} strokeWidth={3} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PaletteEditor;
|
||||||
|
|||||||
+7
-7
@@ -24,34 +24,34 @@ export function colorReducer(
|
|||||||
state: ColorState,
|
state: ColorState,
|
||||||
action: ColorAction,
|
action: ColorAction,
|
||||||
): ColorState {
|
): ColorState {
|
||||||
let comp;
|
let comp, rgb, hsv, hcl, hex, valOrFn, prev;
|
||||||
|
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "SET_COLOR":
|
case "SET_COLOR":
|
||||||
return { ...state, color: action.payload };
|
return { ...state, color: action.payload };
|
||||||
|
|
||||||
case "SET_RGB":
|
case "SET_RGB":
|
||||||
let rgb = action.payload;
|
rgb = action.payload;
|
||||||
return { ...state, color: colorlib.Color.from_rgb(rgb.r, rgb.g, rgb.b) };
|
return { ...state, color: colorlib.Color.from_rgb(rgb.r, rgb.g, rgb.b) };
|
||||||
|
|
||||||
case "SET_HSV":
|
case "SET_HSV":
|
||||||
let hsv = action.payload;
|
hsv = action.payload;
|
||||||
return { ...state, color: colorlib.Color.from_hsv(hsv.h, hsv.s, hsv.v) };
|
return { ...state, color: colorlib.Color.from_hsv(hsv.h, hsv.s, hsv.v) };
|
||||||
|
|
||||||
case "SET_HCL":
|
case "SET_HCL":
|
||||||
let hcl = action.payload;
|
hcl = action.payload;
|
||||||
return { ...state, color: colorlib.Color.from_hcl(hcl.h, hcl.c, hcl.l) };
|
return { ...state, color: colorlib.Color.from_hcl(hcl.h, hcl.c, hcl.l) };
|
||||||
|
|
||||||
case "SET_HEX":
|
case "SET_HEX":
|
||||||
let hex = action.payload;
|
hex = action.payload;
|
||||||
return { ...state, color: colorlib.Color.from_hex(hex.to_code()) };
|
return { ...state, color: colorlib.Color.from_hex(hex.to_code()) };
|
||||||
|
|
||||||
case "SET_VALUE":
|
case "SET_VALUE":
|
||||||
comp = action.component;
|
comp = action.component;
|
||||||
let valOrFn = action.payload;
|
valOrFn = action.payload;
|
||||||
|
|
||||||
if (typeof valOrFn === "function") {
|
if (typeof valOrFn === "function") {
|
||||||
let prev = state.color.get(comp);
|
prev = state.color.get(comp);
|
||||||
return { ...state, color: state.color.update(comp, valOrFn(prev)) };
|
return { ...state, color: state.color.update(comp, valOrFn(prev)) };
|
||||||
} else {
|
} else {
|
||||||
return { ...state, color: state.color.update(comp, valOrFn) };
|
return { ...state, color: state.color.update(comp, valOrFn) };
|
||||||
|
|||||||
@@ -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],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { useSmoothAnimation } from "./animation";
|
import { useSmoothAnimation } from "./animation";
|
||||||
|
|
||||||
if (typeof TouchEvent === "undefined") {
|
if (typeof TouchEvent === "undefined") {
|
||||||
// @ts-ignore - intentionally creating global
|
// @ts-expect-error - intentionally creating global
|
||||||
window.TouchEvent = window.MouseEvent;
|
window.TouchEvent = window.MouseEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +61,8 @@ export function useCrosshair({
|
|||||||
yValueRangeRef.current = yValueRange;
|
yValueRangeRef.current = yValueRange;
|
||||||
}, [xValueRange, yValueRange]);
|
}, [xValueRange, yValueRange]);
|
||||||
|
|
||||||
const calculatePositions = useCallback((event: MouseEvent | TouchEvent) => {
|
const calculatePositions = useCallback(
|
||||||
|
(event: MouseEvent | TouchEvent) => {
|
||||||
const orig = originRef.current;
|
const orig = originRef.current;
|
||||||
const dims = dimensionsRef.current;
|
const dims = dimensionsRef.current;
|
||||||
const xRange = xValueRangeRef.current;
|
const xRange = xValueRangeRef.current;
|
||||||
@@ -84,14 +85,16 @@ export function useCrosshair({
|
|||||||
|
|
||||||
setXValueRef.current(newXValue);
|
setXValueRef.current(newXValue);
|
||||||
setYValueRef.current(newYValue);
|
setYValueRef.current(newYValue);
|
||||||
}, []);
|
},
|
||||||
|
[invertX, invertY],
|
||||||
|
);
|
||||||
|
|
||||||
const handleMove = useCallback(
|
const handleMove = useCallback(
|
||||||
(event: MouseEvent | TouchEvent) => {
|
(event: MouseEvent | TouchEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
smoothAnimation(() => calculatePositions(event));
|
smoothAnimation(() => calculatePositions(event));
|
||||||
},
|
},
|
||||||
[calculatePositions],
|
[calculatePositions, smoothAnimation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleEnd = useCallback(
|
const handleEnd = useCallback(
|
||||||
|
|||||||
+24
-11
@@ -22,9 +22,11 @@ interface DragState<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function reducer<T>(state: DragState<T>, action: DragAction<T>) {
|
function reducer<T>(state: DragState<T>, action: DragAction<T>) {
|
||||||
|
let items, cursor, rects, newTargetIndex, newPreviewItems, movedItem;
|
||||||
|
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "resetItems":
|
case "resetItems":
|
||||||
const items = action.items;
|
items = action.items;
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
items: [...items],
|
items: [...items],
|
||||||
@@ -44,8 +46,9 @@ function reducer<T>(state: DragState<T>, action: DragAction<T>) {
|
|||||||
case "processMove":
|
case "processMove":
|
||||||
if (!state.isDragging) return state;
|
if (!state.isDragging) return state;
|
||||||
|
|
||||||
const { cursor, rects } = action;
|
cursor = action.cursor;
|
||||||
let newTargetIndex = state.targetIndex;
|
rects = action.rects;
|
||||||
|
newTargetIndex = state.targetIndex;
|
||||||
|
|
||||||
for (let i = 0; i < rects.length; i++) {
|
for (let i = 0; i < rects.length; i++) {
|
||||||
const rect = rects[i];
|
const rect = rects[i];
|
||||||
@@ -62,8 +65,8 @@ function reducer<T>(state: DragState<T>, action: DragAction<T>) {
|
|||||||
|
|
||||||
if (newTargetIndex === state.targetIndex) return state;
|
if (newTargetIndex === state.targetIndex) return state;
|
||||||
|
|
||||||
const newPreviewItems = [...state.items];
|
newPreviewItems = [...state.items];
|
||||||
const [movedItem] = newPreviewItems.splice(state.sourceIndex, 1);
|
[movedItem] = newPreviewItems.splice(state.sourceIndex, 1);
|
||||||
newPreviewItems.splice(newTargetIndex, 0, movedItem);
|
newPreviewItems.splice(newTargetIndex, 0, movedItem);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -136,18 +139,21 @@ export function useDragAndDrop<T extends { id: string }>({
|
|||||||
return itemElement;
|
return itemElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getItemIndex(el: HTMLElement) {
|
const getItemIndex = useCallback(
|
||||||
|
(el: HTMLElement) => {
|
||||||
const itemId = el.dataset.itemId;
|
const itemId = el.dataset.itemId;
|
||||||
const index = items.findIndex((item) => item.id === itemId);
|
const index = items.findIndex((item) => item.id === itemId);
|
||||||
return index;
|
return index;
|
||||||
}
|
},
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
function captureItemBoundaries() {
|
const captureItemBoundaries = useCallback(() => {
|
||||||
itemBoundingRects.current = items.map((item) => {
|
itemBoundingRects.current = items.map((item) => {
|
||||||
const el = itemRefs.current[item.id]?.current;
|
const el = itemRefs.current[item.id]?.current;
|
||||||
return el ? el.getBoundingClientRect() : new DOMRect();
|
return el ? el.getBoundingClientRect() : new DOMRect();
|
||||||
});
|
});
|
||||||
}
|
}, [itemRefs, items]);
|
||||||
|
|
||||||
const handleDragMove = useCallback(
|
const handleDragMove = useCallback(
|
||||||
(event: MouseEvent | TouchEvent) => {
|
(event: MouseEvent | TouchEvent) => {
|
||||||
@@ -195,7 +201,14 @@ export function useDragAndDrop<T extends { id: string }>({
|
|||||||
document.addEventListener("touchend", handleDragEnd);
|
document.addEventListener("touchend", handleDragEnd);
|
||||||
document.addEventListener("touchcancel", handleDragEnd);
|
document.addEventListener("touchcancel", handleDragEnd);
|
||||||
},
|
},
|
||||||
[items, dispatch, handleDragEnd, handleDragMove],
|
[
|
||||||
|
items,
|
||||||
|
dispatch,
|
||||||
|
handleDragEnd,
|
||||||
|
handleDragMove,
|
||||||
|
captureItemBoundaries,
|
||||||
|
getItemIndex,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set/cleanup event handlers
|
// Set/cleanup event handlers
|
||||||
@@ -221,7 +234,7 @@ export function useDragAndDrop<T extends { id: string }>({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [items, handleDragStart, disabled]);
|
}, [items, handleDragStart, disabled, itemRefs]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
containerRef,
|
containerRef,
|
||||||
|
|||||||
@@ -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()}`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
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: HexColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColorNameUpdate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColorValueUpdate {
|
||||||
|
id: string;
|
||||||
|
hex: HexColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaletteCard {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
colors: PaletteColor[];
|
||||||
|
selectedColorIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaletteCardState {
|
||||||
|
present: PaletteCard;
|
||||||
|
history: PaletteCard[];
|
||||||
|
future: PaletteCard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaletteCardAction =
|
||||||
|
| { type: "SET_CARD_NAME"; payload: string }
|
||||||
|
| { 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: "REORDER_COLORS"; payload: PaletteColor[] }
|
||||||
|
| { 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 {
|
||||||
|
switch (action.type) {
|
||||||
|
case "SET_CARD_NAME":
|
||||||
|
state = pushToHistory(state, { ...state.present, name: action.payload });
|
||||||
|
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 "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 "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 "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":
|
||||||
|
return pushToHistory(state, {
|
||||||
|
...state.present,
|
||||||
|
colors: action.payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 "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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaletteCardActions {
|
||||||
|
setCardName: (name: string) => 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;
|
||||||
|
deleteSelectedColors: () => void;
|
||||||
|
duplicateSelectedColors: () => void;
|
||||||
|
reorderColors: (colors: PaletteColor[]) => void;
|
||||||
|
undo: () => void;
|
||||||
|
redo: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPaletteCardActions(
|
||||||
|
dispatch: Dispatch<PaletteCardAction>,
|
||||||
|
): PaletteCardActions {
|
||||||
|
return {
|
||||||
|
setCardName: (name) => dispatch({ type: "SET_CARD_NAME", payload: name }),
|
||||||
|
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" }),
|
||||||
|
deleteSelectedColors: () => dispatch({ type: "DELETE_SELECTED_COLORS" }),
|
||||||
|
duplicateSelectedColors: () =>
|
||||||
|
dispatch({ type: "DUPLICATE_SELECTED_COLORS" }),
|
||||||
|
reorderColors: (colors) =>
|
||||||
|
dispatch({ type: "REORDER_COLORS", payload: colors }),
|
||||||
|
undo: () => dispatch({ type: "UNDO" }),
|
||||||
|
redo: () => dispatch({ type: "REDO" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
+14
-16
@@ -16,7 +16,7 @@ import { useSmoothAnimation } from "./animation";
|
|||||||
import { useScroll } from "./scroll";
|
import { useScroll } from "./scroll";
|
||||||
|
|
||||||
if (typeof TouchEvent === "undefined") {
|
if (typeof TouchEvent === "undefined") {
|
||||||
// @ts-ignore - intentionally creating global
|
// @ts-expect-error - intentionally creating global
|
||||||
window.TouchEvent = window.MouseEvent;
|
window.TouchEvent = window.MouseEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +59,11 @@ export function useSlider({
|
|||||||
const maxPosition = useRef(0);
|
const maxPosition = useRef(0);
|
||||||
|
|
||||||
// Internal position management
|
// Internal position management
|
||||||
const [position, setPosition] = useState(0);
|
const position = valueToPosition(
|
||||||
|
value,
|
||||||
|
chooseValueByDirection(direction, dimensions.x, dimensions.y),
|
||||||
|
valueRange,
|
||||||
|
);
|
||||||
const positionRef = useRef(position);
|
const positionRef = useRef(position);
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
@@ -79,11 +83,11 @@ export function useSlider({
|
|||||||
maxPosition.current,
|
maxPosition.current,
|
||||||
valueRangeRef.current,
|
valueRangeRef.current,
|
||||||
);
|
);
|
||||||
}, [direction, origin, dimensions]);
|
}, [direction, origin, dimensions, value]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
valueRangeRef.current = valueRange;
|
valueRangeRef.current = valueRange;
|
||||||
}, [valueRangeRef]);
|
}, [valueRange, valueRangeRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValueRef.current = setValue;
|
setValueRef.current = setValue;
|
||||||
@@ -94,7 +98,8 @@ export function useSlider({
|
|||||||
}, [position]);
|
}, [position]);
|
||||||
|
|
||||||
// Setup drag handlers
|
// Setup drag handlers
|
||||||
const calculatePosition = useCallback((event: MouseEvent | TouchEvent) => {
|
const calculatePosition = useCallback(
|
||||||
|
(event: MouseEvent | TouchEvent) => {
|
||||||
const dir = directionRef.current;
|
const dir = directionRef.current;
|
||||||
const orig = originRef.current;
|
const orig = originRef.current;
|
||||||
const dims = dimensionsRef.current;
|
const dims = dimensionsRef.current;
|
||||||
@@ -116,14 +121,16 @@ export function useSlider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setValueRef.current(newValue);
|
setValueRef.current(newValue);
|
||||||
}, []);
|
},
|
||||||
|
[invert],
|
||||||
|
);
|
||||||
|
|
||||||
const handleMove = useCallback(
|
const handleMove = useCallback(
|
||||||
(event: MouseEvent | TouchEvent) => {
|
(event: MouseEvent | TouchEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
smoothAnimation(() => calculatePosition(event));
|
smoothAnimation(() => calculatePosition(event));
|
||||||
},
|
},
|
||||||
[calculatePosition],
|
[calculatePosition, smoothAnimation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleEnd = useCallback(() => {
|
const handleEnd = useCallback(() => {
|
||||||
@@ -198,15 +205,6 @@ export function useSlider({
|
|||||||
onScrollDown: handleScrollDown,
|
onScrollDown: handleScrollDown,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const newPosition = valueToPosition(
|
|
||||||
value,
|
|
||||||
maxPosition.current,
|
|
||||||
valueRangeRef.current,
|
|
||||||
);
|
|
||||||
setPosition(newPosition);
|
|
||||||
}, [value, setPosition]);
|
|
||||||
|
|
||||||
// Set up entry listeners
|
// Set up entry listeners
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentRef = sliderRef.current;
|
const currentRef = sliderRef.current;
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Hex as HexColor } from "colorlib";
|
||||||
|
|
||||||
|
import type { PaletteCard, PaletteColor } from "./paletteCard";
|
||||||
|
|
||||||
|
const CARDS_KEY = "luminance:cards";
|
||||||
|
const ACTIVE_ID_KEY = "luminance:activeCardId";
|
||||||
|
|
||||||
|
interface SerializedColor {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hex: string;
|
||||||
|
}
|
||||||
|
interface SerializedCard {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
colors: SerializedColor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeColor(color: PaletteColor): SerializedColor {
|
||||||
|
return {
|
||||||
|
id: color.id,
|
||||||
|
name: color.name,
|
||||||
|
hex: color.hex.to_code(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deserializeColor(raw: SerializedColor): PaletteColor {
|
||||||
|
return {
|
||||||
|
id: raw.id,
|
||||||
|
name: raw.name,
|
||||||
|
hex: HexColor.from_code(raw.hex),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeCard(card: PaletteCard): SerializedCard {
|
||||||
|
return {
|
||||||
|
id: card.id,
|
||||||
|
name: card.name,
|
||||||
|
colors: card.colors.map(serializeColor),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeCard(raw: SerializedCard): PaletteCard {
|
||||||
|
return {
|
||||||
|
id: raw.id,
|
||||||
|
name: raw.name,
|
||||||
|
colors: raw.colors.map(deserializeColor),
|
||||||
|
selectedColorIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadCards(): Record<string, SerializedCard> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(CARDS_KEY) ?? "{}");
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveCards(cards: Record<string, SerializedCard>) {
|
||||||
|
localStorage.setItem(CARDS_KEY, JSON.stringify(cards));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadActiveCardId(): string | null {
|
||||||
|
return localStorage.getItem(ACTIVE_ID_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveActiveCardId(id: string): void {
|
||||||
|
localStorage.setItem(ACTIVE_ID_KEY, id);
|
||||||
|
}
|
||||||
@@ -1,44 +1,11 @@
|
|||||||
import * as colorlib from "colorlib";
|
import * as colorlib from "colorlib";
|
||||||
import { beforeEach, describe, expect, test } from "vitest";
|
import { beforeEach, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { expectEqualColor, mockUseReducer } from "@/testUtil";
|
||||||
|
|
||||||
import { colorReducer, createColorActions } from "../color";
|
import { colorReducer, createColorActions } from "../color";
|
||||||
import type { ColorAction, ColorActions, ColorState } from "../color";
|
import type { ColorAction, ColorActions, ColorState } from "../color";
|
||||||
|
|
||||||
const mockUseReducer = <T extends object, U>(
|
|
||||||
reducer: (state: T, action: U) => T,
|
|
||||||
initialArg: T,
|
|
||||||
): [T, (value: U) => void] => {
|
|
||||||
let currentState = initialArg;
|
|
||||||
|
|
||||||
const state = new Proxy({} as T, {
|
|
||||||
get: (_, prop) => currentState[prop as keyof T],
|
|
||||||
});
|
|
||||||
|
|
||||||
const dispatch = (value: U) => {
|
|
||||||
const nextState = reducer(currentState, value);
|
|
||||||
currentState = nextState;
|
|
||||||
};
|
|
||||||
return [state, dispatch];
|
|
||||||
};
|
|
||||||
|
|
||||||
const expectRGB = (value: colorlib.RGB, expected: colorlib.RGB) => {
|
|
||||||
expect(value.r).toBe(expected.r);
|
|
||||||
expect(value.g).toBe(expected.g);
|
|
||||||
expect(value.b).toBe(expected.b);
|
|
||||||
};
|
|
||||||
|
|
||||||
const expectHSV = (value: colorlib.HSV, expected: colorlib.HSV) => {
|
|
||||||
expect(value.h).toBe(expected.h);
|
|
||||||
expect(value.s).toBe(expected.s);
|
|
||||||
expect(value.v).toBe(expected.v);
|
|
||||||
};
|
|
||||||
|
|
||||||
const expectHCL = (value: colorlib.HCL, expected: colorlib.HCL) => {
|
|
||||||
expect(value.h).toBe(expected.h);
|
|
||||||
expect(value.c).toBe(expected.c);
|
|
||||||
expect(value.l).toBe(expected.l);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("color reducer", () => {
|
describe("color reducer", () => {
|
||||||
const initialState = { color: colorlib.Color.from_hex("000") };
|
const initialState = { color: colorlib.Color.from_hex("000") };
|
||||||
|
|
||||||
@@ -61,7 +28,7 @@ describe("color reducer", () => {
|
|||||||
payload: nextColor,
|
payload: nextColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
expectRGB(nextState.color.rgb, nextColor);
|
expectEqualColor(nextState.color.rgb, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("set hsv", () => {
|
test("set hsv", () => {
|
||||||
@@ -71,7 +38,7 @@ describe("color reducer", () => {
|
|||||||
payload: nextColor,
|
payload: nextColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
expectHSV(nextState.color.hsv, nextColor);
|
expectEqualColor(nextState.color.hsv, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("set hcl", () => {
|
test("set hcl", () => {
|
||||||
@@ -81,7 +48,7 @@ describe("color reducer", () => {
|
|||||||
payload: nextColor,
|
payload: nextColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
expectHCL(nextState.color.hcl, nextColor);
|
expectEqualColor(nextState.color.hcl, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("set hex", () => {
|
test("set hex", () => {
|
||||||
@@ -104,7 +71,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.RGB_R,
|
component: colorlib.Component.RGB_R,
|
||||||
payload: nextColor.r,
|
payload: nextColor.r,
|
||||||
});
|
});
|
||||||
expectRGB(nextState.color.rgb, nextColor);
|
expectEqualColor(nextState.color.rgb, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("set rgb g", () => {
|
test("set rgb g", () => {
|
||||||
@@ -114,7 +81,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.RGB_G,
|
component: colorlib.Component.RGB_G,
|
||||||
payload: nextColor.g,
|
payload: nextColor.g,
|
||||||
});
|
});
|
||||||
expectRGB(nextState.color.rgb, nextColor);
|
expectEqualColor(nextState.color.rgb, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("set rgb b", () => {
|
test("set rgb b", () => {
|
||||||
@@ -124,7 +91,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.RGB_B,
|
component: colorlib.Component.RGB_B,
|
||||||
payload: nextColor.b,
|
payload: nextColor.b,
|
||||||
});
|
});
|
||||||
expectRGB(nextState.color.rgb, nextColor);
|
expectEqualColor(nextState.color.rgb, nextColor);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,7 +103,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.HSV_H,
|
component: colorlib.Component.HSV_H,
|
||||||
payload: nextColor.h,
|
payload: nextColor.h,
|
||||||
});
|
});
|
||||||
expectHSV(nextState.color.hsv, nextColor);
|
expectEqualColor(nextState.color.hsv, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("set hsv s", () => {
|
test("set hsv s", () => {
|
||||||
@@ -146,7 +113,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.HSV_S,
|
component: colorlib.Component.HSV_S,
|
||||||
payload: nextColor.s,
|
payload: nextColor.s,
|
||||||
});
|
});
|
||||||
expectHSV(nextState.color.hsv, nextColor);
|
expectEqualColor(nextState.color.hsv, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("set hsv v", () => {
|
test("set hsv v", () => {
|
||||||
@@ -156,7 +123,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.HSV_V,
|
component: colorlib.Component.HSV_V,
|
||||||
payload: nextColor.v,
|
payload: nextColor.v,
|
||||||
});
|
});
|
||||||
expectHSV(nextState.color.hsv, nextColor);
|
expectEqualColor(nextState.color.hsv, nextColor);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,7 +135,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.HCL_H,
|
component: colorlib.Component.HCL_H,
|
||||||
payload: nextColor.h,
|
payload: nextColor.h,
|
||||||
});
|
});
|
||||||
expectHCL(nextState.color.hcl, nextColor);
|
expectEqualColor(nextState.color.hcl, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("set hcl c", () => {
|
test("set hcl c", () => {
|
||||||
@@ -178,7 +145,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.HCL_C,
|
component: colorlib.Component.HCL_C,
|
||||||
payload: nextColor.c,
|
payload: nextColor.c,
|
||||||
});
|
});
|
||||||
expectHCL(nextState.color.hcl, nextColor);
|
expectEqualColor(nextState.color.hcl, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("set hcl l", () => {
|
test("set hcl l", () => {
|
||||||
@@ -188,7 +155,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.HCL_L,
|
component: colorlib.Component.HCL_L,
|
||||||
payload: nextColor.l,
|
payload: nextColor.l,
|
||||||
});
|
});
|
||||||
expectHCL(nextState.color.hcl, nextColor);
|
expectEqualColor(nextState.color.hcl, nextColor);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -202,7 +169,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.RGB_R,
|
component: colorlib.Component.RGB_R,
|
||||||
payload: (prev) => prev + 100,
|
payload: (prev) => prev + 100,
|
||||||
});
|
});
|
||||||
expectRGB(nextState.color.rgb, nextColor);
|
expectEqualColor(nextState.color.rgb, nextColor);
|
||||||
|
|
||||||
nextColor = colorlib.RGB.new(50, 0, 0);
|
nextColor = colorlib.RGB.new(50, 0, 0);
|
||||||
nextState = colorReducer(nextState, {
|
nextState = colorReducer(nextState, {
|
||||||
@@ -210,7 +177,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.RGB_R,
|
component: colorlib.Component.RGB_R,
|
||||||
payload: (prev) => prev - 50,
|
payload: (prev) => prev - 50,
|
||||||
});
|
});
|
||||||
expectRGB(nextState.color.rgb, nextColor);
|
expectEqualColor(nextState.color.rgb, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("adjust rgb g", () => {
|
test("adjust rgb g", () => {
|
||||||
@@ -220,7 +187,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.RGB_G,
|
component: colorlib.Component.RGB_G,
|
||||||
payload: (prev) => prev + 100,
|
payload: (prev) => prev + 100,
|
||||||
});
|
});
|
||||||
expectRGB(nextState.color.rgb, nextColor);
|
expectEqualColor(nextState.color.rgb, nextColor);
|
||||||
|
|
||||||
nextColor = colorlib.RGB.new(0, 50, 0);
|
nextColor = colorlib.RGB.new(0, 50, 0);
|
||||||
nextState = colorReducer(nextState, {
|
nextState = colorReducer(nextState, {
|
||||||
@@ -228,7 +195,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.RGB_G,
|
component: colorlib.Component.RGB_G,
|
||||||
payload: (prev) => prev - 50,
|
payload: (prev) => prev - 50,
|
||||||
});
|
});
|
||||||
expectRGB(nextState.color.rgb, nextColor);
|
expectEqualColor(nextState.color.rgb, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("adjust rgb b", () => {
|
test("adjust rgb b", () => {
|
||||||
@@ -238,7 +205,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.RGB_B,
|
component: colorlib.Component.RGB_B,
|
||||||
payload: (prev) => prev + 100,
|
payload: (prev) => prev + 100,
|
||||||
});
|
});
|
||||||
expectRGB(nextState.color.rgb, nextColor);
|
expectEqualColor(nextState.color.rgb, nextColor);
|
||||||
|
|
||||||
nextColor = colorlib.RGB.new(0, 0, 50);
|
nextColor = colorlib.RGB.new(0, 0, 50);
|
||||||
nextState = colorReducer(nextState, {
|
nextState = colorReducer(nextState, {
|
||||||
@@ -246,7 +213,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.RGB_B,
|
component: colorlib.Component.RGB_B,
|
||||||
payload: (prev) => prev - 50,
|
payload: (prev) => prev - 50,
|
||||||
});
|
});
|
||||||
expectRGB(nextState.color.rgb, nextColor);
|
expectEqualColor(nextState.color.rgb, nextColor);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -258,7 +225,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.HSV_H,
|
component: colorlib.Component.HSV_H,
|
||||||
payload: (prev) => prev + 100,
|
payload: (prev) => prev + 100,
|
||||||
});
|
});
|
||||||
expectHSV(nextState.color.hsv, nextColor);
|
expectEqualColor(nextState.color.hsv, nextColor);
|
||||||
|
|
||||||
nextColor = colorlib.HSV.new(50, 0, 0);
|
nextColor = colorlib.HSV.new(50, 0, 0);
|
||||||
nextState = colorReducer(nextState, {
|
nextState = colorReducer(nextState, {
|
||||||
@@ -266,7 +233,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.HSV_H,
|
component: colorlib.Component.HSV_H,
|
||||||
payload: (prev) => prev - 50,
|
payload: (prev) => prev - 50,
|
||||||
});
|
});
|
||||||
expectHSV(nextState.color.hsv, nextColor);
|
expectEqualColor(nextState.color.hsv, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("adjust hsv s", () => {
|
test("adjust hsv s", () => {
|
||||||
@@ -276,7 +243,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.HSV_S,
|
component: colorlib.Component.HSV_S,
|
||||||
payload: (prev) => prev + 1,
|
payload: (prev) => prev + 1,
|
||||||
});
|
});
|
||||||
expectHSV(nextState.color.hsv, nextColor);
|
expectEqualColor(nextState.color.hsv, nextColor);
|
||||||
|
|
||||||
nextColor = colorlib.HSV.new(0, 0.5, 0);
|
nextColor = colorlib.HSV.new(0, 0.5, 0);
|
||||||
nextState = colorReducer(nextState, {
|
nextState = colorReducer(nextState, {
|
||||||
@@ -284,7 +251,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.HSV_S,
|
component: colorlib.Component.HSV_S,
|
||||||
payload: (prev) => prev - 0.5,
|
payload: (prev) => prev - 0.5,
|
||||||
});
|
});
|
||||||
expectHSV(nextState.color.hsv, nextColor);
|
expectEqualColor(nextState.color.hsv, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("adjust hsv v", () => {
|
test("adjust hsv v", () => {
|
||||||
@@ -294,7 +261,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.HSV_V,
|
component: colorlib.Component.HSV_V,
|
||||||
payload: (prev) => prev + 1,
|
payload: (prev) => prev + 1,
|
||||||
});
|
});
|
||||||
expectHSV(nextState.color.hsv, nextColor);
|
expectEqualColor(nextState.color.hsv, nextColor);
|
||||||
|
|
||||||
nextColor = colorlib.HSV.new(0, 0, 0.5);
|
nextColor = colorlib.HSV.new(0, 0, 0.5);
|
||||||
nextState = colorReducer(nextState, {
|
nextState = colorReducer(nextState, {
|
||||||
@@ -302,7 +269,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.HSV_V,
|
component: colorlib.Component.HSV_V,
|
||||||
payload: (prev) => prev - 0.5,
|
payload: (prev) => prev - 0.5,
|
||||||
});
|
});
|
||||||
expectHSV(nextState.color.hsv, nextColor);
|
expectEqualColor(nextState.color.hsv, nextColor);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -314,7 +281,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.HCL_H,
|
component: colorlib.Component.HCL_H,
|
||||||
payload: (prev) => prev + 100,
|
payload: (prev) => prev + 100,
|
||||||
});
|
});
|
||||||
expectHCL(nextState.color.hcl, nextColor);
|
expectEqualColor(nextState.color.hcl, nextColor);
|
||||||
|
|
||||||
nextColor = colorlib.HCL.new(50, 0, 0);
|
nextColor = colorlib.HCL.new(50, 0, 0);
|
||||||
nextState = colorReducer(nextState, {
|
nextState = colorReducer(nextState, {
|
||||||
@@ -322,7 +289,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.HCL_H,
|
component: colorlib.Component.HCL_H,
|
||||||
payload: (prev) => prev - 50,
|
payload: (prev) => prev - 50,
|
||||||
});
|
});
|
||||||
expectHCL(nextState.color.hcl, nextColor);
|
expectEqualColor(nextState.color.hcl, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("adjust hcl c", () => {
|
test("adjust hcl c", () => {
|
||||||
@@ -332,7 +299,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.HCL_C,
|
component: colorlib.Component.HCL_C,
|
||||||
payload: (prev) => prev + 1,
|
payload: (prev) => prev + 1,
|
||||||
});
|
});
|
||||||
expectHCL(nextState.color.hcl, nextColor);
|
expectEqualColor(nextState.color.hcl, nextColor);
|
||||||
|
|
||||||
nextColor = colorlib.HCL.new(0, 0.5, 0);
|
nextColor = colorlib.HCL.new(0, 0.5, 0);
|
||||||
nextState = colorReducer(nextState, {
|
nextState = colorReducer(nextState, {
|
||||||
@@ -340,7 +307,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.HCL_C,
|
component: colorlib.Component.HCL_C,
|
||||||
payload: (prev) => prev - 0.5,
|
payload: (prev) => prev - 0.5,
|
||||||
});
|
});
|
||||||
expectHCL(nextState.color.hcl, nextColor);
|
expectEqualColor(nextState.color.hcl, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("adjust hcl l", () => {
|
test("adjust hcl l", () => {
|
||||||
@@ -350,7 +317,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.HCL_L,
|
component: colorlib.Component.HCL_L,
|
||||||
payload: (prev) => prev + 1,
|
payload: (prev) => prev + 1,
|
||||||
});
|
});
|
||||||
expectHCL(nextState.color.hcl, nextColor);
|
expectEqualColor(nextState.color.hcl, nextColor);
|
||||||
|
|
||||||
nextColor = colorlib.HCL.new(0, 0, 0.5);
|
nextColor = colorlib.HCL.new(0, 0, 0.5);
|
||||||
nextState = colorReducer(nextState, {
|
nextState = colorReducer(nextState, {
|
||||||
@@ -358,7 +325,7 @@ describe("color reducer", () => {
|
|||||||
component: colorlib.Component.HCL_L,
|
component: colorlib.Component.HCL_L,
|
||||||
payload: (prev) => prev - 0.5,
|
payload: (prev) => prev - 0.5,
|
||||||
});
|
});
|
||||||
expectHCL(nextState.color.hcl, nextColor);
|
expectEqualColor(nextState.color.hcl, nextColor);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -388,19 +355,19 @@ describe("color actions", () => {
|
|||||||
test("set rgb", () => {
|
test("set rgb", () => {
|
||||||
const nextColor = colorlib.RGB.new(1, 2, 3);
|
const nextColor = colorlib.RGB.new(1, 2, 3);
|
||||||
actions.rgb.setRGB(nextColor);
|
actions.rgb.setRGB(nextColor);
|
||||||
expectRGB(state.color.rgb, nextColor);
|
expectEqualColor(state.color.rgb, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("set hsv", () => {
|
test("set hsv", () => {
|
||||||
const nextColor = colorlib.HSV.new(1, 2, 3);
|
const nextColor = colorlib.HSV.new(1, 2, 3);
|
||||||
actions.hsv.setHSV(nextColor);
|
actions.hsv.setHSV(nextColor);
|
||||||
expectHSV(state.color.hsv, nextColor);
|
expectEqualColor(state.color.hsv, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("set hcl", () => {
|
test("set hcl", () => {
|
||||||
const nextColor = colorlib.HCL.new(1, 2, 3);
|
const nextColor = colorlib.HCL.new(1, 2, 3);
|
||||||
actions.hcl.setHCL(nextColor);
|
actions.hcl.setHCL(nextColor);
|
||||||
expectHCL(state.color.hcl, nextColor);
|
expectEqualColor(state.color.hcl, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("set hex", () => {
|
test("set hex", () => {
|
||||||
@@ -415,19 +382,19 @@ describe("color actions", () => {
|
|||||||
test("set rgb r", () => {
|
test("set rgb r", () => {
|
||||||
const nextColor = colorlib.RGB.new(100, 0, 0);
|
const nextColor = colorlib.RGB.new(100, 0, 0);
|
||||||
actions.rgb.setR(nextColor.r);
|
actions.rgb.setR(nextColor.r);
|
||||||
expectRGB(state.color.rgb, nextColor);
|
expectEqualColor(state.color.rgb, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("set rgb g", () => {
|
test("set rgb g", () => {
|
||||||
const nextColor = colorlib.RGB.new(0, 100, 0);
|
const nextColor = colorlib.RGB.new(0, 100, 0);
|
||||||
actions.rgb.setG(nextColor.g);
|
actions.rgb.setG(nextColor.g);
|
||||||
expectRGB(state.color.rgb, nextColor);
|
expectEqualColor(state.color.rgb, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("set rgb b", () => {
|
test("set rgb b", () => {
|
||||||
const nextColor = colorlib.RGB.new(0, 0, 100);
|
const nextColor = colorlib.RGB.new(0, 0, 100);
|
||||||
actions.rgb.setB(nextColor.b);
|
actions.rgb.setB(nextColor.b);
|
||||||
expectRGB(state.color.rgb, nextColor);
|
expectEqualColor(state.color.rgb, nextColor);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -435,19 +402,19 @@ describe("color actions", () => {
|
|||||||
test("set hsv h", () => {
|
test("set hsv h", () => {
|
||||||
const nextColor = colorlib.HSV.new(100, 0, 0);
|
const nextColor = colorlib.HSV.new(100, 0, 0);
|
||||||
actions.hsv.setH(nextColor.h);
|
actions.hsv.setH(nextColor.h);
|
||||||
expectHSV(state.color.hsv, nextColor);
|
expectEqualColor(state.color.hsv, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("set hsv s", () => {
|
test("set hsv s", () => {
|
||||||
const nextColor = colorlib.HSV.new(0, 0.5, 0);
|
const nextColor = colorlib.HSV.new(0, 0.5, 0);
|
||||||
actions.hsv.setS(nextColor.s);
|
actions.hsv.setS(nextColor.s);
|
||||||
expectHSV(state.color.hsv, nextColor);
|
expectEqualColor(state.color.hsv, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("set hsv v", () => {
|
test("set hsv v", () => {
|
||||||
const nextColor = colorlib.HSV.new(0, 0, 0.5);
|
const nextColor = colorlib.HSV.new(0, 0, 0.5);
|
||||||
actions.hsv.setV(nextColor.v);
|
actions.hsv.setV(nextColor.v);
|
||||||
expectHSV(state.color.hsv, nextColor);
|
expectEqualColor(state.color.hsv, nextColor);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -455,19 +422,19 @@ describe("color actions", () => {
|
|||||||
test("set hcl h", () => {
|
test("set hcl h", () => {
|
||||||
const nextColor = colorlib.HCL.new(100, 0, 0);
|
const nextColor = colorlib.HCL.new(100, 0, 0);
|
||||||
actions.hcl.setH(nextColor.h);
|
actions.hcl.setH(nextColor.h);
|
||||||
expectHCL(state.color.hcl, nextColor);
|
expectEqualColor(state.color.hcl, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("set hcl c", () => {
|
test("set hcl c", () => {
|
||||||
const nextColor = colorlib.HCL.new(0, 0.5, 0);
|
const nextColor = colorlib.HCL.new(0, 0.5, 0);
|
||||||
actions.hcl.setC(nextColor.c);
|
actions.hcl.setC(nextColor.c);
|
||||||
expectHCL(state.color.hcl, nextColor);
|
expectEqualColor(state.color.hcl, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("set hcl l", () => {
|
test("set hcl l", () => {
|
||||||
const nextColor = colorlib.HCL.new(0, 0, 0.5);
|
const nextColor = colorlib.HCL.new(0, 0, 0.5);
|
||||||
actions.hcl.setL(nextColor.l);
|
actions.hcl.setL(nextColor.l);
|
||||||
expectHCL(state.color.hcl, nextColor);
|
expectEqualColor(state.color.hcl, nextColor);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -477,31 +444,31 @@ describe("color actions", () => {
|
|||||||
test("adjust rgb r", () => {
|
test("adjust rgb r", () => {
|
||||||
let nextColor = colorlib.RGB.new(100, 0, 0);
|
let nextColor = colorlib.RGB.new(100, 0, 0);
|
||||||
actions.rgb.setR((prev) => prev + 100);
|
actions.rgb.setR((prev) => prev + 100);
|
||||||
expectRGB(state.color.rgb, nextColor);
|
expectEqualColor(state.color.rgb, nextColor);
|
||||||
|
|
||||||
nextColor = colorlib.RGB.new(50, 0, 0);
|
nextColor = colorlib.RGB.new(50, 0, 0);
|
||||||
actions.rgb.setR((prev) => prev - 50);
|
actions.rgb.setR((prev) => prev - 50);
|
||||||
expectRGB(state.color.rgb, nextColor);
|
expectEqualColor(state.color.rgb, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("adjust rgb g", () => {
|
test("adjust rgb g", () => {
|
||||||
let nextColor = colorlib.RGB.new(0, 100, 0);
|
let nextColor = colorlib.RGB.new(0, 100, 0);
|
||||||
actions.rgb.setG((prev) => prev + 100);
|
actions.rgb.setG((prev) => prev + 100);
|
||||||
expectRGB(state.color.rgb, nextColor);
|
expectEqualColor(state.color.rgb, nextColor);
|
||||||
|
|
||||||
nextColor = colorlib.RGB.new(0, 50, 0);
|
nextColor = colorlib.RGB.new(0, 50, 0);
|
||||||
actions.rgb.setG((prev) => prev - 50);
|
actions.rgb.setG((prev) => prev - 50);
|
||||||
expectRGB(state.color.rgb, nextColor);
|
expectEqualColor(state.color.rgb, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("adjust rgb b", () => {
|
test("adjust rgb b", () => {
|
||||||
let nextColor = colorlib.RGB.new(0, 0, 100);
|
let nextColor = colorlib.RGB.new(0, 0, 100);
|
||||||
actions.rgb.setB((prev) => prev + 100);
|
actions.rgb.setB((prev) => prev + 100);
|
||||||
expectRGB(state.color.rgb, nextColor);
|
expectEqualColor(state.color.rgb, nextColor);
|
||||||
|
|
||||||
nextColor = colorlib.RGB.new(0, 0, 50);
|
nextColor = colorlib.RGB.new(0, 0, 50);
|
||||||
actions.rgb.setB((prev) => prev - 50);
|
actions.rgb.setB((prev) => prev - 50);
|
||||||
expectRGB(state.color.rgb, nextColor);
|
expectEqualColor(state.color.rgb, nextColor);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -509,31 +476,31 @@ describe("color actions", () => {
|
|||||||
test("adjust hsv h", () => {
|
test("adjust hsv h", () => {
|
||||||
let nextColor = colorlib.HSV.new(100, 0, 0);
|
let nextColor = colorlib.HSV.new(100, 0, 0);
|
||||||
actions.hsv.setH((prev) => prev + 100);
|
actions.hsv.setH((prev) => prev + 100);
|
||||||
expectHSV(state.color.hsv, nextColor);
|
expectEqualColor(state.color.hsv, nextColor);
|
||||||
|
|
||||||
nextColor = colorlib.HSV.new(50, 0, 0);
|
nextColor = colorlib.HSV.new(50, 0, 0);
|
||||||
actions.hsv.setH((prev) => prev - 50);
|
actions.hsv.setH((prev) => prev - 50);
|
||||||
expectHSV(state.color.hsv, nextColor);
|
expectEqualColor(state.color.hsv, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("adjust hsv s", () => {
|
test("adjust hsv s", () => {
|
||||||
let nextColor = colorlib.HSV.new(0, 1, 0);
|
let nextColor = colorlib.HSV.new(0, 1, 0);
|
||||||
actions.hsv.setS((prev) => prev + 1);
|
actions.hsv.setS((prev) => prev + 1);
|
||||||
expectHSV(state.color.hsv, nextColor);
|
expectEqualColor(state.color.hsv, nextColor);
|
||||||
|
|
||||||
nextColor = colorlib.HSV.new(0, 0.5, 0);
|
nextColor = colorlib.HSV.new(0, 0.5, 0);
|
||||||
actions.hsv.setS((prev) => prev - 0.5);
|
actions.hsv.setS((prev) => prev - 0.5);
|
||||||
expectHSV(state.color.hsv, nextColor);
|
expectEqualColor(state.color.hsv, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("adjust hsv v", () => {
|
test("adjust hsv v", () => {
|
||||||
let nextColor = colorlib.HSV.new(0, 0, 1);
|
let nextColor = colorlib.HSV.new(0, 0, 1);
|
||||||
actions.hsv.setV((prev) => prev + 1);
|
actions.hsv.setV((prev) => prev + 1);
|
||||||
expectHSV(state.color.hsv, nextColor);
|
expectEqualColor(state.color.hsv, nextColor);
|
||||||
|
|
||||||
nextColor = colorlib.HSV.new(0, 0, 0.5);
|
nextColor = colorlib.HSV.new(0, 0, 0.5);
|
||||||
actions.hsv.setV((prev) => prev - 0.5);
|
actions.hsv.setV((prev) => prev - 0.5);
|
||||||
expectHSV(state.color.hsv, nextColor);
|
expectEqualColor(state.color.hsv, nextColor);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -541,31 +508,31 @@ describe("color actions", () => {
|
|||||||
test("adjust hcl h", () => {
|
test("adjust hcl h", () => {
|
||||||
let nextColor = colorlib.HCL.new(100, 0, 0);
|
let nextColor = colorlib.HCL.new(100, 0, 0);
|
||||||
actions.hcl.setH((prev) => prev + 100);
|
actions.hcl.setH((prev) => prev + 100);
|
||||||
expectHCL(state.color.hcl, nextColor);
|
expectEqualColor(state.color.hcl, nextColor);
|
||||||
|
|
||||||
nextColor = colorlib.HCL.new(50, 0, 0);
|
nextColor = colorlib.HCL.new(50, 0, 0);
|
||||||
actions.hcl.setH((prev) => prev - 50);
|
actions.hcl.setH((prev) => prev - 50);
|
||||||
expectHCL(state.color.hcl, nextColor);
|
expectEqualColor(state.color.hcl, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("adjust hcl c", () => {
|
test("adjust hcl c", () => {
|
||||||
let nextColor = colorlib.HCL.new(0, 1, 0);
|
let nextColor = colorlib.HCL.new(0, 1, 0);
|
||||||
actions.hcl.setC((prev) => prev + 1);
|
actions.hcl.setC((prev) => prev + 1);
|
||||||
expectHCL(state.color.hcl, nextColor);
|
expectEqualColor(state.color.hcl, nextColor);
|
||||||
|
|
||||||
nextColor = colorlib.HCL.new(0, 0.5, 0);
|
nextColor = colorlib.HCL.new(0, 0.5, 0);
|
||||||
actions.hcl.setC((prev) => prev - 0.5);
|
actions.hcl.setC((prev) => prev - 0.5);
|
||||||
expectHCL(state.color.hcl, nextColor);
|
expectEqualColor(state.color.hcl, nextColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("adjust hcl l", () => {
|
test("adjust hcl l", () => {
|
||||||
let nextColor = colorlib.HCL.new(0, 0, 1);
|
let nextColor = colorlib.HCL.new(0, 0, 1);
|
||||||
actions.hcl.setL((prev) => prev + 1);
|
actions.hcl.setL((prev) => prev + 1);
|
||||||
expectHCL(state.color.hcl, nextColor);
|
expectEqualColor(state.color.hcl, nextColor);
|
||||||
|
|
||||||
nextColor = colorlib.HCL.new(0, 0, 0.5);
|
nextColor = colorlib.HCL.new(0, 0, 0.5);
|
||||||
actions.hcl.setL((prev) => prev - 0.5);
|
actions.hcl.setL((prev) => prev - 0.5);
|
||||||
expectHCL(state.color.hcl, nextColor);
|
expectEqualColor(state.color.hcl, nextColor);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import type { CartesianSpace } from "@/types";
|
import type { CartesianSpace } from "@/types";
|
||||||
import { valueToPosition } from "@/util";
|
import { valueToPosition } from "@/util";
|
||||||
@@ -12,8 +12,8 @@ function TestSquare() {
|
|||||||
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
|
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
|
||||||
const [xValue, setXValue] = useState(0);
|
const [xValue, setXValue] = useState(0);
|
||||||
const [yValue, setYValue] = useState(0);
|
const [yValue, setYValue] = useState(0);
|
||||||
const xValueRange = { min: 0, max: 100 };
|
const xValueRange = useMemo(() => ({ min: 0, max: 100 }), []);
|
||||||
const yValueRange = { min: 0, max: 100 };
|
const yValueRange = useMemo(() => ({ min: 0, max: 100 }), []);
|
||||||
const [xPosition, setXPosition] = useState(0);
|
const [xPosition, setXPosition] = useState(0);
|
||||||
const [yPosition, setYPosition] = useState(0);
|
const [yPosition, setYPosition] = useState(0);
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ function TestSquare() {
|
|||||||
const newYPos = valueToPosition(yValue, dimensions.y - 1, yValueRange);
|
const newYPos = valueToPosition(yValue, dimensions.y - 1, yValueRange);
|
||||||
setXPosition(newXPos);
|
setXPosition(newXPos);
|
||||||
setYPosition(newYPos);
|
setYPosition(newYPos);
|
||||||
}, [xValue, yValue]);
|
}, [xValue, yValue, dimensions.x, dimensions.y, xValueRange, yValueRange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
|
|
||||||
import { useDragAndDrop } from "../dragAndDrop";
|
import { useDragAndDrop } from "../dragAndDrop";
|
||||||
import styles from "./dragAndDropTest.module.css";
|
import styles from "./dragAndDrop.test.module.css";
|
||||||
|
|
||||||
type Item = {
|
type Item = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
import { Hex as HexColor } from "colorlib";
|
||||||
|
import { beforeEach, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { mockUseReducer } from "@/testUtil";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PaletteCard,
|
||||||
|
PaletteCardAction,
|
||||||
|
PaletteCardActions,
|
||||||
|
PaletteCardState,
|
||||||
|
} from "../paletteCard";
|
||||||
|
import { createPaletteCardActions, paletteCardReducer } from "../paletteCard";
|
||||||
|
|
||||||
|
// Fixtures
|
||||||
|
|
||||||
|
const makeColor = (id: string, hex = "000000") => ({
|
||||||
|
id,
|
||||||
|
name: `Color ${id}`,
|
||||||
|
hex: HexColor.from_code(hex),
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeCard = (overrides: Partial<PaletteCard> = {}): PaletteCard => ({
|
||||||
|
id: "card_1",
|
||||||
|
name: "Test Palette",
|
||||||
|
colors: [],
|
||||||
|
selectedColorIds: [],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeState = (
|
||||||
|
present: PaletteCard,
|
||||||
|
history: PaletteCard[] = [],
|
||||||
|
future: PaletteCard[] = [],
|
||||||
|
): PaletteCardState => ({ present, history, future });
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tests
|
||||||
|
|
||||||
|
describe("set card name", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setup(emptyState);
|
||||||
|
});
|
||||||
|
|
||||||
|
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.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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import type { CartesianSpace } from "@/types";
|
import type { CartesianSpace } from "@/types";
|
||||||
import { Direction } from "@/types";
|
import { Direction } from "@/types";
|
||||||
@@ -17,7 +17,7 @@ function TestSlider({
|
|||||||
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
|
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
|
||||||
const [value, setValue] = useState(0);
|
const [value, setValue] = useState(0);
|
||||||
const [position, setPosition] = useState(0);
|
const [position, setPosition] = useState(0);
|
||||||
const valueRange = { min: 0, max: 100 };
|
const valueRange = useMemo(() => ({ min: 0, max: 100 }), []);
|
||||||
const { sliderRef, isDragging } = useSlider({
|
const { sliderRef, isDragging } = useSlider({
|
||||||
direction,
|
direction,
|
||||||
origin,
|
origin,
|
||||||
@@ -51,7 +51,7 @@ function TestSlider({
|
|||||||
} else {
|
} else {
|
||||||
setValue(0);
|
setValue(0);
|
||||||
}
|
}
|
||||||
}, [dimensions, direction]);
|
}, [dimensions, direction, position]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const maxPosition = chooseValueByDirection(
|
const maxPosition = chooseValueByDirection(
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
export function useResize(callback: () => void): () => void {
|
export function onResize(callback: () => void): () => void {
|
||||||
window.addEventListener("resize", callback);
|
window.addEventListener("resize", callback);
|
||||||
return () => window.removeEventListener("resize", callback);
|
return () => window.removeEventListener("resize", callback);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,7 @@
|
|||||||
import { createContext, useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
enum ViewportMode {
|
import { MediaQueryContext, ViewportMode } from "./context";
|
||||||
DESKTOP = "desktop",
|
|
||||||
MOBILE_LANDSCAPE = "mobile-landscape",
|
|
||||||
MOBILE_PORTRAIT = "mobile-portrait",
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MediaQueryContextType {
|
|
||||||
viewportMode: ViewportMode;
|
|
||||||
isDesktop: boolean;
|
|
||||||
isMobileLandscape: boolean;
|
|
||||||
isMobilePortrait: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MediaQueryContext = createContext<
|
|
||||||
MediaQueryContextType | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
export const MediaQueryProvider = ({ children }: { children: ReactNode }) => {
|
export const MediaQueryProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const [viewportMode, setViewportMode] = useState<ViewportMode>(
|
const [viewportMode, setViewportMode] = useState<ViewportMode>(
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
import { createContext, useReducer } from "react";
|
import { useMemo, useReducer } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import * as colorlib from "colorlib";
|
import * as colorlib from "colorlib";
|
||||||
|
|
||||||
import { colorReducer, createColorActions } from "@/hooks/color";
|
import { colorReducer, createColorActions } from "@/hooks/color";
|
||||||
import type { ColorActions } from "@/hooks/color";
|
|
||||||
|
|
||||||
interface SelectedColorContextType {
|
import { SelectedColorContext } from "./context";
|
||||||
selectedColor: colorlib.Color;
|
|
||||||
selectedColorActions: ColorActions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SelectedColorContext = createContext<
|
|
||||||
SelectedColorContextType | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
export const SelectedColorProvider = ({
|
export const SelectedColorProvider = ({
|
||||||
children,
|
children,
|
||||||
@@ -24,12 +16,18 @@ export const SelectedColorProvider = ({
|
|||||||
color: colorlib.Color.from_hex("00C9FA"),
|
color: colorlib.Color.from_hex("00C9FA"),
|
||||||
};
|
};
|
||||||
const [colorState, colorDispatch] = useReducer(colorReducer, initialState);
|
const [colorState, colorDispatch] = useReducer(colorReducer, initialState);
|
||||||
const colorActions = createColorActions(colorDispatch);
|
const colorActions = useMemo(
|
||||||
|
() => createColorActions(colorDispatch),
|
||||||
|
[colorDispatch],
|
||||||
|
);
|
||||||
|
|
||||||
const value = {
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
selectedColor: colorState.color,
|
selectedColor: colorState.color,
|
||||||
selectedColorActions: colorActions,
|
selectedColorActions: colorActions,
|
||||||
};
|
}),
|
||||||
|
[colorState.color, colorActions],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectedColorContext.Provider value={value}>
|
<SelectedColorContext.Provider value={value}>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
import * as colorlib from "colorlib";
|
||||||
|
|
||||||
|
import type { ColorActions } from "@/hooks/color";
|
||||||
|
|
||||||
|
export enum ViewportMode {
|
||||||
|
DESKTOP = "desktop",
|
||||||
|
MOBILE_LANDSCAPE = "mobile-landscape",
|
||||||
|
MOBILE_PORTRAIT = "mobile-portrait",
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaQueryContextType {
|
||||||
|
viewportMode: ViewportMode;
|
||||||
|
isDesktop: boolean;
|
||||||
|
isMobileLandscape: boolean;
|
||||||
|
isMobilePortrait: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MediaQueryContext = createContext<
|
||||||
|
MediaQueryContextType | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
interface SelectedColorContextType {
|
||||||
|
selectedColor: colorlib.Color;
|
||||||
|
selectedColorActions: ColorActions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelectedColorContext = createContext<
|
||||||
|
SelectedColorContextType | undefined
|
||||||
|
>(undefined);
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
|
|
||||||
import { MediaQueryContext } from "./MediaQueryProvider";
|
import { MediaQueryContext, SelectedColorContext } from "./context";
|
||||||
import { SelectedColorContext } from "./SelectedColorProvider";
|
|
||||||
|
|
||||||
export function useMediaQuery() {
|
export function useMediaQuery() {
|
||||||
const context = useContext(MediaQueryContext);
|
const context = useContext(MediaQueryContext);
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Color, HCL, HSV, Hex, RGB } from "colorlib";
|
||||||
|
import { expect } from "vitest";
|
||||||
|
|
||||||
|
export const mockUseReducer = <T extends object, U>(
|
||||||
|
reducer: (state: T, action: U) => T,
|
||||||
|
initialArg: T,
|
||||||
|
): [T, (value: U) => void] => {
|
||||||
|
let currentState = initialArg;
|
||||||
|
|
||||||
|
const state = new Proxy({} as T, {
|
||||||
|
get: (_, prop) => currentState[prop as keyof T],
|
||||||
|
});
|
||||||
|
|
||||||
|
const dispatch = (value: U) => {
|
||||||
|
const nextState = reducer(currentState, value);
|
||||||
|
currentState = nextState;
|
||||||
|
};
|
||||||
|
return [state, dispatch];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expectEqualColor = <T extends { equals(other: T): boolean }>(
|
||||||
|
value: T,
|
||||||
|
expected: T,
|
||||||
|
) => {
|
||||||
|
if (!value.equals(expected)) {
|
||||||
|
if (value instanceof Color && expected instanceof Color) {
|
||||||
|
expect(value.hex.to_code()).toBe(expected.hex.to_code());
|
||||||
|
} else if (value instanceof HCL && expected instanceof HCL) {
|
||||||
|
expect(value.h).toBe(expected.h);
|
||||||
|
expect(value.c).toBe(expected.c);
|
||||||
|
expect(value.l).toBe(expected.l);
|
||||||
|
} else if (value instanceof HSV && expected instanceof HSV) {
|
||||||
|
expect(value.h).toBe(expected.h);
|
||||||
|
expect(value.s).toBe(expected.s);
|
||||||
|
expect(value.v).toBe(expected.v);
|
||||||
|
} else if (
|
||||||
|
(value instanceof RGB && expected instanceof RGB) ||
|
||||||
|
(value instanceof Hex && expected instanceof Hex)
|
||||||
|
) {
|
||||||
|
expect(value.r).toBe(expected.r);
|
||||||
|
expect(value.g).toBe(expected.g);
|
||||||
|
expect(value.b).toBe(expected.b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(value.equals(expected)).toBe(true);
|
||||||
|
};
|
||||||
@@ -111,3 +111,11 @@ export function roundTo(
|
|||||||
export function formatCssRgb(hex: Hex) {
|
export function formatCssRgb(hex: Hex) {
|
||||||
return `rgb(${hex.r},${hex.g},${hex.b})`;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
|
import * as path from "path";
|
||||||
import topLevelAwait from "vite-plugin-top-level-await";
|
import topLevelAwait from "vite-plugin-top-level-await";
|
||||||
import wasm from "vite-plugin-wasm";
|
import wasm from "vite-plugin-wasm";
|
||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [wasm(), topLevelAwait()],
|
plugins: [wasm(), topLevelAwait()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user