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