diff --git a/frontend/src/features/themes/editor/fields/ColorField.tsx b/frontend/src/features/themes/editor/fields/ColorField.tsx index b194157..ac3db9e 100644 --- a/frontend/src/features/themes/editor/fields/ColorField.tsx +++ b/frontend/src/features/themes/editor/fields/ColorField.tsx @@ -1,4 +1,4 @@ -import { type CSSProperties, useId, useState } from 'react' +import { type CSSProperties, memo, useId, useState } from 'react' import { useTranslation } from 'react-i18next' import { argbToCss, parseArgb } from '../../types' import './fields.css' @@ -16,7 +16,7 @@ const withAlpha = (hex: string, rgb6: string) => `#${pad2(parseArgb(hex).a)}${rg const withRgb = (hex: string, a: number) => `#${pad2(a)}${toRgb6(hex).slice(1)}`.toUpperCase() /** One editable palette slot: native color picker + alpha slider + hex field. */ -export function ColorField({ label, value, onChange }: Props) { +export const ColorField = memo(function ColorField({ label, value, onChange }: Props) { const { t: tr } = useTranslation('themes') const id = useId() // Local mirror so a partially-typed hex isn't clobbered mid-edit; reset when the value changes @@ -70,4 +70,4 @@ export function ColorField({ label, value, onChange }: Props) { ) -} +}) diff --git a/frontend/src/features/themes/editor/fields/GradientField.tsx b/frontend/src/features/themes/editor/fields/GradientField.tsx index 27ae642..afdb438 100644 --- a/frontend/src/features/themes/editor/fields/GradientField.tsx +++ b/frontend/src/features/themes/editor/fields/GradientField.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react' import { useTranslation } from 'react-i18next' import { cssAngle } from '@features/clickgui/themeVars' import { argbToCss, type GradientSpec } from '../../types' @@ -9,7 +10,7 @@ type Props = { title: string; value: GradientSpec; onChange: (patch: Partial ) -} +}) diff --git a/frontend/src/features/themes/editor/panel/PaletteTabs.tsx b/frontend/src/features/themes/editor/panel/PaletteTabs.tsx index 2dc5fcb..0830a90 100644 --- a/frontend/src/features/themes/editor/panel/PaletteTabs.tsx +++ b/frontend/src/features/themes/editor/panel/PaletteTabs.tsx @@ -1,4 +1,4 @@ -import { type KeyboardEvent, useId, useState } from 'react' +import { type KeyboardEvent, useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import type { GradientSpec, ThemeColors, ThemeEntry } from '../../types' import { ColorField } from '../fields/ColorField' @@ -24,9 +24,34 @@ export function PaletteTabs({ draft, onChange }: Props) { const [tab, setTab] = useState('colors') const base = useId() - const setColor = (slot: keyof ThemeColors, hex: string) => onChange({ ...draft, theme: { ...draft.theme, [slot]: hex } }) - const setGradient = (key: keyof ThemeEntry, patch: Partial) => + // ColorField/GradientField are memoized so an edit to one slot doesn't re-render the other + // eleven. That only holds if their `onChange` prop is referentially stable too -- a fresh + // `(hex) => setColor(slot, hex)` per render would defeat memo for every field, every time. + // `latest` lets setColor/setGradient stay stable (empty deps) while still reading the + // current draft/onChange; the per-slot wrapper closures are then built once in a `useMemo` + // keyed off those stable functions, not off `draft`. + const latest = useRef({ draft, onChange }) + useLayoutEffect(() => { + latest.current = { draft, onChange } + }) + + const setColor = useCallback((slot: keyof ThemeColors, hex: string) => { + const { draft, onChange } = latest.current + onChange({ ...draft, theme: { ...draft.theme, [slot]: hex } }) + }, []) + const setGradient = useCallback((key: keyof ThemeEntry, patch: Partial) => { + const { draft, onChange } = latest.current onChange({ ...draft, [key]: { ...(draft[key] as GradientSpec), ...patch } }) + }, []) + + const colorHandlers = useMemo(() => { + const entries = GROUPS.flatMap((g) => g.slots).map((slot) => [slot, (hex: string) => setColor(slot, hex)] as const) + return Object.fromEntries(entries) as Record void> + }, [setColor]) + const gradientHandlers = useMemo(() => { + const entries = GRADIENT_SLOTS.map((g) => [g.key, (patch: Partial) => setGradient(g.key, patch)] as const) + return Object.fromEntries(entries) as Record<(typeof GRADIENT_SLOTS)[number]['key'], (patch: Partial) => void> + }, [setGradient]) const onKey = (e: KeyboardEvent) => { if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return @@ -60,7 +85,7 @@ export function PaletteTabs({ draft, onChange }: Props) { {t(`editor.group.${group.key}`)}
{group.slots.map((slot) => ( - setColor(slot, hex)} /> + ))}
@@ -73,7 +98,7 @@ export function PaletteTabs({ draft, onChange }: Props) { key={g.prefix} title={t(`editor.gradient.${g.prefix}`)} value={draft[g.key] as GradientSpec} - onChange={(patch) => setGradient(g.key, patch)} + onChange={gradientHandlers[g.key]} /> ))}