This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
width: 100%;
|
||||
padding: 5px 0px 0px;
|
||||
padding: 10px 0px 0px;
|
||||
|
||||
/* Improve scrolling experience */
|
||||
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
|
||||
@@ -17,32 +17,36 @@
|
||||
height: 50px;
|
||||
width: 25px;
|
||||
margin: 5px;
|
||||
border: 2px solid #7a7a7a;
|
||||
transition:
|
||||
margin 200ms,
|
||||
height 200ms,
|
||||
width 200ms;
|
||||
border: 1px solid #7a7a7a;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.historyColor:hover {
|
||||
height: 56px;
|
||||
width: 31px;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.historyColor:first-of-type:hover {
|
||||
margin-left: 12px;
|
||||
transition:
|
||||
box-shadow 150ms ease-out,
|
||||
transform 150ms ease-out;
|
||||
box-shadow:
|
||||
rgba(60, 64, 67, 0.25) 0px 1px 2px 0px,
|
||||
rgba(60, 64, 67, 0.1) 0px 1px 3px 0px;
|
||||
}
|
||||
|
||||
.historyColor:first-of-type {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
.historyColor:last-of-type:hover {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.historyColor:last-of-type {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.historyColor:hover {
|
||||
transform: translateY(-0.5px);
|
||||
box-shadow:
|
||||
rgba(60, 64, 67, 0.3) 0px 2px 6px 0px,
|
||||
rgba(60, 64, 67, 0.15) 0px 3px 8px 2px;
|
||||
}
|
||||
|
||||
.historyColor:active {
|
||||
transform: translateY(0.5px);
|
||||
box-shadow:
|
||||
rgba(60, 64, 67, 0.2) 0px 0px 1px 0px,
|
||||
inset 0px 1px 2px rgba(0, 0, 0, 0.2);
|
||||
transition-duration: 50ms;
|
||||
}
|
||||
|
||||
@@ -53,10 +53,11 @@ describe("color history", () => {
|
||||
cy.enableTransitions();
|
||||
});
|
||||
|
||||
it("adds stable color values after 1 second", () => {
|
||||
it("adds stable color values after 3 seconds", () => {
|
||||
// add stable values to history
|
||||
cy.dataCy("hex-value-input").as("value").clear().type("#00F536");
|
||||
cy.tick(1000);
|
||||
cy.dataCy("hex-value-input").as("value").clear().type("#00F536").blur();
|
||||
cy.wait(0); // let blur take effect
|
||||
cy.tick(3000);
|
||||
|
||||
cy.dataCy("color-history").children().should("have.length", 1);
|
||||
cy.dataCy("history-color-0").should(
|
||||
@@ -65,8 +66,9 @@ describe("color history", () => {
|
||||
"rgb(0, 245, 54)",
|
||||
);
|
||||
|
||||
cy.get("@value").clear().type("#E23AEC");
|
||||
cy.tick(1000);
|
||||
cy.get("@value").clear().type("#E23AEC").blur();
|
||||
cy.wait(0);
|
||||
cy.tick(3000);
|
||||
|
||||
cy.dataCy("color-history").children().should("have.length", 2);
|
||||
cy.dataCy("history-color-0").should(
|
||||
@@ -81,14 +83,15 @@ describe("color history", () => {
|
||||
|
||||
// disable history
|
||||
cy.dataCy("disabled-checkbox").click();
|
||||
cy.get("@value").clear().type("#00C3EE");
|
||||
cy.tick(1000);
|
||||
cy.get("@value").clear().type("#00C3EE").blur();
|
||||
cy.wait(0);
|
||||
cy.tick(3000);
|
||||
|
||||
cy.dataCy("color-history").children().should("have.length", 2);
|
||||
|
||||
// re-enable history
|
||||
cy.dataCy("disabled-checkbox").click();
|
||||
cy.tick(1000);
|
||||
cy.tick(3000);
|
||||
|
||||
cy.dataCy("color-history").children().should("have.length", 3);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ function ColorHistory({
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const [history, setHistory] = useState<Color[]>([]);
|
||||
const colorValue = color.hex.to_code();
|
||||
const maxItems = 50;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -31,10 +32,10 @@ function ColorHistory({
|
||||
const newHistory = [color, ...prev];
|
||||
return newHistory.slice(0, maxItems);
|
||||
});
|
||||
}, 1000);
|
||||
}, 2000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [color, disabled]);
|
||||
}, [colorValue, disabled]);
|
||||
|
||||
const handleClick = (historyColor: Color) => {
|
||||
setColor(historyColor);
|
||||
|
||||
@@ -27,17 +27,21 @@ function ColorBar({
|
||||
parentDimensions: CartesianSpace;
|
||||
}) {
|
||||
// State
|
||||
const [colorBar, setColorBar] = useState<colorlib.ColorBar | null>(null);
|
||||
const [origin, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
|
||||
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
|
||||
|
||||
// Refs
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const colorBarRef = useRef<colorlib.ColorBar | null>(null);
|
||||
|
||||
// Hooks
|
||||
const smoothAnimation = useSmoothAnimation();
|
||||
|
||||
// Dimensions
|
||||
const barWidth = parentDimensions.x > 0 ? parentDimensions.x - 54 : 0;
|
||||
const barHeight = containerRef.current?.clientHeight;
|
||||
|
||||
// Slider interaction
|
||||
const { sliderRef } = useSlider({
|
||||
direction: Direction.HORIZONTAL,
|
||||
@@ -50,13 +54,14 @@ function ColorBar({
|
||||
|
||||
// Update canvas when hue/luminance changes
|
||||
useEffect(() => {
|
||||
if (colorBar && canvasRef.current) {
|
||||
const bar = colorBarRef.current;
|
||||
if (bar && canvasRef.current) {
|
||||
smoothAnimation(() => {
|
||||
colorBar.fill_color(hue, luminance);
|
||||
refreshColorBar(canvasRef.current!, colorBar);
|
||||
bar.fill_color(hue, luminance);
|
||||
refreshColorBar(canvasRef.current!, bar);
|
||||
});
|
||||
}
|
||||
}, [hue, luminance, colorBar, smoothAnimation]);
|
||||
}, [hue, luminance]);
|
||||
|
||||
// Get measurements
|
||||
useEffect(() => {
|
||||
@@ -71,30 +76,31 @@ function ColorBar({
|
||||
|
||||
// Resize color bar
|
||||
useEffect(() => {
|
||||
if (containerRef.current && canvasRef.current && parentDimensions.x > 0) {
|
||||
const newHeight = containerRef.current.clientHeight;
|
||||
const newWidth = parentDimensions.x - 54;
|
||||
const newColorBar = new colorlib.ColorBar(newWidth, newHeight);
|
||||
if (!containerRef.current || !canvasRef.current || parentDimensions.x <= 0)
|
||||
return;
|
||||
|
||||
setColorBar(newColorBar);
|
||||
colorBarRef.current?.free();
|
||||
|
||||
if (newColorBar) {
|
||||
smoothAnimation(() => {
|
||||
if (canvasRef.current) {
|
||||
newColorBar.fill_color(hue, luminance);
|
||||
refreshColorBar(canvasRef.current!, newColorBar);
|
||||
}
|
||||
});
|
||||
const newHeight = containerRef.current.clientHeight;
|
||||
const newWidth = parentDimensions.x - 54;
|
||||
const bar = new colorlib.ColorBar(newWidth, newHeight);
|
||||
colorBarRef.current = bar;
|
||||
|
||||
smoothAnimation(() => {
|
||||
if (canvasRef.current) {
|
||||
bar.fill_color(hue, luminance);
|
||||
refreshColorBar(canvasRef.current!, bar);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
containerRef,
|
||||
canvasRef,
|
||||
parentDimensions,
|
||||
hue,
|
||||
luminance,
|
||||
smoothAnimation,
|
||||
]);
|
||||
});
|
||||
}, [parentDimensions]);
|
||||
|
||||
// free on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
colorBarRef.current?.free();
|
||||
colorBarRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.colorBarWrapper} ref={containerRef}>
|
||||
@@ -102,15 +108,11 @@ function ColorBar({
|
||||
className={styles.colorBar}
|
||||
ref={sliderRef}
|
||||
style={{
|
||||
width: colorBar?.get_width(),
|
||||
height: colorBar?.get_height(),
|
||||
width: barWidth,
|
||||
height: barHeight,
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={colorBar?.get_width()}
|
||||
height={colorBar?.get_height()}
|
||||
/>
|
||||
<canvas ref={canvasRef} width={barWidth} height={barHeight} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
.container {
|
||||
display: grid;
|
||||
grid-template-columns: 25px 1fr 25px;
|
||||
grid-template-rows: 50px 1fr 25px;
|
||||
grid-template-rows: 1fr 25px;
|
||||
grid-template-areas:
|
||||
". preview ."
|
||||
"leftGrip square rightGrip"
|
||||
". bottomGrip ."
|
||||
". bar .";
|
||||
@@ -13,14 +12,20 @@
|
||||
grid-area: preview;
|
||||
height: 25px;
|
||||
margin-bottom: 15px;
|
||||
border: 2px solid #7a7a7a;
|
||||
border: 1px solid #7a7a7a;
|
||||
box-shadow:
|
||||
rgba(0, 0, 0, 0.16) 0px 3px 6px,
|
||||
rgba(0, 0, 0, 0.23) 0px 3px 6px;
|
||||
}
|
||||
|
||||
.pickerSquare {
|
||||
grid-area: square;
|
||||
position: relative;
|
||||
aspect-ratio: 1/1;
|
||||
border: 2px solid #7a7a7a;
|
||||
border: 1px solid #7a7a7a;
|
||||
box-shadow:
|
||||
rgba(0, 0, 0, 0.16) 0px 3px 6px,
|
||||
rgba(0, 0, 0, 0.23) 0px 3px 6px;
|
||||
}
|
||||
|
||||
.pickerBar {
|
||||
@@ -28,7 +33,49 @@
|
||||
position: relative;
|
||||
height: 25px;
|
||||
margin-top: 15px;
|
||||
border: 2px solid #7a7a7a;
|
||||
border: 1px solid #7a7a7a;
|
||||
box-shadow:
|
||||
rgba(0, 0, 0, 0.16) 0px 3px 6px,
|
||||
rgba(0, 0, 0, 0.23) 0px 3px 6px;
|
||||
}
|
||||
|
||||
/* Grips */
|
||||
.gripSlider {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.grip {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #e7e7e7;
|
||||
border: 1px solid #c9c9c9;
|
||||
border-radius: 4px;
|
||||
color: #8b8b8b;
|
||||
transition:
|
||||
background-color 150ms,
|
||||
box-shadow 150ms;
|
||||
box-shadow:
|
||||
rgba(0, 0, 0, 0.12) 0px 2px 4px,
|
||||
rgba(0, 0, 0, 0.2) 0px 2px 4px;
|
||||
}
|
||||
|
||||
.horizontalGrip .grip {
|
||||
height: 20px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.verticalGripLeft .grip,
|
||||
.verticalGripRight .grip {
|
||||
height: 32px;
|
||||
width: 22px;
|
||||
}
|
||||
|
||||
.horizontalGrip {
|
||||
grid-area: bottomGrip;
|
||||
}
|
||||
|
||||
.verticalGripLeft {
|
||||
@@ -39,10 +86,6 @@
|
||||
grid-area: rightGrip;
|
||||
}
|
||||
|
||||
.horizontalGrip {
|
||||
grid-area: bottomGrip;
|
||||
}
|
||||
|
||||
/* Color Square */
|
||||
.colorSquareWrapper {
|
||||
height: 100%;
|
||||
@@ -94,16 +137,3 @@
|
||||
box-shadow 400ms;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Square Grips */
|
||||
.gripSlider {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.grip {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
@@ -41,19 +41,19 @@ function ColorPicker({
|
||||
|
||||
return (
|
||||
<div className={styles.container} ref={containerRef}>
|
||||
<div
|
||||
className={styles.preview}
|
||||
style={{
|
||||
backgroundColor: formatCssRgb(color.hex),
|
||||
}}
|
||||
></div>
|
||||
{/* <div */}
|
||||
{/* className={styles.preview} */}
|
||||
{/* style={{ */}
|
||||
{/* backgroundColor: formatCssRgb(color.hex), */}
|
||||
{/* }} */}
|
||||
{/* ></div> */}
|
||||
<div className={styles.verticalGripLeft}>
|
||||
<GripSlider
|
||||
direction={Direction.VERTICAL}
|
||||
value={color.hcl.l}
|
||||
setValue={actions.hcl.setL}
|
||||
valueRange={lumRange}
|
||||
arrowDirection="right"
|
||||
position="left"
|
||||
invert={true}
|
||||
parentDimensions={dimensions}
|
||||
/>
|
||||
@@ -77,7 +77,7 @@ function ColorPicker({
|
||||
value={color.hcl.l}
|
||||
setValue={actions.hcl.setL}
|
||||
valueRange={lumRange}
|
||||
arrowDirection="left"
|
||||
position="right"
|
||||
invert={true}
|
||||
parentDimensions={dimensions}
|
||||
/>
|
||||
@@ -88,7 +88,7 @@ function ColorPicker({
|
||||
value={color.hcl.h}
|
||||
setValue={actions.hcl.setH}
|
||||
valueRange={hueRange}
|
||||
arrowDirection="up"
|
||||
position="bottom"
|
||||
parentDimensions={dimensions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -23,15 +23,13 @@ function ColorSquare({
|
||||
parentDimensions: CartesianSpace;
|
||||
}) {
|
||||
// State
|
||||
const [colorSquare, setColorSquare] = useState<colorlib.ColorSquare | null>(
|
||||
null,
|
||||
);
|
||||
const [origin, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
|
||||
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
|
||||
|
||||
// Refs
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const colorSquareRef = useRef<colorlib.ColorSquare | null>(null);
|
||||
|
||||
// Hooks
|
||||
const smoothAnimation = useSmoothAnimation();
|
||||
@@ -56,13 +54,14 @@ function ColorSquare({
|
||||
|
||||
// Update canvas when chroma changes
|
||||
useEffect(() => {
|
||||
if (colorSquare && canvasRef.current) {
|
||||
const square = colorSquareRef.current;
|
||||
if (square && canvasRef.current) {
|
||||
smoothAnimation(() => {
|
||||
colorSquare.fill_chroma(chroma);
|
||||
refreshColorSquare(canvasRef.current!, colorSquare);
|
||||
square.fill_chroma(chroma);
|
||||
refreshColorSquare(canvasRef.current!, square);
|
||||
});
|
||||
}
|
||||
}, [chroma, colorSquare, smoothAnimation]);
|
||||
}, [chroma]);
|
||||
|
||||
// Add event listeners
|
||||
useEffect(() => {
|
||||
@@ -78,26 +77,34 @@ function ColorSquare({
|
||||
return onResize(() =>
|
||||
setMeasurements(containerRef, setOrigin, setDimensions),
|
||||
);
|
||||
}, [containerRef, parentDimensions]);
|
||||
}, [parentDimensions]);
|
||||
|
||||
// Resize square
|
||||
useEffect(() => {
|
||||
if (containerRef.current && canvasRef.current && parentDimensions.x > 0) {
|
||||
const newSize = parentDimensions.x - 54;
|
||||
const newColorSquare = new colorlib.ColorSquare(newSize);
|
||||
if (!containerRef.current || !canvasRef.current || parentDimensions.x <= 0)
|
||||
return;
|
||||
|
||||
setColorSquare(newColorSquare);
|
||||
colorSquareRef.current?.free();
|
||||
|
||||
if (newColorSquare) {
|
||||
smoothAnimation(() => {
|
||||
if (canvasRef.current) {
|
||||
newColorSquare.fill_chroma(chroma);
|
||||
refreshColorSquare(canvasRef.current, newColorSquare);
|
||||
}
|
||||
});
|
||||
const newSize = parentDimensions.x - 54;
|
||||
const square = new colorlib.ColorSquare(newSize);
|
||||
colorSquareRef.current = square;
|
||||
|
||||
smoothAnimation(() => {
|
||||
if (canvasRef.current) {
|
||||
square.fill_chroma(chroma);
|
||||
refreshColorSquare(canvasRef.current, square);
|
||||
}
|
||||
}
|
||||
}, [containerRef, canvasRef, parentDimensions, chroma, smoothAnimation]);
|
||||
});
|
||||
}, [containerRef, canvasRef, parentDimensions]);
|
||||
|
||||
// free on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
colorSquareRef.current?.free();
|
||||
colorSquareRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.colorSquareWrapper} ref={containerRef}>
|
||||
@@ -105,14 +112,14 @@ function ColorSquare({
|
||||
className={styles.colorSquare}
|
||||
ref={crosshairRef}
|
||||
style={{
|
||||
width: colorSquare?.get_size(),
|
||||
height: colorSquare?.get_size(),
|
||||
width: colorSquareRef.current?.get_size(),
|
||||
height: colorSquareRef.current?.get_size(),
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={colorSquare?.get_size()}
|
||||
height={colorSquare?.get_size()}
|
||||
width={colorSquareRef.current?.get_size()}
|
||||
height={colorSquareRef.current?.get_size()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import * as colorlib from "colorlib";
|
||||
|
||||
import { useContrastToken } from "@/hooks/contrast";
|
||||
import { onResize } from "@/hooks/window";
|
||||
import type { CartesianSpace } from "@/types";
|
||||
import { formatCssRgb, setMeasurements, valueToPosition } from "@/util";
|
||||
@@ -21,15 +22,12 @@ export function SquareCrosshair({
|
||||
}) {
|
||||
const [, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
|
||||
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
|
||||
const [darkCrosshairs, setDarkCrosshairs] = useState(true);
|
||||
const crosshairColor = { dark: "black", light: "white" };
|
||||
const token = useContrastToken(() => luminance);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const lumRange = { min: 0, max: 1 };
|
||||
const hueRange = { min: 0, max: 359 };
|
||||
|
||||
useEffect(() => {
|
||||
setDarkCrosshairs(luminance > 0.5);
|
||||
}, [luminance]);
|
||||
|
||||
useEffect(() => {
|
||||
setMeasurements(containerRef, setOrigin, setDimensions);
|
||||
return onResize(() =>
|
||||
@@ -44,8 +42,8 @@ export function SquareCrosshair({
|
||||
style={{
|
||||
width: 1,
|
||||
height: dimensions.y,
|
||||
backgroundColor: darkCrosshairs ? "black" : "white",
|
||||
boxShadow: darkCrosshairs ? "0 0 2px black" : "0 0 2px white",
|
||||
backgroundColor: crosshairColor[token],
|
||||
boxShadow: `0 0 2px ${crosshairColor[token]}`,
|
||||
left: valueToPosition(hue, dimensions.x - 1, hueRange),
|
||||
top: 0,
|
||||
}}
|
||||
@@ -55,8 +53,8 @@ export function SquareCrosshair({
|
||||
style={{
|
||||
width: dimensions.x,
|
||||
height: 1,
|
||||
backgroundColor: darkCrosshairs ? "black" : "white",
|
||||
boxShadow: darkCrosshairs ? "0 0 2px black" : "0 0 2px white",
|
||||
backgroundColor: crosshairColor[token],
|
||||
boxShadow: `0 0 2px ${crosshairColor[token]}`,
|
||||
left: 0,
|
||||
top: valueToPosition(1 - luminance, dimensions.y - 1, lumRange),
|
||||
}}
|
||||
@@ -64,8 +62,8 @@ export function SquareCrosshair({
|
||||
<div
|
||||
className={styles.crossEye}
|
||||
style={{
|
||||
borderColor: darkCrosshairs ? "black" : "white",
|
||||
boxShadow: darkCrosshairs ? "0 0 1px black" : "0 0 1px white",
|
||||
borderColor: crosshairColor[token],
|
||||
boxShadow: `0 0 1px ${crosshairColor[token]}`,
|
||||
backgroundColor: formatCssRgb(hex),
|
||||
top: valueToPosition(1 - luminance, dimensions.y - 1, lumRange) - 6,
|
||||
left: valueToPosition(hue, dimensions.x - 1, hueRange) - 6,
|
||||
@@ -88,14 +86,11 @@ export function BarCrosshair({
|
||||
}) {
|
||||
const [, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
|
||||
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
|
||||
const [darkCrosshairs, setDarkCrosshairs] = useState(true);
|
||||
const crosshairColor = { dark: "black", light: "white" };
|
||||
const token = useContrastToken(() => luminance);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chromaRange = { min: 0, max: 1 };
|
||||
|
||||
useEffect(() => {
|
||||
setDarkCrosshairs(luminance > 0.5);
|
||||
}, [luminance]);
|
||||
|
||||
useEffect(() => {
|
||||
setMeasurements(containerRef, setOrigin, setDimensions);
|
||||
return onResize(() =>
|
||||
@@ -110,8 +105,8 @@ export function BarCrosshair({
|
||||
style={{
|
||||
width: 1,
|
||||
height: dimensions.y,
|
||||
backgroundColor: darkCrosshairs ? "black" : "white",
|
||||
boxShadow: darkCrosshairs ? "0 0 2px black" : "0 0 2px white",
|
||||
backgroundColor: crosshairColor[token],
|
||||
boxShadow: `0 0 2px ${crosshairColor[token]}`,
|
||||
left: valueToPosition(chroma, dimensions.x - 1, chromaRange),
|
||||
top: 0,
|
||||
}}
|
||||
@@ -119,8 +114,8 @@ export function BarCrosshair({
|
||||
<div
|
||||
className={styles.crossEye}
|
||||
style={{
|
||||
borderColor: darkCrosshairs ? "black" : "white",
|
||||
boxShadow: darkCrosshairs ? "0 0 1px black" : "0 0 1px white",
|
||||
borderColor: crosshairColor[token],
|
||||
boxShadow: `0 0 1px ${crosshairColor[token]}`,
|
||||
backgroundColor: formatCssRgb(hex),
|
||||
top: 6,
|
||||
left: valueToPosition(chroma, dimensions.x - 1, chromaRange) - 6,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { GripHorizontal, GripVertical } from "lucide-react";
|
||||
|
||||
import type { Setter } from "@/hooks/color";
|
||||
import { useSlider } from "@/hooks/slider";
|
||||
import { onResize } from "@/hooks/window";
|
||||
@@ -18,7 +20,7 @@ function GripSlider({
|
||||
value,
|
||||
setValue,
|
||||
valueRange,
|
||||
arrowDirection,
|
||||
position,
|
||||
invert = false,
|
||||
parentDimensions,
|
||||
}: {
|
||||
@@ -26,7 +28,7 @@ function GripSlider({
|
||||
value: number;
|
||||
setValue: Setter;
|
||||
valueRange: Range;
|
||||
arrowDirection: "up" | "left" | "right";
|
||||
position: "bottom" | "right" | "left";
|
||||
invert?: boolean;
|
||||
parentDimensions: CartesianSpace;
|
||||
}) {
|
||||
@@ -52,48 +54,18 @@ function GripSlider({
|
||||
return onResize(() => setMeasurements(sliderRef, setOrigin, setDimensions));
|
||||
}, [sliderRef, parentDimensions]);
|
||||
|
||||
const upArrowStyle = {
|
||||
borderLeft: "12px solid transparent",
|
||||
borderRight: "12px solid transparent",
|
||||
borderBottom: "25px solid black",
|
||||
};
|
||||
|
||||
const leftArrowStyle = {
|
||||
borderTop: "12px solid transparent",
|
||||
borderBottom: "12px solid transparent",
|
||||
borderRight: "25px solid black",
|
||||
};
|
||||
|
||||
const rightArrowStyle = {
|
||||
borderTop: "12px solid transparent",
|
||||
borderBottom: "12px solid transparent",
|
||||
borderLeft: "25px solid black",
|
||||
};
|
||||
|
||||
const arrowStyle = (function () {
|
||||
switch (arrowDirection) {
|
||||
case "up":
|
||||
return upArrowStyle;
|
||||
case "left":
|
||||
return leftArrowStyle;
|
||||
case "right":
|
||||
return rightArrowStyle;
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
const isVertical = direction === Direction.VERTICAL;
|
||||
|
||||
return (
|
||||
<div className={styles.gripSlider} ref={sliderRef}>
|
||||
<div
|
||||
className={styles.grip}
|
||||
style={{
|
||||
...arrowStyle,
|
||||
cursor: isDragging ? "grabbing" : "grab",
|
||||
top: chooseValueByDirection(
|
||||
direction,
|
||||
0,
|
||||
-12 +
|
||||
6,
|
||||
-17 +
|
||||
valueToPosition(
|
||||
valueRange.max - value,
|
||||
dimensions.y - 1,
|
||||
@@ -102,11 +74,24 @@ function GripSlider({
|
||||
),
|
||||
left: chooseValueByDirection(
|
||||
direction,
|
||||
-12 + valueToPosition(value, dimensions.x - 1, valueRange),
|
||||
0,
|
||||
-16 + valueToPosition(value, dimensions.x - 1, valueRange),
|
||||
(() => {
|
||||
if (position === "right") {
|
||||
return 6;
|
||||
} else if (position === "left") {
|
||||
return -4;
|
||||
}
|
||||
return 0;
|
||||
})(),
|
||||
),
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{isVertical ? (
|
||||
<GripVertical size={24} strokeWidth={3} />
|
||||
) : (
|
||||
<GripHorizontal size={24} strokeWidth={3} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,12 @@
|
||||
max-height: 94px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 2px solid #7a7a7a;
|
||||
background-color: #f7f7f7;
|
||||
border: 1px solid #c9c9c9;
|
||||
border-radius: 4px;
|
||||
box-shadow:
|
||||
rgba(0, 0, 0, 0.1) 0px 2px 4px,
|
||||
rgba(0, 0, 0, 0.15) 0px 2px 4px;
|
||||
}
|
||||
|
||||
.componentWrapper {
|
||||
@@ -22,7 +27,6 @@
|
||||
min-height: 0;
|
||||
font-family: monospace;
|
||||
border-top: 1px solid #7a7a7a;
|
||||
border-bottom: 1px solid #7a7a7a;
|
||||
}
|
||||
|
||||
.componentWrapper:first-of-type {
|
||||
@@ -37,7 +41,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-right: 2px solid #7a7a7a;
|
||||
border-right: 1px solid #7a7a7a;
|
||||
}
|
||||
|
||||
.section:last-of-type {
|
||||
@@ -65,7 +69,7 @@
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background-color: #aaa;
|
||||
background-color: #c1c1c1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -84,6 +88,9 @@
|
||||
user-select: none;
|
||||
background: none;
|
||||
border: none;
|
||||
transition-property: background-color;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
@@ -114,9 +121,14 @@
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
font-family: monospace;
|
||||
border: 2px solid #7a7a7a;
|
||||
background-color: #f7f7f7;
|
||||
border: 1px solid #c9c9c9;
|
||||
border-radius: 4px;
|
||||
height: 25px;
|
||||
max-width: 150px;
|
||||
box-shadow:
|
||||
rgba(0, 0, 0, 0.1) 0px 2px 4px,
|
||||
rgba(0, 0, 0, 0.15) 0px 2px 4px;
|
||||
}
|
||||
|
||||
.hexLabel {
|
||||
|
||||
@@ -45,6 +45,11 @@ function ColorValues({
|
||||
|
||||
return (
|
||||
<div className={styles.colorValuesWrapper} ref={wrapperRef}>
|
||||
<HexEditor
|
||||
color={color.hex}
|
||||
actions={actions.hex}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<SpaceEditor
|
||||
space="HCL"
|
||||
color={color.hcl}
|
||||
@@ -63,11 +68,6 @@ function ColorValues({
|
||||
actions={actions.rgb}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<HexEditor
|
||||
color={color.hex}
|
||||
actions={actions.hex}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,11 +49,12 @@ describe("hex editor tests", () => {
|
||||
cy.get("@color").should("have.text", "000000");
|
||||
|
||||
cy.get("@value").blur();
|
||||
cy.get("@value").should("have.value", "#000000");
|
||||
cy.get("@value").should("have.value", "#000");
|
||||
cy.get("@color").should("have.text", "000000");
|
||||
|
||||
// Type a new value
|
||||
cy.get("@value").focus().type("{backspace}");
|
||||
cy.get("@value").focus();
|
||||
cy.get("@value").type("{backspace}");
|
||||
cy.get("@value").should("have.value", "");
|
||||
cy.get("@color").should("have.text", "000000");
|
||||
|
||||
@@ -62,11 +63,11 @@ describe("hex editor tests", () => {
|
||||
cy.get("@color").should("have.text", "000000");
|
||||
|
||||
cy.get("@value").type("c");
|
||||
cy.get("@value").should("have.value", "#ABC");
|
||||
cy.get("@color").should("have.text", "AABBCC");
|
||||
cy.get("@value").should("have.value", "abc");
|
||||
cy.get("@color").should("have.text", "000000");
|
||||
|
||||
cy.get("@value").blur();
|
||||
cy.get("@value").should("have.value", "#AABBCC");
|
||||
cy.get("@value").should("have.value", "#ABC");
|
||||
cy.get("@color").should("have.text", "AABBCC");
|
||||
|
||||
// Invalid blur resets to last valid color
|
||||
|
||||
@@ -102,8 +102,8 @@ describe("space editor tests", () => {
|
||||
});
|
||||
|
||||
cy.dataCy("rgb-value").should("have.text", "RGB (16, 75, 74)");
|
||||
cy.dataCy("hsv-value").should("have.text", "HSV (178, 0.78, 0.29)");
|
||||
cy.dataCy("hcl-value").should("have.text", "HCL (178, 0.78, 0.25)");
|
||||
cy.dataCy("hsv-value").should("have.text", "HSV (179, 0.78, 0.29)");
|
||||
cy.dataCy("hcl-value").should("have.text", "HCL (179, 0.78, 0.25)");
|
||||
cy.dataCy("hex-value").should("have.text", "HEX: #104B4A");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ describe("component editor tests", () => {
|
||||
cy.dataCy("R-slider")
|
||||
.click()
|
||||
.dataCy("R-slider-bar")
|
||||
.should("have.css", "width", "138px")
|
||||
.should("have.css", "width", "140px")
|
||||
.dataCy("R-value-input")
|
||||
.should("have.value", "127");
|
||||
|
||||
@@ -80,7 +80,7 @@ describe("component editor tests", () => {
|
||||
.type("100")
|
||||
.should("have.value", "100")
|
||||
.dataCy("R-slider-bar")
|
||||
.should("have.css", "width", "109px");
|
||||
.should("have.css", "width", "110px");
|
||||
|
||||
// Scrolling input should update value
|
||||
cy.dataCy("R-value-input")
|
||||
@@ -134,7 +134,7 @@ describe("component editor tests", () => {
|
||||
cy.dataCy("R-slider")
|
||||
.click()
|
||||
.dataCy("R-slider-bar")
|
||||
.should("have.css", "width", "138px")
|
||||
.should("have.css", "width", "140px")
|
||||
.dataCy("R-value-input")
|
||||
.should("have.value", "127");
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as colorlib from "colorlib";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import type { HexColorActions } from "@/hooks/color";
|
||||
import { extractHexValue, formatHexString } from "@/hooks/hex";
|
||||
import { useScroll } from "@/hooks/scroll";
|
||||
import { useSlider } from "@/hooks/slider";
|
||||
import { onResize } from "@/hooks/window";
|
||||
@@ -340,30 +341,6 @@ function useLongPressRepeat(
|
||||
// Hex Editor //
|
||||
// ---------- //
|
||||
|
||||
const extractHexValue = (value: string): string | null => {
|
||||
const match = value.match(/^#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})$/);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
const formatHexString = (
|
||||
color: colorlib.Hex,
|
||||
preserveShortFormat: boolean = false,
|
||||
): string => {
|
||||
const hexValue = color.to_code();
|
||||
|
||||
if (preserveShortFormat) {
|
||||
if (
|
||||
hexValue[0] === hexValue[1] &&
|
||||
hexValue[2] === hexValue[3] &&
|
||||
hexValue[4] === hexValue[5]
|
||||
) {
|
||||
return `#${hexValue[0]}${hexValue[2]}${hexValue[4]}`;
|
||||
}
|
||||
}
|
||||
|
||||
return `#${color.to_code()}`;
|
||||
};
|
||||
|
||||
export function HexEditor({
|
||||
color,
|
||||
actions,
|
||||
@@ -375,24 +352,34 @@ export function HexEditor({
|
||||
}) {
|
||||
const [inputValue, setInputValue] = useState(formatHexString(color));
|
||||
const [isShortHex, setIsShortHex] = useState(false);
|
||||
const isFocused = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(formatHexString(color, isShortHex));
|
||||
if (!isFocused.current) {
|
||||
setInputValue(formatHexString(color, isShortHex));
|
||||
}
|
||||
}, [color, isShortHex]);
|
||||
|
||||
const onFocus = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
isFocused.current = true;
|
||||
e.target.select();
|
||||
};
|
||||
|
||||
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setInputValue(value);
|
||||
};
|
||||
|
||||
const hex = extractHexValue(value);
|
||||
const onBlur = () => {
|
||||
isFocused.current = false;
|
||||
const hex = extractHexValue(inputValue);
|
||||
if (hex) {
|
||||
setIsShortHex(hex.length === 3);
|
||||
const newColor = colorlib.Hex.from_code(hex);
|
||||
actions.setHex(newColor);
|
||||
setInputValue(formatHexString(newColor, isShortHex));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const onBlur = () => {
|
||||
setInputValue(formatHexString(color));
|
||||
};
|
||||
|
||||
@@ -414,7 +401,7 @@ export function HexEditor({
|
||||
value={inputValue}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
onFocus={(e) => e.target.select()}
|
||||
onFocus={onFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -6,32 +6,461 @@
|
||||
}
|
||||
|
||||
.actionBar {
|
||||
height: 40px;
|
||||
padding: 10px 11px;
|
||||
border-bottom: 1px solid #c9c9c9;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.actionBar .actionButton {
|
||||
cursor: pointer;
|
||||
margin: 4px;
|
||||
background-color: #e7e7e7;
|
||||
border-radius: 0;
|
||||
border: 1px solid #e2e2e2;
|
||||
box-shadow:
|
||||
rgba(60, 64, 67, 0.3) 0px 1px 2px 0px,
|
||||
rgba(60, 64, 67, 0.05) 0px 2px 6px 1px;
|
||||
transition-property: background-color, box-shadow;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.actionBar .actionButton:hover {
|
||||
background-color: #f0f0f0;
|
||||
box-shadow:
|
||||
rgba(60, 64, 67, 0.25) 0px 3px 6px 0px,
|
||||
rgba(60, 64, 67, 0.1) 0px 4px 8px 2px;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.actionBar .actionButton:active {
|
||||
background-color: #d1d1d1;
|
||||
box-shadow:
|
||||
rgba(60, 64, 67, 0.3) 0px 1px 1px 0px,
|
||||
inset 0px 1px 2px rgba(0, 0, 0, 0.2);
|
||||
transform: translateY(1px);
|
||||
transition-duration: 50ms;
|
||||
}
|
||||
|
||||
.actionBar .iconButton {
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.actionBar .wordButton {
|
||||
font-size: 14px;
|
||||
padding: 4px 4px;
|
||||
}
|
||||
|
||||
.actionBar .activeButton {
|
||||
background-color: #f8b800;
|
||||
border-color: #e1964b;
|
||||
box-shadow:
|
||||
rgba(60, 64, 67, 0.25) 0px 1px 2px 0px,
|
||||
0 0 8px 1px rgba(255, 163, 56, 0.5),
|
||||
inset 0 0 4px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.actionBar .activeButton:hover {
|
||||
background-color: #fea944;
|
||||
box-shadow:
|
||||
rgba(60, 64, 67, 0.25) 0px 2px 4px 0px,
|
||||
0 0 12px 2px rgba(255, 163, 56, 0.6),
|
||||
inset 0 0 4px rgba(255, 255, 255, 0.5);
|
||||
transform: translateY(-0.5px);
|
||||
}
|
||||
|
||||
.actionBar .activeButton:active {
|
||||
background-color: #eb8d16;
|
||||
box-shadow:
|
||||
rgba(60, 64, 67, 0.2) 0px 0px 1px 0px,
|
||||
0 0 4px 0px rgba(255, 163, 56, 0.4),
|
||||
inset 0px 1px 2px rgba(0, 0, 0, 0.2);
|
||||
transform: translateY(0.5px);
|
||||
transition-duration: 50ms;
|
||||
}
|
||||
|
||||
.cardWrapper {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
margin: 32px;
|
||||
display: grid;
|
||||
border-radius: 8px;
|
||||
grid-template-columns: 1fr 1fr 4fr;
|
||||
grid-template-rows: 40px 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-areas:
|
||||
". . card-header"
|
||||
"sync sync card-header"
|
||||
"preview selection palette";
|
||||
overflow-y: auto;
|
||||
/* border: 1px solid #ddd; */
|
||||
box-shadow:
|
||||
rgba(0, 0, 0, 0.19) 0px 10px 20px,
|
||||
rgba(0, 0, 0, 0.23) 0px 6px 6px;
|
||||
}
|
||||
|
||||
.sync {
|
||||
grid-area: sync;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #f6f6f6;
|
||||
border-right: 2px solid #aaa;
|
||||
border-bottom: 2px solid #aaa;
|
||||
transition-property: background-color;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.sync:hover {
|
||||
background-color: #f1f1f1;
|
||||
}
|
||||
|
||||
.sync:active {
|
||||
background-color: #e7e7e7;
|
||||
}
|
||||
|
||||
.sync .leftSpan {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sync .middleSpan {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: middle;
|
||||
margin: 0px 8px;
|
||||
}
|
||||
|
||||
.sync .rightSpan {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
grid-area: card-header;
|
||||
display: flex;
|
||||
padding: 4px 8px 8px;
|
||||
color: #292929;
|
||||
background-color: #f6f6f6;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
border-bottom: 2px solid #aaa;
|
||||
}
|
||||
|
||||
.cardHeader .editableFieldWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cardHeader .editableField {
|
||||
}
|
||||
|
||||
.cardHeader .editingField {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cardHeader .editableFieldButton {
|
||||
cursor: pointer;
|
||||
color: black;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
margin: 2px -2px 2px 8px;
|
||||
padding: 5px 6px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
transition-property: background-color;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.cardHeader .editableFieldButton:hover {
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.cardHeader .editableFieldButton:active {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.pickerColor {
|
||||
grid-area: preview;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.paletteColor {
|
||||
grid-area: selection;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.pickerColor .arrowIndicator {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@keyframes slideRight {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideLeft {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(-3px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.pickerColor:hover .arrowIndicator {
|
||||
animation: slideRight 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.paletteColor:hover .arrowIndicator {
|
||||
animation: slideLeft 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.previewPane .arrowIndicator {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.previewPane .arrowIndicatorDark {
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.previewPane:hover .arrowIndicatorDark {
|
||||
color: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.previewPane:active .arrowIndicatorDark {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.previewPane .arrowIndicatorLight {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.previewPane:hover .arrowIndicatorLight {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.previewPane:active .arrowIndicatorLight {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.palette {
|
||||
grid-area: palette;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
background-color: #f7f7f7;
|
||||
}
|
||||
|
||||
.paletteRowWrapper {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
min-height: 3em;
|
||||
}
|
||||
|
||||
.paletteRowWrapper.draggable {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.paletteRowWrapper.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.paletteRowWrapper.dragging .paletteRow,
|
||||
.paletteRowWrapper.multiSelected .paletteRow {
|
||||
transform: scale(0.96, 0.9);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.paletteRow {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
transition:
|
||||
transform 200ms ease-out,
|
||||
border-radius 200ms ease-out;
|
||||
}
|
||||
|
||||
.paletteRow .selectedIndicator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.paletteRow .indicatorDark {
|
||||
color: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.paletteRow .indicatorLight {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.paletteRow .targettedIndicator {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: opacity 150ms ease-out;
|
||||
}
|
||||
|
||||
.paletteRow:hover .targettedIndicator {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.modeDecorator {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.checkDecorator {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 3px;
|
||||
border: 2px solid;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.checkDecoratorDark {
|
||||
color: rgba(0, 0, 0, 0.8);
|
||||
border-color: rgba(0, 0, 0, 0.5);
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.checkDecoratorLight {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.gripDark {
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.gripLight {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.paletteRowDataWithDecorator {
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
.paletteRowData {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.paletteRowData .colorName {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.paletteRowData .colorHex {
|
||||
font-size: 14px;
|
||||
font-family: monospace;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.paletteRow .editableFieldWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.paletteRow .editableField,
|
||||
.paletteRow .field {
|
||||
cursor: text;
|
||||
border-radius: 4px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.paletteRow .editableFieldDark,
|
||||
.paletteRow .fieldDark {
|
||||
color: rgba(0, 0, 0, 0.95);
|
||||
background-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.paletteRow .editableFieldLight,
|
||||
.paletteRow .fieldLight {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.paletteRow .editableFieldButton {
|
||||
transition-property: color;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.paletteRow .editableFieldButtonDark {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.paletteRow .editableFieldButtonDark:hover {
|
||||
color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.paletteRow .editableFieldButtonDark:active {
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.paletteRow .editableFieldButtonLight {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.paletteRow .editableFieldButtonLight:hover {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.paletteRow .editableFieldButtonLight:active {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.paletteRow .editingField {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.paletteRow .colorHex {
|
||||
width: 124px;
|
||||
}
|
||||
|
||||
.colorName .editableField,
|
||||
.colorName .field {
|
||||
padding: 1px 8px;
|
||||
}
|
||||
|
||||
.colorHex .editableField,
|
||||
.colorHex .field {
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.paletteRow .editableFieldButton {
|
||||
cursor: pointer;
|
||||
padding-left: 4px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,40 @@
|
||||
import { useReducer } from "react";
|
||||
|
||||
import { Color } from "colorlib";
|
||||
import { Color, Hex as HexColor } from "colorlib";
|
||||
|
||||
import { HexEditor } from "@/components/ColorValues/ValueEditor";
|
||||
import { colorReducer, createColorActions } from "@/hooks/color";
|
||||
import type { PaletteCardState } from "@/hooks/paletteCard";
|
||||
|
||||
import PaletteEditor from "./PaletteEditor";
|
||||
|
||||
const initialState = {
|
||||
const initialPickerState = {
|
||||
color: Color.from_hex("000"),
|
||||
};
|
||||
|
||||
function TestWrapper() {
|
||||
const [state, dispatch] = useReducer(colorReducer, initialState);
|
||||
const defaultPaletteCard = {
|
||||
id: "card_id",
|
||||
name: "Test Palette",
|
||||
colors: [
|
||||
{ id: "red", name: "Red", hex: HexColor.from_code("FF0000") },
|
||||
{ id: "green", name: "Green", hex: HexColor.from_code("00FF00") },
|
||||
{ id: "blue", name: "Blue", hex: HexColor.from_code("0000FF") },
|
||||
],
|
||||
selectedColorIds: [],
|
||||
};
|
||||
|
||||
const defaultPaletteCardState = {
|
||||
present: defaultPaletteCard,
|
||||
history: [],
|
||||
future: [],
|
||||
};
|
||||
|
||||
function TestWrapper({
|
||||
initialCardState,
|
||||
}: {
|
||||
initialCardState?: PaletteCardState;
|
||||
}) {
|
||||
const [state, dispatch] = useReducer(colorReducer, initialPickerState);
|
||||
const actions = createColorActions(dispatch);
|
||||
|
||||
return (
|
||||
@@ -21,6 +43,7 @@ function TestWrapper() {
|
||||
<PaletteEditor
|
||||
pickerColor={state.color.hex}
|
||||
setPickerColor={actions.hex.setHex}
|
||||
initialCardState={initialCardState}
|
||||
/>
|
||||
</div>
|
||||
<HexEditor color={state.color.hex} actions={actions.hex} />
|
||||
@@ -28,12 +51,315 @@ function TestWrapper() {
|
||||
);
|
||||
}
|
||||
|
||||
describe("palette editor tests", () => {
|
||||
beforeEach(() => {
|
||||
cy.mount(<TestWrapper />);
|
||||
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("can manually sync picker and palette", () => {
|
||||
cy.mount(<TestWrapper initialCardState={defaultPaletteCardState} />);
|
||||
|
||||
cy.dataCy("hex-value-input").as("hex");
|
||||
cy.dataCy("picker-preview").as("picker");
|
||||
cy.dataCy("selected-preview").as("palette");
|
||||
cy.dataCy("palette-row-0").as("row0");
|
||||
cy.dataCy("palette-row-1").as("row1");
|
||||
cy.dataCy("palette-row-2").as("row2");
|
||||
|
||||
// Ensure picker and preview colors are set to default
|
||||
cy.get("@hex").should("have.value", "#000000");
|
||||
cy.get("@picker").should("have.css", "background-color", "rgb(0, 0, 0)");
|
||||
|
||||
// Clicking the picker when no colors are selected does nothing
|
||||
cy.get("@picker").click();
|
||||
|
||||
// Select a color and sync it to the picker
|
||||
cy.get("@row1").click();
|
||||
cy.get("@palette")
|
||||
.should("have.css", "background-color", "rgb(0, 255, 0)")
|
||||
.click();
|
||||
cy.get("@picker").should("have.css", "background-color", "rgb(0, 255, 0)");
|
||||
cy.get("@hex").should("have.value", "#00FF00");
|
||||
|
||||
// Select a new color, picker remains the same
|
||||
cy.get("@row0").click();
|
||||
cy.get("@palette").should("have.css", "background-color", "rgb(255, 0, 0)");
|
||||
cy.get("@picker").should("have.css", "background-color", "rgb(0, 255, 0)");
|
||||
|
||||
// Change picker color, sync back to palette.
|
||||
cy.get("@hex").focus().type("FFFF00{esc}").wait(0);
|
||||
cy.get("@picker").should("have.css", "background-color", "rgb(255, 255, 0)");
|
||||
cy.get("@picker").click();
|
||||
cy.get("@row0").contains("#FFFF00");
|
||||
});
|
||||
|
||||
it("can automatically sync picker and palette", () => {
|
||||
cy.clock();
|
||||
cy.mount(<TestWrapper initialCardState={defaultPaletteCardState} />);
|
||||
|
||||
cy.dataCy("undo").as("undo");
|
||||
cy.dataCy("redo").as("redo");
|
||||
cy.dataCy("sync").as("sync");
|
||||
cy.dataCy("hex-value-input").as("hex");
|
||||
cy.dataCy("picker-preview").as("picker");
|
||||
cy.dataCy("selected-preview").as("palette");
|
||||
cy.dataCy("palette-row-0").as("row0");
|
||||
cy.dataCy("palette-row-1").as("row1");
|
||||
cy.dataCy("palette-row-2").as("row2");
|
||||
|
||||
// Enable color sync
|
||||
cy.get("@sync").click().should("have.attr", "aria-pressed", "true");
|
||||
|
||||
// No color selected, all previews are default
|
||||
cy.get("@hex").should("have.value", "#000000");
|
||||
cy.get("@picker").should("have.css", "background-color", "rgb(0, 0, 0)");
|
||||
|
||||
// Select a color, picker should sync
|
||||
cy.get("@row1").click();
|
||||
cy.get("@palette").should("have.css", "background-color", "rgb(0, 255, 0)");
|
||||
cy.get("@picker").should("have.css", "background-color", "rgb(0, 255, 0)");
|
||||
cy.get("@hex").should("have.value", "#00FF00");
|
||||
|
||||
// Change picker color, palette should sync
|
||||
cy.get("@hex").type("#FF00FF{esc}").wait(0);
|
||||
cy.get("@palette").should("have.css", "background-color", "rgb(255, 0, 255)");
|
||||
cy.get("@picker").should("have.css", "background-color", "rgb(255, 0, 255)");
|
||||
cy.get("@row1").contains("#FF00FF");
|
||||
|
||||
// Turning on sync mode should set picker to selected palette color
|
||||
cy.get("@sync").click();
|
||||
cy.get("@row2").click();
|
||||
cy.get("@sync").click();
|
||||
cy.get("@picker").should("have.css", "background-color", "rgb(0, 0, 255)");
|
||||
cy.get("@hex").should("have.value", "#0000FF");
|
||||
|
||||
// History updates after timeout
|
||||
|
||||
cy.get("@hex").type("#FF00FF{esc}").wait(0);
|
||||
cy.get("@picker").should("have.css", "background-color", "rgb(255, 0, 255)");
|
||||
cy.tick(3000);
|
||||
cy.get("@hex").type("#00FF00{esc}").wait(0);
|
||||
cy.get("@picker").should("have.css", "background-color", "rgb(0, 255, 0)");
|
||||
cy.tick(3000);
|
||||
|
||||
// undo goes back to pink, then blue
|
||||
cy.get("@undo").click();
|
||||
cy.get("@palette").should("have.css", "background-color", "rgb(255, 0, 255)");
|
||||
cy.get("@picker").should("have.css", "background-color", "rgb(255, 0, 255)");
|
||||
cy.get("@hex").should("have.value", "#FF00FF");
|
||||
cy.get("@undo").click();
|
||||
cy.get("@palette").should("have.css", "background-color", "rgb(0, 0, 255)");
|
||||
cy.get("@picker").should("have.css", "background-color", "rgb(0, 0, 255)");
|
||||
cy.get("@hex").should("have.value", "#0000FF");
|
||||
|
||||
// redo goes to pink, then green
|
||||
cy.get("@redo").click();
|
||||
cy.get("@palette").should("have.css", "background-color", "rgb(255, 0, 255)");
|
||||
cy.get("@picker").should("have.css", "background-color", "rgb(255, 0, 255)");
|
||||
cy.get("@redo").click();
|
||||
cy.get("@palette").should("have.css", "background-color", "rgb(0, 255, 0)");
|
||||
cy.get("@picker").should("have.css", "background-color", "rgb(0, 255, 0)");
|
||||
|
||||
// undo during timeout wipes intermediate state
|
||||
cy.get("@hex").type("#0000FF{esc}").wait(0);
|
||||
cy.tick(3000); // lock in blue
|
||||
cy.get("@hex").type("#FF00FF{esc}").wait(0); // pink is debounced
|
||||
cy.get("@hex").type("#00FF00{esc}").wait(0);
|
||||
cy.tick(3000); // lock in green
|
||||
|
||||
// pink state is lost, undo goes back to blue
|
||||
cy.get("@undo").click();
|
||||
cy.get("@picker").should("have.css", "background-color", "rgb(0, 0, 255)");
|
||||
|
||||
cy.clock().then((clock) => clock.restore());
|
||||
});
|
||||
|
||||
it("can perform actions in edit mode", () => {
|
||||
cy.mount(<TestWrapper initialCardState={defaultPaletteCardState} />);
|
||||
|
||||
cy.dataCy("select").as("select");
|
||||
cy.dataCy("palette-row-0").as("row0");
|
||||
cy.dataCy("palette-row-1").as("row1");
|
||||
cy.dataCy("palette-row-2").as("row2");
|
||||
|
||||
// enter select mode
|
||||
cy.get("@select").click().should("have.attr", "aria-pressed", "true");
|
||||
|
||||
cy.dataCy("select-all").as("select-all");
|
||||
cy.dataCy("clear").as("clear");
|
||||
|
||||
// select multiple colors
|
||||
cy.get("@row0").should("have.attr", "aria-selected", "false");
|
||||
cy.get("@row1").should("have.attr", "aria-selected", "false");
|
||||
|
||||
cy.get("@row0").click();
|
||||
cy.get("@row1").click();
|
||||
|
||||
cy.get("@row0").should("have.attr", "aria-selected", "true");
|
||||
cy.get("@row1").should("have.attr", "aria-selected", "true");
|
||||
|
||||
// clear selection
|
||||
cy.get("@clear").click();
|
||||
cy.get("@row0").should("have.attr", "aria-selected", "false");
|
||||
cy.get("@row1").should("have.attr", "aria-selected", "false");
|
||||
|
||||
// select all
|
||||
cy.get("@select-all").click();
|
||||
cy.get("@row0").should("have.attr", "aria-selected", "true");
|
||||
cy.get("@row1").should("have.attr", "aria-selected", "true");
|
||||
cy.get("@row2").should("have.attr", "aria-selected", "true");
|
||||
|
||||
// leave select mode
|
||||
cy.get("@select").click().should("have.attr", "aria-pressed", "false");
|
||||
cy.get("@row0").should("have.attr", "aria-selected", "false");
|
||||
cy.get("@row1").should("have.attr", "aria-selected", "false");
|
||||
cy.get("@row2").should("have.attr", "aria-selected", "false");
|
||||
});
|
||||
|
||||
it("can reorder colors in reorder mode", () => {
|
||||
cy.mount(<TestWrapper initialCardState={defaultPaletteCardState} />);
|
||||
|
||||
// enter reorder mode
|
||||
cy.dataCy("reorder").click().should("have.attr", "aria-pressed", "true");
|
||||
|
||||
// drag red down to green
|
||||
cy.dataCy("palette-row-0-wrapper").trigger("mousedown", {
|
||||
buttons: 1,
|
||||
eventConstructor: "MouseEvent",
|
||||
});
|
||||
cy.dataCy("palette-row-1-wrapper").trigger("mousemove", {
|
||||
buttons: 1,
|
||||
eventConstructor: "MouseEvent",
|
||||
});
|
||||
cy.dataCy("palette-row-1-wrapper").trigger("mouseup", {
|
||||
buttons: 1,
|
||||
eventConstructor: "MouseEvent",
|
||||
});
|
||||
|
||||
it("renders the palette editor", () => {
|
||||
cy.dataCy("palette-editor").should("exist");
|
||||
});
|
||||
// green should now be first
|
||||
cy.dataCy("palette-row-0").contains("Green");
|
||||
cy.dataCy("palette-row-1").contains("Red");
|
||||
cy.dataCy("palette-row-2").contains("Blue");
|
||||
|
||||
// leave reorder mode
|
||||
cy.dataCy("reorder").click().should("have.attr", "aria-pressed", "false");
|
||||
|
||||
// order should persist
|
||||
cy.dataCy("palette-row-0").contains("Green");
|
||||
cy.dataCy("palette-row-1").contains("Red");
|
||||
cy.dataCy("palette-row-2").contains("Blue");
|
||||
});
|
||||
|
||||
@@ -1,51 +1,911 @@
|
||||
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import type { KeyboardEvent, MouseEvent, RefObject } from "react";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { Hex as HexColor } from "colorlib";
|
||||
import {
|
||||
Check,
|
||||
CheckCheck,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
CircleDashed,
|
||||
Copy,
|
||||
Crosshair,
|
||||
Dot,
|
||||
GripVertical,
|
||||
Pencil,
|
||||
Plus,
|
||||
Redo2,
|
||||
RefreshCw,
|
||||
RefreshCwOff,
|
||||
Trash2,
|
||||
Undo2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import type { ContrastToken } from "@/hooks/contrast";
|
||||
import { luminanceFromHex, useContrastToken } from "@/hooks/contrast";
|
||||
import { useDragAndDrop } from "@/hooks/dragAndDrop";
|
||||
import { extractHexValue, formatHexString } from "@/hooks/hex";
|
||||
import {
|
||||
createPaletteCardActions,
|
||||
paletteCardReducer,
|
||||
} from "@/hooks/paletteCard";
|
||||
import type {
|
||||
PaletteCard,
|
||||
PaletteCardActions,
|
||||
PaletteCardState,
|
||||
PaletteColor,
|
||||
PaletteMode,
|
||||
} from "@/hooks/paletteCard";
|
||||
import {
|
||||
loadCards,
|
||||
saveActiveCardId,
|
||||
saveCards,
|
||||
serializeCard,
|
||||
} from "@/hooks/storage";
|
||||
import { randomId } from "@/util";
|
||||
|
||||
import styles from "./PaletteEditor.module.css";
|
||||
|
||||
type Timeout = ReturnType<typeof setTimeout>;
|
||||
|
||||
const SYNC_DELAY = 2000;
|
||||
const DEFAULT_BG = HexColor.from_code("f6f6f6");
|
||||
|
||||
function defaultPaletteCard(): PaletteCardState {
|
||||
const defaultCard = {
|
||||
id: randomId(),
|
||||
name: "New Palette",
|
||||
colors: [],
|
||||
selectedColorIds: [],
|
||||
};
|
||||
|
||||
return {
|
||||
present: defaultCard,
|
||||
history: [],
|
||||
future: [],
|
||||
};
|
||||
}
|
||||
|
||||
function PaletteEditor({
|
||||
pickerColor,
|
||||
setPickerColor,
|
||||
initialCardState,
|
||||
}: {
|
||||
pickerColor: HexColor;
|
||||
setPickerColor: (hex: HexColor) => void;
|
||||
initialCardState?: PaletteCardState;
|
||||
}) {
|
||||
const [cardState, dispatch] = useReducer(
|
||||
paletteCardReducer,
|
||||
initialCardState || defaultPaletteCard(),
|
||||
);
|
||||
const actions = useMemo(() => createPaletteCardActions(dispatch), [dispatch]);
|
||||
const [historyCounter, setHistoryCounter] = useState(0);
|
||||
const [mode, setMode] = useState<PaletteMode>("normal");
|
||||
const [isSynced, setIsSynced] = useState(false);
|
||||
const snapshotRef = useRef<PaletteCard | null>(null);
|
||||
const timerRef = useRef<Timeout | null>(null);
|
||||
|
||||
const incrementHistoryCounter = () => {
|
||||
setHistoryCounter((prev) => prev + 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
saveActiveCardId(cardState.present.id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const cards = loadCards();
|
||||
cards[cardState.present.id] = serializeCard(cardState.present);
|
||||
saveCards(cards);
|
||||
}, [cardState.present]);
|
||||
|
||||
return (
|
||||
<div className={styles.paletteEditor} data-cy="palette-editor">
|
||||
<ActionBar />
|
||||
<PaletteCard />
|
||||
<ActionBar
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
actions={actions}
|
||||
hasSelection={cardState.present.selectedColorIds.length > 0}
|
||||
canUndo={cardState.history.length > 0}
|
||||
canRedo={cardState.future.length > 0}
|
||||
isSynced={isSynced}
|
||||
incrementHistoryCounter={incrementHistoryCounter}
|
||||
snapshotRef={snapshotRef}
|
||||
syncTimerRef={timerRef}
|
||||
/>
|
||||
<PaletteCard
|
||||
pickerColor={pickerColor}
|
||||
setPickerColor={setPickerColor}
|
||||
cardState={cardState.present}
|
||||
actions={actions}
|
||||
mode={mode}
|
||||
isSynced={isSynced}
|
||||
setIsSynced={setIsSynced}
|
||||
snapshotRef={snapshotRef}
|
||||
syncTimerRef={timerRef}
|
||||
historyCounter={historyCounter}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionBar() {
|
||||
return <div className={styles.actionBar}>actions</div>;
|
||||
}
|
||||
function ActionBar({
|
||||
mode,
|
||||
setMode,
|
||||
actions,
|
||||
hasSelection,
|
||||
canUndo,
|
||||
canRedo,
|
||||
isSynced,
|
||||
incrementHistoryCounter,
|
||||
snapshotRef,
|
||||
syncTimerRef,
|
||||
}: {
|
||||
mode: PaletteMode;
|
||||
setMode: (mode: PaletteMode) => void;
|
||||
actions: PaletteCardActions;
|
||||
hasSelection: boolean;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
isSynced: boolean;
|
||||
incrementHistoryCounter: () => void;
|
||||
snapshotRef: RefObject<PaletteCard | null>;
|
||||
syncTimerRef: RefObject<Timeout | null>;
|
||||
}) {
|
||||
const clearSyncTimeout = () => {
|
||||
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
|
||||
snapshotRef.current = null;
|
||||
};
|
||||
|
||||
const handleUndo = () => {
|
||||
if (isSynced) clearSyncTimeout();
|
||||
incrementHistoryCounter();
|
||||
actions.undo();
|
||||
};
|
||||
|
||||
const handleRedo = () => {
|
||||
if (isSynced) clearSyncTimeout();
|
||||
incrementHistoryCounter();
|
||||
actions.redo();
|
||||
};
|
||||
|
||||
const handleModeChange = (next: PaletteMode) => {
|
||||
if (mode === "normal") {
|
||||
if (isSynced && syncTimerRef.current) {
|
||||
clearTimeout(syncTimerRef.current);
|
||||
if (snapshotRef.current) {
|
||||
actions.commitToHistory(snapshotRef.current);
|
||||
snapshotRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
actions.clearSelection();
|
||||
setMode(next);
|
||||
};
|
||||
|
||||
function PaletteCard() {
|
||||
return (
|
||||
<div className={styles.cardWrapper}>
|
||||
<CardHeader />
|
||||
<PickerColor />
|
||||
<PaletteColor />
|
||||
<Palette />
|
||||
<div className={styles.actionBar} data-cy="action-bar">
|
||||
<button
|
||||
className={clsx(styles.actionButton, styles.iconButton)}
|
||||
data-cy="undo"
|
||||
disabled={!canUndo}
|
||||
onClick={handleUndo}
|
||||
title="Undo"
|
||||
>
|
||||
<Undo2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={clsx(styles.actionButton, styles.iconButton)}
|
||||
data-cy="redo"
|
||||
disabled={!canRedo}
|
||||
onClick={handleRedo}
|
||||
title="Redo"
|
||||
>
|
||||
<Redo2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={clsx(styles.actionButton, styles.iconButton)}
|
||||
data-cy="add"
|
||||
onClick={actions.addColor}
|
||||
title="Add Color"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={clsx(styles.actionButton, styles.iconButton)}
|
||||
data-cy="delete"
|
||||
disabled={!hasSelection}
|
||||
onClick={actions.deleteSelectedColors}
|
||||
title="Delete Selected"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={clsx(styles.actionButton, styles.iconButton)}
|
||||
data-cy="duplicate"
|
||||
disabled={!hasSelection}
|
||||
onClick={actions.duplicateSelectedColors}
|
||||
title="Duplicate Selected"
|
||||
>
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={clsx(styles.actionButton, styles.wordButton, {
|
||||
[styles.activeButton]: mode === "reorder",
|
||||
})}
|
||||
data-cy="reorder"
|
||||
aria-pressed={mode === "reorder"}
|
||||
onClick={() =>
|
||||
handleModeChange(mode === "reorder" ? "normal" : "reorder")
|
||||
}
|
||||
title="Reorder Colors"
|
||||
>
|
||||
Reorder
|
||||
</button>
|
||||
<button
|
||||
className={clsx(styles.actionButton, styles.wordButton, {
|
||||
[styles.activeButton]: mode === "select",
|
||||
})}
|
||||
data-cy="select"
|
||||
aria-pressed={mode === "select"}
|
||||
onClick={() =>
|
||||
handleModeChange(mode === "select" ? "normal" : "select")
|
||||
}
|
||||
title="Select Multiple"
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
{mode === "select" && (
|
||||
<>
|
||||
<button
|
||||
className={clsx(styles.actionButton, styles.iconButton)}
|
||||
data-cy="select-all"
|
||||
onClick={actions.selectAll}
|
||||
title="Select All"
|
||||
>
|
||||
<CheckCheck size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={clsx(styles.actionButton, styles.iconButton)}
|
||||
data-cy="clear"
|
||||
onClick={actions.clearSelection}
|
||||
title="Clear Selections"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader() {
|
||||
return <div className={styles.cardHeader}>header</div>;
|
||||
function PaletteCard({
|
||||
pickerColor,
|
||||
setPickerColor,
|
||||
cardState,
|
||||
actions,
|
||||
mode,
|
||||
isSynced,
|
||||
setIsSynced,
|
||||
snapshotRef,
|
||||
syncTimerRef,
|
||||
historyCounter,
|
||||
}: {
|
||||
pickerColor: HexColor;
|
||||
setPickerColor: (hex: HexColor) => void;
|
||||
cardState: PaletteCard;
|
||||
actions: PaletteCardActions;
|
||||
mode: PaletteMode;
|
||||
isSynced: boolean;
|
||||
setIsSynced: (v: boolean) => void;
|
||||
snapshotRef: RefObject<PaletteCard | null>;
|
||||
syncTimerRef: RefObject<Timeout | null>;
|
||||
historyCounter: number;
|
||||
}) {
|
||||
const selectedColor =
|
||||
mode === "select"
|
||||
? null
|
||||
: cardState.selectedColorIds.length === 1
|
||||
? cardState.colors.find((c) => c.id === cardState.selectedColorIds[0])
|
||||
: null;
|
||||
const wasSyncedRef = useRef(false);
|
||||
const pickerColorValue = pickerColor.to_code();
|
||||
|
||||
// when sync toggles on, set picker <- palette
|
||||
useEffect(() => {
|
||||
if (!isSynced) {
|
||||
wasSyncedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 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}
|
||||
paletteColorId={selectedColor?.id || null}
|
||||
setPaletteColor={actions.setColorValue}
|
||||
isSynced={isSynced}
|
||||
/>
|
||||
<PaletteColor
|
||||
selectedColor={selectedColor?.hex || null}
|
||||
setPickerColor={setPickerColor}
|
||||
isSynced={isSynced}
|
||||
/>
|
||||
<Palette cardState={cardState} actions={actions} mode={mode} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PickerColor() {
|
||||
return <div className={styles.pickerColor}>picker color</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 PaletteColor() {
|
||||
return <div className={styles.paletteColor}>palette color</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 Palette() {
|
||||
return <div className={styles.palette}>palette</div>;
|
||||
function PickerColor({
|
||||
pickerColor,
|
||||
paletteColorId,
|
||||
setPaletteColor,
|
||||
isSynced,
|
||||
}: {
|
||||
pickerColor: HexColor;
|
||||
paletteColorId: string | null;
|
||||
setPaletteColor: (id: string, hex: HexColor) => void;
|
||||
isSynced: boolean;
|
||||
}) {
|
||||
const arrowToken = useContrastToken(() => luminanceFromHex(pickerColor));
|
||||
|
||||
const handleClick = () => {
|
||||
if (!isSynced && paletteColorId) {
|
||||
setPaletteColor(paletteColorId, pickerColor);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.previewPane, styles.pickerColor)}
|
||||
data-cy="picker-preview"
|
||||
style={{
|
||||
cursor: isSynced ? "unset" : "pointer",
|
||||
backgroundColor: formatHexString(pickerColor),
|
||||
}}
|
||||
onClick={handleClick}
|
||||
title={!isSynced ? "Send to Picker" : ""}
|
||||
>
|
||||
{!isSynced && (
|
||||
<div
|
||||
data-cy="picker-color-arrow"
|
||||
className={clsx(styles.arrowIndicator, {
|
||||
[styles.arrowIndicatorDark]: arrowToken === "dark",
|
||||
[styles.arrowIndicatorLight]: arrowToken === "light",
|
||||
})}
|
||||
>
|
||||
<ChevronsRight size={32} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PaletteColor({
|
||||
selectedColor,
|
||||
setPickerColor,
|
||||
isSynced,
|
||||
}: {
|
||||
selectedColor: HexColor | null;
|
||||
setPickerColor: (hex: HexColor) => void;
|
||||
isSynced: boolean;
|
||||
}) {
|
||||
const bgColor = selectedColor || DEFAULT_BG;
|
||||
const arrowToken = useContrastToken(() => luminanceFromHex(bgColor));
|
||||
|
||||
const handleClick = () => {
|
||||
if (!isSynced && selectedColor) {
|
||||
setPickerColor(selectedColor);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.previewPane, styles.paletteColor)}
|
||||
data-cy="selected-preview"
|
||||
style={{
|
||||
cursor: isSynced ? "unset" : "pointer",
|
||||
backgroundColor: formatHexString(bgColor),
|
||||
}}
|
||||
onClick={handleClick}
|
||||
title={!isSynced ? "Send to Palette" : ""}
|
||||
>
|
||||
{!isSynced && (
|
||||
<div
|
||||
data-cy="palette-color-arrow"
|
||||
className={clsx(styles.arrowIndicator, {
|
||||
[styles.arrowIndicatorDark]: arrowToken === "dark",
|
||||
[styles.arrowIndicatorLight]: arrowToken === "light",
|
||||
})}
|
||||
>
|
||||
<ChevronsLeft size={32} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Palette({
|
||||
cardState,
|
||||
actions,
|
||||
mode,
|
||||
}: {
|
||||
cardState: PaletteCard;
|
||||
actions: PaletteCardActions;
|
||||
mode: PaletteMode;
|
||||
}) {
|
||||
const {
|
||||
containerRef,
|
||||
setItemRef,
|
||||
isDragging,
|
||||
// sourceIndex,
|
||||
targetIndex,
|
||||
previewItems,
|
||||
} = useDragAndDrop({
|
||||
items: cardState.colors,
|
||||
handleReorder: actions.reorderColors,
|
||||
disabled: mode !== "reorder",
|
||||
});
|
||||
|
||||
const handleNormalClick = (color: PaletteColor) => {
|
||||
const ids = cardState.selectedColorIds;
|
||||
const isSelected = ids.includes(color.id);
|
||||
console.log(color.id, isSelected);
|
||||
if (isSelected) {
|
||||
actions.setSelectedColors([]);
|
||||
} else {
|
||||
actions.setSelectedColors([color.id]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectClick = (color: PaletteColor) => {
|
||||
const ids = cardState.selectedColorIds;
|
||||
const next = ids.includes(color.id)
|
||||
? ids.filter((id) => id !== color.id)
|
||||
: [...ids, color.id];
|
||||
actions.setSelectedColors(next);
|
||||
};
|
||||
|
||||
const onRowClick =
|
||||
mode === "normal"
|
||||
? handleNormalClick
|
||||
: mode === "select"
|
||||
? handleSelectClick
|
||||
: undefined;
|
||||
|
||||
const displayColors = isDragging ? previewItems : cardState.colors;
|
||||
|
||||
return (
|
||||
<div className={styles.palette} data-cy="palette" ref={containerRef}>
|
||||
{displayColors.length > 0 ? (
|
||||
displayColors.map((color, index) => (
|
||||
<PaletteRow
|
||||
key={color.id}
|
||||
color={color}
|
||||
index={index}
|
||||
isSelected={cardState.selectedColorIds.includes(color.id)}
|
||||
isEditable={
|
||||
mode === "normal" && cardState.selectedColorIds[0] === color.id
|
||||
}
|
||||
isDragging={isDragging}
|
||||
mode={mode}
|
||||
actions={actions}
|
||||
onRowClick={onRowClick}
|
||||
setItemRef={setItemRef}
|
||||
targetIndex={targetIndex}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<span style={{ margin: 8 }}>
|
||||
No colors in palette. Press{" "}
|
||||
<Plus size={16} style={{ transform: "translateY(2px)" }} /> in the
|
||||
toolbar above to add one.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PaletteRow({
|
||||
color,
|
||||
index,
|
||||
isSelected,
|
||||
isEditable,
|
||||
isDragging,
|
||||
mode,
|
||||
actions,
|
||||
onRowClick,
|
||||
setItemRef,
|
||||
targetIndex,
|
||||
}: {
|
||||
color: PaletteColor;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
isEditable: boolean;
|
||||
isDragging: boolean;
|
||||
mode: PaletteMode;
|
||||
actions: PaletteCardActions;
|
||||
onRowClick?: (color: PaletteColor, e: MouseEvent<HTMLDivElement>) => void;
|
||||
setItemRef: (el: HTMLElement | null, id: string) => void;
|
||||
targetIndex: number;
|
||||
}) {
|
||||
const isNormalMode = mode === "normal";
|
||||
const isSelectMode = mode === "select";
|
||||
const isReorderMode = mode === "reorder";
|
||||
|
||||
const Wrapper = isReorderMode ? motion.div : "div";
|
||||
const motionProps = isReorderMode
|
||||
? { layout: true, transition: { duration: 0.25 } }
|
||||
: {};
|
||||
|
||||
const token = useContrastToken(() => luminanceFromHex(color.hex));
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
className={clsx(styles.paletteRowWrapper, {
|
||||
[styles.draggable]: isReorderMode,
|
||||
[styles.dragging]: isDragging && index === targetIndex,
|
||||
[styles.multiSelected]: isSelectMode && isSelected,
|
||||
})}
|
||||
ref={(el) => setItemRef(el, color.id)}
|
||||
data-cy={`palette-row-${index}-wrapper`}
|
||||
data-item-id={color.id}
|
||||
onClick={(e) => onRowClick?.(color, e)}
|
||||
{...motionProps}
|
||||
>
|
||||
<div
|
||||
className={styles.paletteRow}
|
||||
data-cy={`palette-row-${index}`}
|
||||
aria-selected={isSelected}
|
||||
style={{
|
||||
backgroundColor: formatHexString(color.hex),
|
||||
}}
|
||||
>
|
||||
{isSelectMode && (
|
||||
<span
|
||||
className={clsx(styles.modeDecorator, styles.checkDecorator, {
|
||||
[styles.checkDecoratorSelected]: isSelected,
|
||||
[styles.checkDecoratorDark]: token === "dark",
|
||||
[styles.checkDecoratorLight]: token === "light",
|
||||
})}
|
||||
>
|
||||
{isSelected && <Check size={18} strokeWidth={3} />}
|
||||
</span>
|
||||
)}
|
||||
{mode === "reorder" && (
|
||||
<span
|
||||
className={clsx(styles.modeDecorator, {
|
||||
[styles.gripDark]: token === "dark",
|
||||
[styles.gripLight]: token === "light",
|
||||
})}
|
||||
>
|
||||
<GripVertical size={24} />
|
||||
</span>
|
||||
)}
|
||||
{isNormalMode &&
|
||||
(isSelected ? (
|
||||
<Crosshair
|
||||
className={clsx(styles.selectedIndicator, {
|
||||
[styles.indicatorDark]: token === "dark",
|
||||
[styles.indicatorLight]: token === "light",
|
||||
})}
|
||||
size={27}
|
||||
>
|
||||
<Dot />
|
||||
</Crosshair>
|
||||
) : (
|
||||
<CircleDashed
|
||||
className={clsx(styles.targettedIndicator, {
|
||||
[styles.indicatorDark]: token === "dark",
|
||||
[styles.indicatorLight]: token === "light",
|
||||
})}
|
||||
size={32}
|
||||
/>
|
||||
))}
|
||||
<div className={styles.paletteRowData}>
|
||||
<div className={styles.colorName}>
|
||||
{isEditable ? (
|
||||
<EditableField
|
||||
testID={`palette-row-name-${index}`}
|
||||
value={color.name}
|
||||
setValue={(newName: string) =>
|
||||
actions.setColorName(color.id, newName)
|
||||
}
|
||||
buttonSize={12}
|
||||
contrastToken={token}
|
||||
reset={!isSelected}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<span
|
||||
data-cy={`palette-row-name-${index}`}
|
||||
className={clsx(styles.field, {
|
||||
[styles.fieldDark]: token === "dark",
|
||||
[styles.fieldLight]: token === "light",
|
||||
})}
|
||||
>
|
||||
{color.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.colorHex}>
|
||||
{isEditable ? (
|
||||
<EditableField
|
||||
testID={`palette-row-hex-${index}`}
|
||||
value={color.hex.to_code()}
|
||||
setValue={(newHex: string) =>
|
||||
actions.setColorValue(color.id, HexColor.from_code(newHex))
|
||||
}
|
||||
buttonSize={12}
|
||||
contrastToken={token}
|
||||
reset={!isSelected}
|
||||
validate={(raw: string) => extractHexValue(raw)}
|
||||
render={(raw: string) => `#${raw}`}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.fieldWrapper}>
|
||||
<span
|
||||
data-cy={`palette-row-hex-${index}`}
|
||||
className={clsx(styles.field, {
|
||||
[styles.fieldDark]: token === "dark",
|
||||
[styles.fieldLight]: token === "light",
|
||||
})}
|
||||
>
|
||||
{formatHexString(color.hex)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableField({
|
||||
testID,
|
||||
value,
|
||||
setValue,
|
||||
buttonSize,
|
||||
contrastToken,
|
||||
reset,
|
||||
validate,
|
||||
render,
|
||||
}: {
|
||||
testID: string;
|
||||
value: string;
|
||||
setValue: (v: string) => void;
|
||||
buttonSize: number;
|
||||
contrastToken?: ContrastToken;
|
||||
reset?: boolean;
|
||||
validate?: (raw: string) => string | null;
|
||||
render?: (raw: string) => string;
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const spanRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (reset) setIsEditing(false);
|
||||
}, [reset]);
|
||||
|
||||
useEffect(() => {
|
||||
// return if not editing or not rendered
|
||||
if (!isEditing || !spanRef.current) return;
|
||||
|
||||
// set span content
|
||||
spanRef.current.textContent = render ? render(value) : value;
|
||||
|
||||
// focus span
|
||||
spanRef.current.focus();
|
||||
|
||||
// select contents
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(spanRef.current);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
window.getSelection()?.addRange(range);
|
||||
}, [isEditing, value, render]);
|
||||
|
||||
const onConfirm = () => {
|
||||
const raw = spanRef.current?.textContent ?? "";
|
||||
const validated = validate ? validate(raw) : raw;
|
||||
if (validated) setValue(validated);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.editableFieldWrapper}>
|
||||
<div className={styles.valueWrapper}>
|
||||
<span
|
||||
ref={spanRef}
|
||||
data-cy={isEditing ? `${testID}-input` : testID}
|
||||
className={clsx(styles.editableField, {
|
||||
[styles.editingField]: isEditing,
|
||||
[styles.editableFieldDark]: contrastToken === "dark",
|
||||
[styles.editableFieldLight]: contrastToken === "light",
|
||||
})}
|
||||
contentEditable={isEditing}
|
||||
suppressContentEditableWarning
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={isEditing ? handleKeyDown : undefined}
|
||||
>
|
||||
{!isEditing && (render ? render(value) : value)}
|
||||
</span>
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<div>
|
||||
<button
|
||||
data-cy={`${testID}-cancel`}
|
||||
className={clsx(styles.editableFieldButton, {
|
||||
[styles.editableFieldButtonDark]: contrastToken === "dark",
|
||||
[styles.editableFieldButtonLight]: contrastToken === "light",
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
}}
|
||||
title="Cancel"
|
||||
>
|
||||
<X size={buttonSize} strokeWidth={3} />
|
||||
</button>
|
||||
<button
|
||||
data-cy={`${testID}-confirm`}
|
||||
className={clsx(styles.editableFieldButton, {
|
||||
[styles.editableFieldButtonDark]: contrastToken === "dark",
|
||||
[styles.editableFieldButtonLight]: contrastToken === "light",
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConfirm();
|
||||
}}
|
||||
title="Confirm"
|
||||
>
|
||||
<Check size={buttonSize} strokeWidth={3} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<button
|
||||
data-cy={`${testID}-edit`}
|
||||
className={clsx(styles.editableFieldButton, {
|
||||
[styles.editableFieldButtonDark]: contrastToken === "dark",
|
||||
[styles.editableFieldButtonLight]: contrastToken === "light",
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={buttonSize} strokeWidth={3} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PaletteEditor;
|
||||
|
||||
Reference in New Issue
Block a user