This commit is contained in:
@@ -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