Files
luminance/src/components/PaletteEditor/PaletteEditor.tsx
T
jay 5f6d0f43ee
Test and Build / test-and-build (push) Failing after 2m44s
Completed palette editor, ui overhaul.
2026-03-23 08:24:44 -04:00

912 lines
24 KiB
TypeScript

import { useEffect, useMemo, useReducer, useRef, useState } from "react";
import type { KeyboardEvent, MouseEvent, RefObject } from "react";
import clsx from "clsx";
import { Hex as HexColor } from "colorlib";
import {
Check,
CheckCheck,
ChevronsLeft,
ChevronsRight,
CircleDashed,
Copy,
Crosshair,
Dot,
GripVertical,
Pencil,
Plus,
Redo2,
RefreshCw,
RefreshCwOff,
Trash2,
Undo2,
X,
} from "lucide-react";
import { motion } from "motion/react";
import type { ContrastToken } from "@/hooks/contrast";
import { luminanceFromHex, useContrastToken } from "@/hooks/contrast";
import { useDragAndDrop } from "@/hooks/dragAndDrop";
import { extractHexValue, formatHexString } from "@/hooks/hex";
import {
createPaletteCardActions,
paletteCardReducer,
} from "@/hooks/paletteCard";
import type {
PaletteCard,
PaletteCardActions,
PaletteCardState,
PaletteColor,
PaletteMode,
} from "@/hooks/paletteCard";
import {
loadCards,
saveActiveCardId,
saveCards,
serializeCard,
} from "@/hooks/storage";
import { randomId } from "@/util";
import styles from "./PaletteEditor.module.css";
type Timeout = ReturnType<typeof setTimeout>;
const SYNC_DELAY = 2000;
const DEFAULT_BG = HexColor.from_code("f6f6f6");
function defaultPaletteCard(): PaletteCardState {
const defaultCard = {
id: randomId(),
name: "New Palette",
colors: [],
selectedColorIds: [],
};
return {
present: defaultCard,
history: [],
future: [],
};
}
function PaletteEditor({
pickerColor,
setPickerColor,
initialCardState,
}: {
pickerColor: HexColor;
setPickerColor: (hex: HexColor) => void;
initialCardState?: PaletteCardState;
}) {
const [cardState, dispatch] = useReducer(
paletteCardReducer,
initialCardState || defaultPaletteCard(),
);
const actions = useMemo(() => createPaletteCardActions(dispatch), [dispatch]);
const [historyCounter, setHistoryCounter] = useState(0);
const [mode, setMode] = useState<PaletteMode>("normal");
const [isSynced, setIsSynced] = useState(false);
const snapshotRef = useRef<PaletteCard | null>(null);
const timerRef = useRef<Timeout | null>(null);
const incrementHistoryCounter = () => {
setHistoryCounter((prev) => prev + 1);
};
useEffect(() => {
saveActiveCardId(cardState.present.id);
}, []);
useEffect(() => {
const cards = loadCards();
cards[cardState.present.id] = serializeCard(cardState.present);
saveCards(cards);
}, [cardState.present]);
return (
<div className={styles.paletteEditor} data-cy="palette-editor">
<ActionBar
mode={mode}
setMode={setMode}
actions={actions}
hasSelection={cardState.present.selectedColorIds.length > 0}
canUndo={cardState.history.length > 0}
canRedo={cardState.future.length > 0}
isSynced={isSynced}
incrementHistoryCounter={incrementHistoryCounter}
snapshotRef={snapshotRef}
syncTimerRef={timerRef}
/>
<PaletteCard
pickerColor={pickerColor}
setPickerColor={setPickerColor}
cardState={cardState.present}
actions={actions}
mode={mode}
isSynced={isSynced}
setIsSynced={setIsSynced}
snapshotRef={snapshotRef}
syncTimerRef={timerRef}
historyCounter={historyCounter}
/>
</div>
);
}
function ActionBar({
mode,
setMode,
actions,
hasSelection,
canUndo,
canRedo,
isSynced,
incrementHistoryCounter,
snapshotRef,
syncTimerRef,
}: {
mode: PaletteMode;
setMode: (mode: PaletteMode) => void;
actions: PaletteCardActions;
hasSelection: boolean;
canUndo: boolean;
canRedo: boolean;
isSynced: boolean;
incrementHistoryCounter: () => void;
snapshotRef: RefObject<PaletteCard | null>;
syncTimerRef: RefObject<Timeout | null>;
}) {
const clearSyncTimeout = () => {
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
snapshotRef.current = null;
};
const handleUndo = () => {
if (isSynced) clearSyncTimeout();
incrementHistoryCounter();
actions.undo();
};
const handleRedo = () => {
if (isSynced) clearSyncTimeout();
incrementHistoryCounter();
actions.redo();
};
const handleModeChange = (next: PaletteMode) => {
if (mode === "normal") {
if (isSynced && syncTimerRef.current) {
clearTimeout(syncTimerRef.current);
if (snapshotRef.current) {
actions.commitToHistory(snapshotRef.current);
snapshotRef.current = null;
}
}
}
actions.clearSelection();
setMode(next);
};
return (
<div className={styles.actionBar} data-cy="action-bar">
<button
className={clsx(styles.actionButton, styles.iconButton)}
data-cy="undo"
disabled={!canUndo}
onClick={handleUndo}
title="Undo"
>
<Undo2 size={16} />
</button>
<button
className={clsx(styles.actionButton, styles.iconButton)}
data-cy="redo"
disabled={!canRedo}
onClick={handleRedo}
title="Redo"
>
<Redo2 size={16} />
</button>
<button
className={clsx(styles.actionButton, styles.iconButton)}
data-cy="add"
onClick={actions.addColor}
title="Add Color"
>
<Plus size={16} />
</button>
<button
className={clsx(styles.actionButton, styles.iconButton)}
data-cy="delete"
disabled={!hasSelection}
onClick={actions.deleteSelectedColors}
title="Delete Selected"
>
<Trash2 size={16} />
</button>
<button
className={clsx(styles.actionButton, styles.iconButton)}
data-cy="duplicate"
disabled={!hasSelection}
onClick={actions.duplicateSelectedColors}
title="Duplicate Selected"
>
<Copy size={16} />
</button>
<button
className={clsx(styles.actionButton, styles.wordButton, {
[styles.activeButton]: mode === "reorder",
})}
data-cy="reorder"
aria-pressed={mode === "reorder"}
onClick={() =>
handleModeChange(mode === "reorder" ? "normal" : "reorder")
}
title="Reorder Colors"
>
Reorder
</button>
<button
className={clsx(styles.actionButton, styles.wordButton, {
[styles.activeButton]: mode === "select",
})}
data-cy="select"
aria-pressed={mode === "select"}
onClick={() =>
handleModeChange(mode === "select" ? "normal" : "select")
}
title="Select Multiple"
>
Select
</button>
{mode === "select" && (
<>
<button
className={clsx(styles.actionButton, styles.iconButton)}
data-cy="select-all"
onClick={actions.selectAll}
title="Select All"
>
<CheckCheck size={16} />
</button>
<button
className={clsx(styles.actionButton, styles.iconButton)}
data-cy="clear"
onClick={actions.clearSelection}
title="Clear Selections"
>
<X size={16} />
</button>
</>
)}
</div>
);
}
function PaletteCard({
pickerColor,
setPickerColor,
cardState,
actions,
mode,
isSynced,
setIsSynced,
snapshotRef,
syncTimerRef,
historyCounter,
}: {
pickerColor: HexColor;
setPickerColor: (hex: HexColor) => void;
cardState: PaletteCard;
actions: PaletteCardActions;
mode: PaletteMode;
isSynced: boolean;
setIsSynced: (v: boolean) => void;
snapshotRef: RefObject<PaletteCard | null>;
syncTimerRef: RefObject<Timeout | null>;
historyCounter: number;
}) {
const selectedColor =
mode === "select"
? null
: cardState.selectedColorIds.length === 1
? cardState.colors.find((c) => c.id === cardState.selectedColorIds[0])
: null;
const wasSyncedRef = useRef(false);
const pickerColorValue = pickerColor.to_code();
// when sync toggles on, set picker <- palette
useEffect(() => {
if (!isSynced) {
wasSyncedRef.current = false;
return;
}
// handle toggle on
if (!wasSyncedRef.current) {
wasSyncedRef.current = true;
if (syncTimerRef.current) {
clearTimeout(syncTimerRef.current);
syncTimerRef.current = null;
if (snapshotRef.current) {
actions.commitToHistory(snapshotRef.current);
snapshotRef.current = null;
}
}
if (selectedColor) setPickerColor(selectedColor.hex);
}
}, [selectedColor?.id, isSynced]);
// during sync, set picker -> palette
useEffect(() => {
if (!isSynced || !selectedColor || !wasSyncedRef.current) return;
if (!snapshotRef.current) {
snapshotRef.current = cardState; // capture pre-change state once
}
actions.setColorValueSilent(selectedColor.id, pickerColor);
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
syncTimerRef.current = setTimeout(() => {
actions.commitToHistory(snapshotRef.current!);
snapshotRef.current = null;
}, SYNC_DELAY);
}, [pickerColorValue]);
// when selection changes, set picker <- palette
useEffect(() => {
if (!isSynced || !selectedColor) return;
if (syncTimerRef.current) {
clearTimeout(syncTimerRef.current);
syncTimerRef.current = null;
if (snapshotRef.current) {
snapshotRef.current = null;
}
}
setPickerColor(selectedColor.hex);
}, [selectedColor?.id]);
// undo/redo, set picker <- palette
useEffect(() => {
if (!isSynced || !selectedColor || !wasSyncedRef.current) return;
setPickerColor(selectedColor.hex);
}, [historyCounter]);
return (
<div className={styles.cardWrapper} data-cy="palette-card">
<SyncButton isSynced={isSynced} setIsSynced={setIsSynced} />
<CardHeader name={cardState.name} onNameChange={actions.setCardName} />
<PickerColor
pickerColor={pickerColor}
paletteColorId={selectedColor?.id || null}
setPaletteColor={actions.setColorValue}
isSynced={isSynced}
/>
<PaletteColor
selectedColor={selectedColor?.hex || null}
setPickerColor={setPickerColor}
isSynced={isSynced}
/>
<Palette cardState={cardState} actions={actions} mode={mode} />
</div>
);
}
function SyncButton({
isSynced,
setIsSynced,
}: {
isSynced: boolean;
setIsSynced: (v: boolean) => void;
}) {
return (
<div
className={styles.sync}
data-cy="sync"
onClick={() => setIsSynced(!isSynced)}
aria-pressed={isSynced}
style={{
color: isSynced ? "#292929" : "#7a7a7a",
}}
title={isSynced ? "Unsync Picker & Palette" : "Sync Picker and Palette"}
>
<span className={styles.leftSpan}>Picker</span>
<span className={styles.middleSpan}>
{isSynced ? <RefreshCw size={22} /> : <RefreshCwOff size={22} />}
</span>
<span className={styles.rightSpan}>Palette</span>
</div>
);
}
function CardHeader({
name,
onNameChange,
}: {
name: string;
onNameChange: (name: string) => void;
}) {
return (
<div className={styles.cardHeader} data-cy="card-header">
<EditableField
testID="card-name"
value={name}
setValue={onNameChange}
buttonSize={12}
/>
</div>
);
}
function PickerColor({
pickerColor,
paletteColorId,
setPaletteColor,
isSynced,
}: {
pickerColor: HexColor;
paletteColorId: string | null;
setPaletteColor: (id: string, hex: HexColor) => void;
isSynced: boolean;
}) {
const arrowToken = useContrastToken(() => luminanceFromHex(pickerColor));
const handleClick = () => {
if (!isSynced && paletteColorId) {
setPaletteColor(paletteColorId, pickerColor);
}
};
return (
<div
className={clsx(styles.previewPane, styles.pickerColor)}
data-cy="picker-preview"
style={{
cursor: isSynced ? "unset" : "pointer",
backgroundColor: formatHexString(pickerColor),
}}
onClick={handleClick}
title={!isSynced ? "Send to Picker" : ""}
>
{!isSynced && (
<div
data-cy="picker-color-arrow"
className={clsx(styles.arrowIndicator, {
[styles.arrowIndicatorDark]: arrowToken === "dark",
[styles.arrowIndicatorLight]: arrowToken === "light",
})}
>
<ChevronsRight size={32} />
</div>
)}
</div>
);
}
function PaletteColor({
selectedColor,
setPickerColor,
isSynced,
}: {
selectedColor: HexColor | null;
setPickerColor: (hex: HexColor) => void;
isSynced: boolean;
}) {
const bgColor = selectedColor || DEFAULT_BG;
const arrowToken = useContrastToken(() => luminanceFromHex(bgColor));
const handleClick = () => {
if (!isSynced && selectedColor) {
setPickerColor(selectedColor);
}
};
return (
<div
className={clsx(styles.previewPane, styles.paletteColor)}
data-cy="selected-preview"
style={{
cursor: isSynced ? "unset" : "pointer",
backgroundColor: formatHexString(bgColor),
}}
onClick={handleClick}
title={!isSynced ? "Send to Palette" : ""}
>
{!isSynced && (
<div
data-cy="palette-color-arrow"
className={clsx(styles.arrowIndicator, {
[styles.arrowIndicatorDark]: arrowToken === "dark",
[styles.arrowIndicatorLight]: arrowToken === "light",
})}
>
<ChevronsLeft size={32} />
</div>
)}
</div>
);
}
function Palette({
cardState,
actions,
mode,
}: {
cardState: PaletteCard;
actions: PaletteCardActions;
mode: PaletteMode;
}) {
const {
containerRef,
setItemRef,
isDragging,
// sourceIndex,
targetIndex,
previewItems,
} = useDragAndDrop({
items: cardState.colors,
handleReorder: actions.reorderColors,
disabled: mode !== "reorder",
});
const handleNormalClick = (color: PaletteColor) => {
const ids = cardState.selectedColorIds;
const isSelected = ids.includes(color.id);
console.log(color.id, isSelected);
if (isSelected) {
actions.setSelectedColors([]);
} else {
actions.setSelectedColors([color.id]);
}
};
const handleSelectClick = (color: PaletteColor) => {
const ids = cardState.selectedColorIds;
const next = ids.includes(color.id)
? ids.filter((id) => id !== color.id)
: [...ids, color.id];
actions.setSelectedColors(next);
};
const onRowClick =
mode === "normal"
? handleNormalClick
: mode === "select"
? handleSelectClick
: undefined;
const displayColors = isDragging ? previewItems : cardState.colors;
return (
<div className={styles.palette} data-cy="palette" ref={containerRef}>
{displayColors.length > 0 ? (
displayColors.map((color, index) => (
<PaletteRow
key={color.id}
color={color}
index={index}
isSelected={cardState.selectedColorIds.includes(color.id)}
isEditable={
mode === "normal" && cardState.selectedColorIds[0] === color.id
}
isDragging={isDragging}
mode={mode}
actions={actions}
onRowClick={onRowClick}
setItemRef={setItemRef}
targetIndex={targetIndex}
/>
))
) : (
<span style={{ margin: 8 }}>
No colors in palette. Press{" "}
<Plus size={16} style={{ transform: "translateY(2px)" }} /> in the
toolbar above to add one.
</span>
)}
</div>
);
}
function PaletteRow({
color,
index,
isSelected,
isEditable,
isDragging,
mode,
actions,
onRowClick,
setItemRef,
targetIndex,
}: {
color: PaletteColor;
index: number;
isSelected: boolean;
isEditable: boolean;
isDragging: boolean;
mode: PaletteMode;
actions: PaletteCardActions;
onRowClick?: (color: PaletteColor, e: MouseEvent<HTMLDivElement>) => void;
setItemRef: (el: HTMLElement | null, id: string) => void;
targetIndex: number;
}) {
const isNormalMode = mode === "normal";
const isSelectMode = mode === "select";
const isReorderMode = mode === "reorder";
const Wrapper = isReorderMode ? motion.div : "div";
const motionProps = isReorderMode
? { layout: true, transition: { duration: 0.25 } }
: {};
const token = useContrastToken(() => luminanceFromHex(color.hex));
return (
<Wrapper
className={clsx(styles.paletteRowWrapper, {
[styles.draggable]: isReorderMode,
[styles.dragging]: isDragging && index === targetIndex,
[styles.multiSelected]: isSelectMode && isSelected,
})}
ref={(el) => setItemRef(el, color.id)}
data-cy={`palette-row-${index}-wrapper`}
data-item-id={color.id}
onClick={(e) => onRowClick?.(color, e)}
{...motionProps}
>
<div
className={styles.paletteRow}
data-cy={`palette-row-${index}`}
aria-selected={isSelected}
style={{
backgroundColor: formatHexString(color.hex),
}}
>
{isSelectMode && (
<span
className={clsx(styles.modeDecorator, styles.checkDecorator, {
[styles.checkDecoratorSelected]: isSelected,
[styles.checkDecoratorDark]: token === "dark",
[styles.checkDecoratorLight]: token === "light",
})}
>
{isSelected && <Check size={18} strokeWidth={3} />}
</span>
)}
{mode === "reorder" && (
<span
className={clsx(styles.modeDecorator, {
[styles.gripDark]: token === "dark",
[styles.gripLight]: token === "light",
})}
>
<GripVertical size={24} />
</span>
)}
{isNormalMode &&
(isSelected ? (
<Crosshair
className={clsx(styles.selectedIndicator, {
[styles.indicatorDark]: token === "dark",
[styles.indicatorLight]: token === "light",
})}
size={27}
>
<Dot />
</Crosshair>
) : (
<CircleDashed
className={clsx(styles.targettedIndicator, {
[styles.indicatorDark]: token === "dark",
[styles.indicatorLight]: token === "light",
})}
size={32}
/>
))}
<div className={styles.paletteRowData}>
<div className={styles.colorName}>
{isEditable ? (
<EditableField
testID={`palette-row-name-${index}`}
value={color.name}
setValue={(newName: string) =>
actions.setColorName(color.id, newName)
}
buttonSize={12}
contrastToken={token}
reset={!isSelected}
/>
) : (
<div>
<span
data-cy={`palette-row-name-${index}`}
className={clsx(styles.field, {
[styles.fieldDark]: token === "dark",
[styles.fieldLight]: token === "light",
})}
>
{color.name}
</span>
</div>
)}
</div>
<div className={styles.colorHex}>
{isEditable ? (
<EditableField
testID={`palette-row-hex-${index}`}
value={color.hex.to_code()}
setValue={(newHex: string) =>
actions.setColorValue(color.id, HexColor.from_code(newHex))
}
buttonSize={12}
contrastToken={token}
reset={!isSelected}
validate={(raw: string) => extractHexValue(raw)}
render={(raw: string) => `#${raw}`}
/>
) : (
<div className={styles.fieldWrapper}>
<span
data-cy={`palette-row-hex-${index}`}
className={clsx(styles.field, {
[styles.fieldDark]: token === "dark",
[styles.fieldLight]: token === "light",
})}
>
{formatHexString(color.hex)}
</span>
</div>
)}
</div>
</div>
</div>
</Wrapper>
);
}
function EditableField({
testID,
value,
setValue,
buttonSize,
contrastToken,
reset,
validate,
render,
}: {
testID: string;
value: string;
setValue: (v: string) => void;
buttonSize: number;
contrastToken?: ContrastToken;
reset?: boolean;
validate?: (raw: string) => string | null;
render?: (raw: string) => string;
}) {
const [isEditing, setIsEditing] = useState(false);
const spanRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (reset) setIsEditing(false);
}, [reset]);
useEffect(() => {
// return if not editing or not rendered
if (!isEditing || !spanRef.current) return;
// set span content
spanRef.current.textContent = render ? render(value) : value;
// focus span
spanRef.current.focus();
// select contents
const range = document.createRange();
range.selectNodeContents(spanRef.current);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
}, [isEditing, value, render]);
const onConfirm = () => {
const raw = spanRef.current?.textContent ?? "";
const validated = validate ? validate(raw) : raw;
if (validated) setValue(validated);
setIsEditing(false);
};
const onCancel = () => {
window.getSelection()?.removeAllRanges();
setIsEditing(false);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
} else if (e.key === "Enter") {
e.preventDefault();
onConfirm();
}
};
return (
<div className={styles.editableFieldWrapper}>
<div className={styles.valueWrapper}>
<span
ref={spanRef}
data-cy={isEditing ? `${testID}-input` : testID}
className={clsx(styles.editableField, {
[styles.editingField]: isEditing,
[styles.editableFieldDark]: contrastToken === "dark",
[styles.editableFieldLight]: contrastToken === "light",
})}
contentEditable={isEditing}
suppressContentEditableWarning
onClick={(e) => e.stopPropagation()}
onKeyDown={isEditing ? handleKeyDown : undefined}
>
{!isEditing && (render ? render(value) : value)}
</span>
</div>
{isEditing ? (
<div>
<button
data-cy={`${testID}-cancel`}
className={clsx(styles.editableFieldButton, {
[styles.editableFieldButtonDark]: contrastToken === "dark",
[styles.editableFieldButtonLight]: contrastToken === "light",
})}
onClick={(e) => {
e.stopPropagation();
onCancel();
}}
title="Cancel"
>
<X size={buttonSize} strokeWidth={3} />
</button>
<button
data-cy={`${testID}-confirm`}
className={clsx(styles.editableFieldButton, {
[styles.editableFieldButtonDark]: contrastToken === "dark",
[styles.editableFieldButtonLight]: contrastToken === "light",
})}
onClick={(e) => {
e.stopPropagation();
onConfirm();
}}
title="Confirm"
>
<Check size={buttonSize} strokeWidth={3} />
</button>
</div>
) : (
<div>
<button
data-cy={`${testID}-edit`}
className={clsx(styles.editableFieldButton, {
[styles.editableFieldButtonDark]: contrastToken === "dark",
[styles.editableFieldButtonLight]: contrastToken === "light",
})}
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
title="Edit"
>
<Pencil size={buttonSize} strokeWidth={3} />
</button>
</div>
)}
</div>
);
}
export default PaletteEditor;