import { useEffect, useMemo, useReducer, useRef, useState } from "react"; import type { KeyboardEvent, MouseEvent, RefObject } from "react"; import clsx from "clsx"; import { Hex as HexColor } from "colorlib"; import { Check, CheckCheck, ChevronsLeft, ChevronsRight, CircleDashed, Copy, Crosshair, Dot, GripVertical, Pencil, Plus, Redo2, RefreshCw, RefreshCwOff, Trash2, Undo2, X, } from "lucide-react"; import { motion } from "motion/react"; import type { ContrastToken } from "@/hooks/contrast"; import { luminanceFromHex, useContrastToken } from "@/hooks/contrast"; import { useDragAndDrop } from "@/hooks/dragAndDrop"; import { extractHexValue, formatHexString } from "@/hooks/hex"; import { createPaletteCardActions, paletteCardReducer, } from "@/hooks/paletteCard"; import type { PaletteCard, PaletteCardActions, PaletteCardState, PaletteColor, PaletteMode, } from "@/hooks/paletteCard"; import { loadCards, saveActiveCardId, saveCards, serializeCard, } from "@/hooks/storage"; import { randomId } from "@/util"; import styles from "./PaletteEditor.module.css"; type Timeout = ReturnType; const SYNC_DELAY = 2000; const DEFAULT_BG = HexColor.from_code("f6f6f6"); function defaultPaletteCard(): PaletteCardState { const defaultCard = { id: randomId(), name: "New Palette", colors: [], selectedColorIds: [], }; return { present: defaultCard, history: [], future: [], }; } function PaletteEditor({ pickerColor, setPickerColor, initialCardState, }: { pickerColor: HexColor; setPickerColor: (hex: HexColor) => void; initialCardState?: PaletteCardState; }) { const [cardState, dispatch] = useReducer( paletteCardReducer, initialCardState || defaultPaletteCard(), ); const actions = useMemo(() => createPaletteCardActions(dispatch), [dispatch]); const [historyCounter, setHistoryCounter] = useState(0); const [mode, setMode] = useState("normal"); const [isSynced, setIsSynced] = useState(false); const snapshotRef = useRef(null); const timerRef = useRef(null); const incrementHistoryCounter = () => { setHistoryCounter((prev) => prev + 1); }; useEffect(() => { saveActiveCardId(cardState.present.id); }, []); useEffect(() => { const cards = loadCards(); cards[cardState.present.id] = serializeCard(cardState.present); saveCards(cards); }, [cardState.present]); return (
0} canUndo={cardState.history.length > 0} canRedo={cardState.future.length > 0} isSynced={isSynced} incrementHistoryCounter={incrementHistoryCounter} snapshotRef={snapshotRef} syncTimerRef={timerRef} />
); } function ActionBar({ mode, setMode, actions, hasSelection, canUndo, canRedo, isSynced, incrementHistoryCounter, snapshotRef, syncTimerRef, }: { mode: PaletteMode; setMode: (mode: PaletteMode) => void; actions: PaletteCardActions; hasSelection: boolean; canUndo: boolean; canRedo: boolean; isSynced: boolean; incrementHistoryCounter: () => void; snapshotRef: RefObject; syncTimerRef: RefObject; }) { const clearSyncTimeout = () => { if (syncTimerRef.current) clearTimeout(syncTimerRef.current); snapshotRef.current = null; }; const handleUndo = () => { if (isSynced) clearSyncTimeout(); incrementHistoryCounter(); actions.undo(); }; const handleRedo = () => { if (isSynced) clearSyncTimeout(); incrementHistoryCounter(); actions.redo(); }; const handleModeChange = (next: PaletteMode) => { if (mode === "normal") { if (isSynced && syncTimerRef.current) { clearTimeout(syncTimerRef.current); if (snapshotRef.current) { actions.commitToHistory(snapshotRef.current); snapshotRef.current = null; } } } actions.clearSelection(); setMode(next); }; return (
{mode === "select" && ( <> )}
); } function PaletteCard({ pickerColor, setPickerColor, cardState, actions, mode, isSynced, setIsSynced, snapshotRef, syncTimerRef, historyCounter, }: { pickerColor: HexColor; setPickerColor: (hex: HexColor) => void; cardState: PaletteCard; actions: PaletteCardActions; mode: PaletteMode; isSynced: boolean; setIsSynced: (v: boolean) => void; snapshotRef: RefObject; syncTimerRef: RefObject; historyCounter: number; }) { const selectedColor = mode === "select" ? null : cardState.selectedColorIds.length === 1 ? cardState.colors.find((c) => c.id === cardState.selectedColorIds[0]) : null; const wasSyncedRef = useRef(false); const pickerColorValue = pickerColor.to_code(); // when sync toggles on, set picker <- palette useEffect(() => { if (!isSynced) { wasSyncedRef.current = false; return; } // handle toggle on if (!wasSyncedRef.current) { wasSyncedRef.current = true; if (syncTimerRef.current) { clearTimeout(syncTimerRef.current); syncTimerRef.current = null; if (snapshotRef.current) { actions.commitToHistory(snapshotRef.current); snapshotRef.current = null; } } if (selectedColor) setPickerColor(selectedColor.hex); } }, [selectedColor?.id, isSynced]); // during sync, set picker -> palette useEffect(() => { if (!isSynced || !selectedColor || !wasSyncedRef.current) return; if (!snapshotRef.current) { snapshotRef.current = cardState; // capture pre-change state once } actions.setColorValueSilent(selectedColor.id, pickerColor); if (syncTimerRef.current) clearTimeout(syncTimerRef.current); syncTimerRef.current = setTimeout(() => { actions.commitToHistory(snapshotRef.current!); snapshotRef.current = null; }, SYNC_DELAY); }, [pickerColorValue]); // when selection changes, set picker <- palette useEffect(() => { if (!isSynced || !selectedColor) return; if (syncTimerRef.current) { clearTimeout(syncTimerRef.current); syncTimerRef.current = null; if (snapshotRef.current) { snapshotRef.current = null; } } setPickerColor(selectedColor.hex); }, [selectedColor?.id]); // undo/redo, set picker <- palette useEffect(() => { if (!isSynced || !selectedColor || !wasSyncedRef.current) return; setPickerColor(selectedColor.hex); }, [historyCounter]); return (
); } function SyncButton({ isSynced, setIsSynced, }: { isSynced: boolean; setIsSynced: (v: boolean) => void; }) { return (
setIsSynced(!isSynced)} aria-pressed={isSynced} style={{ color: isSynced ? "#292929" : "#7a7a7a", }} title={isSynced ? "Unsync Picker & Palette" : "Sync Picker and Palette"} > Picker {isSynced ? : } Palette
); } function CardHeader({ name, onNameChange, }: { name: string; onNameChange: (name: string) => void; }) { return (
); } function PickerColor({ pickerColor, setPickerColor, selectedColor, isSynced, }: { pickerColor: HexColor; setPickerColor: (hex: HexColor) => void; selectedColor: HexColor | null; isSynced: boolean; }) { const arrowToken = useContrastToken(() => luminanceFromHex(pickerColor)); const handleClick = () => { if (!isSynced && selectedColor) { setPickerColor(selectedColor); } }; return (
{!isSynced && (
)}
); } function PaletteColor({ selectedColor, pickerColor, paletteColorId, setPaletteColor, isSynced, }: { selectedColor: HexColor | null; pickerColor: HexColor; paletteColorId: string | null; setPaletteColor: (id: string, hex: HexColor) => void; isSynced: boolean; }) { const bgColor = selectedColor || DEFAULT_BG; const arrowToken = useContrastToken(() => luminanceFromHex(bgColor)); const handleClick = () => { if (!isSynced && paletteColorId) { setPaletteColor(paletteColorId, pickerColor); } }; return (
{!isSynced && (
)}
); } function Palette({ cardState, actions, mode, }: { cardState: PaletteCard; actions: PaletteCardActions; mode: PaletteMode; }) { const { containerRef, setItemRef, isDragging, // sourceIndex, targetIndex, previewItems, } = useDragAndDrop({ items: cardState.colors, handleReorder: actions.reorderColors, disabled: mode !== "reorder", }); const handleNormalClick = (color: PaletteColor) => { const ids = cardState.selectedColorIds; const isSelected = ids.includes(color.id); console.log(color.id, isSelected); if (isSelected) { actions.setSelectedColors([]); } else { actions.setSelectedColors([color.id]); } }; const handleSelectClick = (color: PaletteColor) => { const ids = cardState.selectedColorIds; const next = ids.includes(color.id) ? ids.filter((id) => id !== color.id) : [...ids, color.id]; actions.setSelectedColors(next); }; const onRowClick = mode === "normal" ? handleNormalClick : mode === "select" ? handleSelectClick : undefined; const displayColors = isDragging ? previewItems : cardState.colors; return (
{displayColors.length > 0 ? ( displayColors.map((color, index) => ( )) ) : ( No colors in palette. Press{" "} in the toolbar above to add one. )}
); } function PaletteRow({ color, index, isSelected, isEditable, isDragging, mode, actions, onRowClick, setItemRef, targetIndex, }: { color: PaletteColor; index: number; isSelected: boolean; isEditable: boolean; isDragging: boolean; mode: PaletteMode; actions: PaletteCardActions; onRowClick?: (color: PaletteColor, e: MouseEvent) => void; setItemRef: (el: HTMLElement | null, id: string) => void; targetIndex: number; }) { const isNormalMode = mode === "normal"; const isSelectMode = mode === "select"; const isReorderMode = mode === "reorder"; const Wrapper = isReorderMode ? motion.div : "div"; const motionProps = isReorderMode ? { layout: true, transition: { duration: 0.25 } } : {}; const token = useContrastToken(() => luminanceFromHex(color.hex)); return ( setItemRef(el, color.id)} data-cy={`palette-row-${index}-wrapper`} data-item-id={color.id} onClick={(e) => onRowClick?.(color, e)} {...motionProps} >
{isSelectMode && ( {isSelected && } )} {mode === "reorder" && ( )} {isNormalMode && (isSelected ? ( ) : ( ))}
{isEditable ? ( actions.setColorName(color.id, newName) } buttonSize={12} contrastToken={token} reset={!isSelected} /> ) : (
{color.name}
)}
{isEditable ? ( actions.setColorValue(color.id, HexColor.from_code(newHex)) } buttonSize={12} contrastToken={token} reset={!isSelected} validate={(raw: string) => extractHexValue(raw)} render={(raw: string) => `#${raw}`} /> ) : (
{formatHexString(color.hex)}
)}
); } function EditableField({ testID, value, setValue, buttonSize, contrastToken, reset, validate, render, }: { testID: string; value: string; setValue: (v: string) => void; buttonSize: number; contrastToken?: ContrastToken; reset?: boolean; validate?: (raw: string) => string | null; render?: (raw: string) => string; }) { const [isEditing, setIsEditing] = useState(false); const spanRef = useRef(null); useEffect(() => { if (reset) setIsEditing(false); }, [reset]); useEffect(() => { // return if not editing or not rendered if (!isEditing || !spanRef.current) return; // set span content spanRef.current.textContent = render ? render(value) : value; // focus span spanRef.current.focus(); // select contents const range = document.createRange(); range.selectNodeContents(spanRef.current); window.getSelection()?.removeAllRanges(); window.getSelection()?.addRange(range); }, [isEditing, value, render]); const onConfirm = () => { const raw = spanRef.current?.textContent ?? ""; const validated = validate ? validate(raw) : raw; if (validated) setValue(validated); setIsEditing(false); }; const onCancel = () => { window.getSelection()?.removeAllRanges(); setIsEditing(false); }; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); onCancel(); } else if (e.key === "Enter") { e.preventDefault(); onConfirm(); } }; return (
e.stopPropagation()} onKeyDown={isEditing ? handleKeyDown : undefined} > {!isEditing && (render ? render(value) : value)}
{isEditing ? (
) : (
)}
); } export default PaletteEditor;