diff --git a/tgui/packages/tgui/interfaces/ColorPickerModal.tsx b/tgui/packages/tgui/interfaces/ColorPickerModal.tsx index e5c8f1cb732..8177b609114 100644 --- a/tgui/packages/tgui/interfaces/ColorPickerModal.tsx +++ b/tgui/packages/tgui/interfaces/ColorPickerModal.tsx @@ -16,9 +16,14 @@ import { } from 'common/color'; import { clamp } from 'common/math'; import { classes } from 'common/react'; -import React, { FocusEvent, FormEvent, useEffect, useState } from 'react'; +import React, { + FocusEvent, + FormEvent, + useCallback, + useEffect, + useState, +} from 'react'; import { Interactive } from 'tgui/components/Interactive'; -import { logger } from 'tgui/logging'; import { useBackend } from '../backend'; import { @@ -121,7 +126,6 @@ export const ColorPresets = ({ setColor, setShowPresets }) => { return ( <Box key={entry} p="1px" backgroundColor="black"> <Box - key={entry} p="1px" backgroundColor="#AAAAAA" onClick={() => setColor(hexToHsva(entry))} @@ -147,11 +151,12 @@ export const ColorPresets = ({ setColor, setShowPresets }) => { }; export const ColorSelector = ({ color, setColor, defaultColor }) => { - const handleChange = (params: Partial<HsvaColor>) => { - setColor((current: HsvaColor) => { - return { ...current, ...params }; - }); - }; + const handleChange = useCallback( + (params: Partial<HsvaColor>) => { + setColor((current) => ({ ...current, ...params })); + }, + [setColor], + ); const [showPresets, setShowPresets] = useState<boolean>(false); const rgb = hsvaToRgba(color); @@ -199,13 +204,12 @@ export const ColorSelector = ({ color, setColor, defaultColor }) => { </Stack> </Flex.Item> <Flex.Item grow fontSize="15px" lineHeight="24px"> - {showPresets && ( + {showPresets ? ( <ColorPresets setColor={handleChange} setShowPresets={setShowPresets} /> - )} - {!showPresets && ( + ) : ( <Stack vertical> <Stack.Item> <Stack> @@ -217,7 +221,6 @@ export const ColorSelector = ({ color, setColor, defaultColor }) => { fluid color={hsvaToHex(color).substring(1)} onChange={(value) => { - logger.info(value); setColor(hexToHsva(value)); }} /> @@ -242,7 +245,7 @@ export const ColorSelector = ({ color, setColor, defaultColor }) => { <Stack.Item> <TextSetter value={color.h} - callback={(_, v) => handleChange({ h: v })} + callback={(v) => handleChange({ h: v })} max={360} unit="°" /> @@ -260,7 +263,7 @@ export const ColorSelector = ({ color, setColor, defaultColor }) => { <Stack.Item> <TextSetter value={color.s} - callback={(_, v) => handleChange({ s: v })} + callback={(v) => handleChange({ s: v })} unit="%" /> </Stack.Item> @@ -277,7 +280,7 @@ export const ColorSelector = ({ color, setColor, defaultColor }) => { <Stack.Item> <TextSetter value={color.v} - callback={(_, v) => handleChange({ v: v })} + callback={(v) => handleChange({ v })} unit="%" /> </Stack.Item> @@ -295,9 +298,8 @@ export const ColorSelector = ({ color, setColor, defaultColor }) => { <Stack.Item> <TextSetter value={rgb.r} - callback={(_, v) => { - rgb.r = v; - handleChange(rgbaToHsva(rgb)); + callback={(v) => { + handleChange(rgbaToHsva({ ...rgb, r: v })); }} max={255} /> @@ -315,9 +317,8 @@ export const ColorSelector = ({ color, setColor, defaultColor }) => { <Stack.Item> <TextSetter value={rgb.g} - callback={(_, v) => { - rgb.g = v; - handleChange(rgbaToHsva(rgb)); + callback={(v) => { + handleChange(rgbaToHsva({ ...rgb, g: v })); }} max={255} /> @@ -335,9 +336,8 @@ export const ColorSelector = ({ color, setColor, defaultColor }) => { <Stack.Item> <TextSetter value={rgb.b} - callback={(_, v) => { - rgb.b = v; - handleChange(rgbaToHsva(rgb)); + callback={(v) => { + handleChange(rgbaToHsva({ ...rgb, b: v })); }} max={255} /> @@ -359,7 +359,7 @@ const TextSetter = ({ unit, }: { value: number; - callback: any; + callback: (value: number) => void; min?: number; max?: number; unit?: string; @@ -392,70 +392,38 @@ const HexColorInput = ({ onChange: (newColor: string) => void; }) => { const escape = (value: string) => - value.replace(/([^0-9A-F]+)/gi, '').substring(0, alpha ? 8 : 6); - const validate = (value: string) => validHex(value, alpha); + value + .replace(/[^0-9A-Fa-f]/g, '') + .substring(0, alpha ? 8 : 6) + .toUpperCase(); + const validate = (value: string) => + validHex(value, alpha) && + (value.length === 6 || (alpha && value.length === 8)); const [localValue, setLocalValue] = useState(escape(color)); - // Обновляем localValue при изменении color useEffect(() => { - setLocalValue(escape(color)); + if (escape(color) !== localValue) { + setLocalValue(escape(color)); + } }, [color]); const handleInput = (e: FormEvent<HTMLInputElement>) => { const inputValue = escape(e.currentTarget.value); setLocalValue(inputValue); - }; - const handleBlur = (e: FocusEvent<HTMLInputElement>) => { - if (!validate(e.currentTarget.value)) { - setLocalValue(escape(color)); // return to default; - } else { - onChange(escape(e.currentTarget.value)); + if (inputValue.length === 6 && validate(inputValue)) { + onChange(inputValue); } }; - return ( - <Box className={classes(['Input', fluid && 'Input--fluid'])}> - <div className="Input__baseline">.</div> - <input - className="Input__input" - value={localValue} - spellCheck={false} - onInput={handleInput} - onBlur={handleBlur} - /> - </Box> - ); -}; - -const ColorInput = ({ - fluid, - color, - onChange, - escape, - validate, - format, -}: { - fluid?: boolean; - color: string; - onChange: (newColor: string) => void; - escape: (value: string) => string; - validate: (value: string) => boolean; - format?: (value: string) => string; -}) => { - const [localValue, setLocalValue] = useState(escape(color)); - - const handleInput = (e: FormEvent<HTMLInputElement>) => { + const handleBlur = (e: FocusEvent<HTMLInputElement>) => { const inputValue = escape(e.currentTarget.value); - setLocalValue(inputValue); - }; - const handleBlur = (e: FocusEvent<HTMLInputElement>) => { - if (!validate(e.currentTarget.value)) { - setLocalValue(escape(color)); // return to default; + if (!validate(inputValue) || inputValue.length !== 6) { + setLocalValue(escape(color)); } else { - onChange(escape(e.currentTarget.value)); + onChange(inputValue); } }; @@ -464,76 +432,92 @@ const ColorInput = ({ <div className="Input__baseline">.</div> <input className="Input__input" - value={format ? format(localValue) : localValue} + value={localValue} spellCheck={false} onInput={handleInput} onBlur={handleBlur} + {...rest} /> </Box> ); }; -const SaturationValue = ({ hsva, onChange }) => { - const handleMove = (interaction: { left: number; top: number }) => { - onChange({ - s: interaction.left * 100, - v: 100 - interaction.top * 100, - }); - }; - - const handleKey = (offset: { left: number; top: number }) => { - onChange({ - s: clamp(hsva.s + offset.left * 100, 0, 100), - v: clamp(hsva.v - offset.top * 100, 0, 100), - }); - }; - - const containerStyle = { - backgroundColor: hsvaToHslString({ - h: hsva.h, - s: 100, - v: 100, - a: 1, - }), - }; - - return ( - <div className="react-colorful__saturation_value" style={containerStyle}> - <Interactive - onMove={handleMove} - onKey={handleKey} - aria-label="Color" - aria-valuetext={`Saturation ${Math.round(hsva.s)}%, Brightness ${Math.round(hsva.v)}%`} - > - {[ - <Pointer - key="saturation-pointer" - className="react-colorful__saturation_value-pointer" - top={1 - hsva.v / 100} - left={hsva.s / 100} - color={hsvaToHslString(hsva)} - />, - ]} - </Interactive> - </div> - ); -}; - -const Hue = ({ - className, - hue, - onChange, -}: { +interface SaturationValueProps { + hsva: HsvaColor; + onChange: (newColor: Partial<HsvaColor>) => void; +} + +export const SaturationValue = React.memo( + ({ hsva, onChange }: SaturationValueProps) => { + const handleMove = (interaction: { left: number; top: number }) => { + requestAnimationFrame(() => { + onChange({ + s: interaction.left * 100, + v: 100 - interaction.top * 100, + }); + }); + }; + + const handleKey = (offset: { left: number; top: number }) => { + requestAnimationFrame(() => { + onChange({ + s: clamp(hsva.s + offset.left * 100, 0, 100), + v: clamp(hsva.v - offset.top * 100, 0, 100), + }); + }); + }; + + const containerStyle = { + backgroundColor: hsvaToHslString({ + h: hsva.h, + s: 100, + v: 100, + a: 1, + }), + }; + + return ( + <div className="react-colorful__saturation_value" style={containerStyle}> + <Interactive + onMove={handleMove} + onKey={handleKey} + aria-label="Color" + aria-valuetext={`Saturation ${Math.round( + hsva.s, + )}%, Brightness ${Math.round(hsva.v)}%`} + > + {[ + <Pointer + key="pointer" + className="react-colorful__saturation_value-pointer" + top={1 - hsva.v / 100} + left={hsva.s / 100} + color={hsvaToHslString(hsva)} + />, + ]} + </Interactive> + </div> + ); + }, +); + +interface HueProps { className?: string; hue: number; onChange: (newHue: { h: number }) => void; -}) => { +} + +const Hue = React.memo(({ className, hue, onChange }: HueProps) => { const handleMove = (interaction: { left: number }) => { - onChange({ h: 360 * interaction.left }); + requestAnimationFrame(() => { + onChange({ h: 360 * interaction.left }); + }); }; const handleKey = (offset: { left: number }) => { - onChange({ h: clamp(hue + offset.left * 360, 0, 360) }); + requestAnimationFrame(() => { + onChange({ h: clamp(hue + offset.left * 360, 0, 360) }); + }); }; const nodeClassName = classes(['react-colorful__hue', className]); @@ -559,79 +543,85 @@ const Hue = ({ </Interactive> </div> ); -}; +}); -const Saturation = ({ - className, - color, - onChange, -}: { +interface SaturationProps { className?: string; color: HsvaColor; onChange: (newSaturation: { s: number }) => void; -}) => { - const handleMove = (interaction: { left: number }) => { - onChange({ s: 100 * interaction.left }); - }; - - const handleKey = (offset: { left: number }) => { - onChange({ s: clamp(color.s + offset.left * 100, 0, 100) }); - }; - - const nodeClassName = classes(['react-colorful__saturation', className]); - - return ( - <div className={nodeClassName}> - <Interactive - style={{ - background: `linear-gradient(to right, ${hsvaToHslString({ - h: color.h, - s: 0, - v: color.v, - a: 1, - })}, ${hsvaToHslString({ h: color.h, s: 100, v: color.v, a: 1 })})`, - }} - onMove={handleMove} - onKey={handleKey} - aria-label="Saturation" - aria-valuenow={Math.round(color.s)} - aria-valuemax="100" - aria-valuemin="0" - > - {[ - <Pointer - key="corolful-saturation-pointer" - className="react-colorful__saturation-pointer" - left={color.s / 100} - color={hsvaToHslString({ +} + +const Saturation = React.memo( + ({ className, color, onChange }: SaturationProps) => { + const handleMove = (interaction: { left: number }) => { + requestAnimationFrame(() => { + onChange({ s: 100 * interaction.left }); + }); + }; + + const handleKey = (offset: { left: number }) => { + requestAnimationFrame(() => { + onChange({ s: clamp(color.s + offset.left * 100, 0, 100) }); + }); + }; + + const nodeClassName = classes(['react-colorful__saturation', className]); + + return ( + <div className={nodeClassName}> + <Interactive + style={{ + background: `linear-gradient(to right, ${hsvaToHslString({ h: color.h, - s: color.s, + s: 0, v: color.v, a: 1, - })} - />, - ]} - </Interactive> - </div> - ); -}; - -const Value = ({ - className, - color, - onChange, -}: { + })}, ${hsvaToHslString({ h: color.h, s: 100, v: color.v, a: 1 })})`, + }} + onMove={handleMove} + onKey={handleKey} + aria-label="Saturation" + aria-valuenow={Math.round(color.s)} + aria-valuemax="100" + aria-valuemin="0" + > + {[ + <Pointer + key="saturation-pointer" + className="react-colorful__saturation-pointer" + left={color.s / 100} + color={hsvaToHslString({ + h: color.h, + s: color.s, + v: color.v, + a: 1, + })} + />, + ]} + </Interactive> + </div> + ); + }, +); + +interface ValueProps { className?: string; color: HsvaColor; onChange: (newValue: { v: number }) => void; -}) => { +} + +const Value = React.memo(({ className, color, onChange }: ValueProps) => { const handleMove = (interaction: { left: number }) => { - onChange({ v: 100 * interaction.left }); + requestAnimationFrame(() => { + onChange({ v: 100 * interaction.left }); + }); }; const handleKey = (offset: { left: number }) => { - onChange({ - v: clamp(color.v + offset.left * 100, 0, 100), + requestAnimationFrame(() => { + onChange({ + v: clamp(color.v + offset.left * 100, 0, 100), + }); }); }; @@ -657,7 +647,7 @@ const Value = ({ > {[ <Pointer - key="colorful_value-pointer" + key="value-pointer" className="react-colorful__value-pointer" left={color.v / 100} color={hsvaToHslString({ @@ -671,61 +661,64 @@ const Value = ({ </Interactive> </div> ); -}; +}); -const RGBSlider = ({ - className, - color, - onChange, - target, -}: { +interface RGBSliderProps { className?: string; color: HsvaColor; onChange: (newValue: HsvaColor) => void; target: 'r' | 'g' | 'b'; -}) => { - const rgb = hsvaToRgba(color); - - const setNewTarget = (value: number) => { - rgb[target] = value; - onChange(rgbaToHsva(rgb)); - }; - - const handleMove = (interaction: { left: number }) => { - setNewTarget(255 * interaction.left); - }; - - const handleKey = (offset: { left: number }) => { - setNewTarget(clamp(rgb[target] + offset.left * 255, 0, 255)); - }; - - const nodeClassName = classes([`react-colorful__${target}`, className]); - - let selected = - target === 'r' - ? `rgb(${Math.round(rgb.r)},0,0)` - : target === 'g' - ? `rgb(0,${Math.round(rgb.g)},0)` - : `rgb(0,0,${Math.round(rgb.b)})`; - - return ( - <div className={nodeClassName}> - <Interactive - onMove={handleMove} - onKey={handleKey} - aria-valuenow={rgb[target]} - aria-valuemax="255" - aria-valuemin="0" - > - {[ - <Pointer - key="react-colorful-pointer" - className={`react-colorful__${target}-pointer`} - left={rgb[target] / 255} - color={selected} - />, - ]} - </Interactive> - </div> - ); -}; +} + +const RGBSlider = React.memo( + ({ className, color, onChange, target }: RGBSliderProps) => { + const rgb = hsvaToRgba(color); + + const setNewTarget = (value: number) => { + const newRgb = { ...rgb, [target]: value }; + onChange(rgbaToHsva(newRgb)); + }; + + const handleMove = (interaction: { left: number }) => { + requestAnimationFrame(() => { + setNewTarget(255 * interaction.left); + }); + }; + + const handleKey = (offset: { left: number }) => { + requestAnimationFrame(() => { + setNewTarget(clamp(rgb[target] + offset.left * 255, 0, 255)); + }); + }; + + const nodeClassName = classes([`react-colorful__${target}`, className]); + + const selected = + target === 'r' + ? `rgb(${Math.round(rgb.r)},0,0)` + : target === 'g' + ? `rgb(0,${Math.round(rgb.g)},0)` + : `rgb(0,0,${Math.round(rgb.b)})`; + + return ( + <div className={nodeClassName}> + <Interactive + onMove={handleMove} + onKey={handleKey} + aria-valuenow={rgb[target]} + aria-valuemax="255" + aria-valuemin="0" + > + {[ + <Pointer + key={`${target}-pointer`} + className={`react-colorful__${target}-pointer`} + left={rgb[target] / 255} + color={selected} + />, + ]} + </Interactive> + </div> + ); + }, +);