diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx new file mode 100644 index 0000000..8e4cbef --- /dev/null +++ b/src/components/ColorPicker.tsx @@ -0,0 +1,129 @@ +import { memo, useCallback, useEffect, useMemo, useState } from 'react' +import { Input, Popover, Slider } from 'antd' +import { CopyOutlined } from '@ant-design/icons' + +import { copyToClipboard } from './factory' + +import '@styles/components/color_picker.less' + +export class Color { + public r: number + public g: number + public b: number + public a: number = 1 + + public constructor(color: Color | string) + public constructor(r: number, g: number, b: number, a?: number) + + constructor(...args: any[]) { + let out + if (args[0] instanceof Color) { + out = args[0] + } else if (typeof args[0] === 'string') { + out = Color.parseToObject(args[0]) + } else if (typeof args[0] === 'number') { + out = { r: args[0], g: args[1], b: args[2], a: args[3] ?? 1 } + } else throw new Error('Некорректные аргументы') + this.r = out.r + this.g = out.g + this.b = out.b + this.a = out.a + } + + public static parse(str: string): Color { + const out = Color.parseToObject(str) + return new Color(out.r, out.g, out.b, out.a) + } + + private static parseToObject(str: string) { + let rgb: number[] = [] + let a: number = 1 + if (str.startsWith('rgb')) { + const parts = str.replaceAll(/\s/g, '').match(/rgba?\((\d+),(\d+),(\d)+(?:,([\d.]+))?\)/) + if (parts) { + rgb = parts.slice(1, 4).map((v) => Math.min(0, Math.max(parseInt(v), 255))) + if (parts[4]) a = parseFloat(`0${parts[4]}`) + } + } else if (str.startsWith('#')) { + const parts = str.slice(1) + let rgba: string[] | null = parts.length > 5 ? parts.match(/.{1,2}/g) : [...parts] + if (rgba) { + rgb = rgba.slice(0, 3).map((v) => parseInt(v, 16)) + if (rgba[3]) a = parseInt(rgba[3], 16) / 255 + } + } + if (rgb.length < 3) + throw new Error('Некорректная строка') + return { r: rgb[0], g: rgb[1], b: rgb[2], a } + } + + public toString = () => this.toHexString() + public toCssString = () => `rgba(${this.r},${this.g},${this.b},${this.a})` + public toHexString() { + const a = Math.floor(this.a * 255) + let out = '#' + [this.r, this.g, this.b].map((v) => v.toString(16).padStart(2, '0')).join('') + if (a < 255) out += a.toString(16).padStart(2, '0') + return out + } +} + +export type ColorPickerProps = { + value?: string | Color + onChange?: (value: Color) => void + size?: number | string + id?: string +} + +const makeChangeColor = (set: React.Dispatch>, accessor: 'r' | 'g' | 'b' | 'a') => (value: number) => set((prev: Color) => { + const out = new Color(prev) + out[accessor] = value + return out +}) + +export const ColorPicker = memo(({ value = '#AA33BB', onChange, size, ...other }) => { + const [color, setColor] = useState(new Color(255, 255, 255)) + + useEffect(() => setColor(new Color(value)), [value]) + + const divStyle = useMemo(() => ({ + width: size, + height: size, + backgroundColor: color.toCssString(), + }), [size, color]) + + const changeR = useMemo(() => makeChangeColor(setColor, 'r'), []) + const changeG = useMemo(() => makeChangeColor(setColor, 'g'), []) + const changeB = useMemo(() => makeChangeColor(setColor, 'b'), []) + const changeA = useMemo(() => makeChangeColor(setColor, 'a'), []) + + const onClose = useCallback((visible: boolean) => { + if (!visible) + onChange?.(color) + }, [color, onChange]) + + const onCopyClick = useCallback(() => copyToClipboard(color.toHexString()), [color]) + + return ( + +
+ + + + +
+ + )} /> + + )} + > +
+ + ) +}) + +export default ColorPicker diff --git a/src/components/factory.ts b/src/components/factory.ts index 3d721da..9fe9c64 100755 --- a/src/components/factory.ts +++ b/src/components/factory.ts @@ -35,6 +35,15 @@ export const notify = (body?: ReactNode, notifyType: NotifyType = 'info', other? }) } +export const copyToClipboard = (value: string, successText?: string, errorText?: string) => { + try { + navigator.clipboard.writeText(value) + notify(successText ?? 'Текст успешно скопирован в буфер обмена', 'info') + } catch (ex) { + notify(errorText ?? 'Не удалось скопировать текст в буфер обмена', 'error') + } +} + type asyncFunction = (...args: any) => Promise const parseApiEror = (err: unknown, actionName?: string) => { diff --git a/src/styles/components/color_picker.less b/src/styles/components/color_picker.less new file mode 100644 index 0000000..aba100d --- /dev/null +++ b/src/styles/components/color_picker.less @@ -0,0 +1,21 @@ +.asb-color-picker-content { + display: flex; + flex-direction: column; + align-items: stretch; + width: 150px; + height: 150px; + + & > .asb-color-picker-sliders { + flex-grow: 1; + display: flex; + align-items: stretch; + justify-content: space-between; + padding-bottom: 20px; + } +} + +.asb-color-picker-preview { + border: 1px solid black; + width: 20px; + height: 20px; +} \ No newline at end of file