Files
dpage-editor/src/components/ColorPicker.tsx
T
hamid zarghami 33cce93064
deploy to danak / build_and_deploy (push) Has been cancelled
bg opcity
2026-07-08 15:50:21 +03:30

207 lines
7.2 KiB
TypeScript

import { type FC, type InputHTMLAttributes, useEffect, useRef, useState } from "react";
import { HexAlphaColorPicker, HexColorInput, HexColorPicker } from "react-colorful";
import { clx } from "../helpers/utils";
import Error from "./Error";
type Props = {
label?: string;
className?: string;
error_text?: string;
isNotRequired?: boolean;
value?: string;
onChange?: (value: string) => void;
opacity?: number;
onOpacityChange?: (value: number) => void;
opacityLabel?: string;
} & Omit<InputHTMLAttributes<HTMLInputElement>, "onChange" | "value">;
const ColorPicker: FC<Props> = (props: Props) => {
const [color, setColor] = useState<string>(props.value || "#000000");
const [opacityDisplay, setOpacityDisplay] = useState<string>(`${props.opacity ?? 100}`);
const [isOpen, setIsOpen] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const showOpacity = props.onOpacityChange !== undefined;
const clampOpacity = (rawValue: number): number => Math.min(100, Math.max(0, rawValue));
const normalizeHex = (value: string): string => {
if (!value) return "#000000";
const withHash = value.startsWith("#") ? value : `#${value}`;
const shortMatch = /^#([0-9A-Fa-f]{3})$/.exec(withHash);
if (shortMatch) {
const [r, g, b] = shortMatch[1]!.split("");
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
}
const longMatch = /^#([0-9A-Fa-f]{6})$/.exec(withHash);
if (longMatch) {
return `#${longMatch[1]}`.toLowerCase();
}
return "#000000";
};
const toHexAlpha = (hex: string, opacity: number): string => {
const normalizedHex = normalizeHex(hex);
const alpha = Math.round((clampOpacity(opacity) / 100) * 255)
.toString(16)
.padStart(2, "0");
return `${normalizedHex}${alpha}`;
};
const fromHexAlpha = (hexAlpha: string): { hex: string; opacity: number } => {
const match = /^#([0-9A-Fa-f]{8})$/.exec(hexAlpha);
if (!match) {
return {
hex: normalizeHex(hexAlpha),
opacity: 100,
};
}
const rgbaHex = match[1]!.toLowerCase();
const rgb = `#${rgbaHex.slice(0, 6)}`;
const alphaHex = rgbaHex.slice(6, 8);
const opacity = Math.round((parseInt(alphaHex, 16) / 255) * 100);
return { hex: rgb, opacity: clampOpacity(opacity) };
};
const inputClass = clx("w-full bg-white h-[34px] text-black block pl-10 pr-10 text-xs rounded-xl border border-border", props.readOnly && "bg-gray-100 border-0 text-description");
const handleColorChange = (newColor: string) => {
const normalized = normalizeHex(newColor);
setColor(normalized);
props.onChange?.(normalized);
};
const togglePicker = () => {
if (props.readOnly) return;
setIsOpen((prev) => !prev);
};
useEffect(() => {
if (props.value !== undefined) {
setColor(normalizeHex(props.value));
}
}, [props.value]);
useEffect(() => {
if (props.opacity !== undefined) {
setOpacityDisplay(`${props.opacity}`);
}
}, [props.opacity]);
useEffect(() => {
if (!isOpen) return;
const handlePointerDownOutside = (event: MouseEvent) => {
if (!wrapperRef.current?.contains(event.target as Node)) {
setIsOpen(false);
}
};
window.addEventListener("mousedown", handlePointerDownOutside);
return () => window.removeEventListener("mousedown", handlePointerDownOutside);
}, [isOpen]);
const handleOpacityChange = (rawValue: string) => {
setOpacityDisplay(rawValue);
const numValue = parseInt(rawValue, 10);
if (!Number.isNaN(numValue)) {
props.onOpacityChange?.(clampOpacity(numValue));
}
};
const handleOpacityBlur = (rawValue: string) => {
const numValue = parseInt(rawValue, 10) || 0;
const clampedValue = clampOpacity(numValue);
setOpacityDisplay(`${clampedValue}`);
props.onOpacityChange?.(clampedValue);
};
const handleAlphaPickerChange = (hexAlpha: string) => {
const { hex, opacity } = fromHexAlpha(hexAlpha);
setColor(hex);
setOpacityDisplay(`${opacity}`);
props.onChange?.(hex);
props.onOpacityChange?.(opacity);
};
const colorField = (
<div className={clx("w-full", showOpacity && "flex-1")}>
<label className="text-xs">{props.label}</label>
<div className={clx("w-full relative mt-1 rounded-xl", !props.readOnly && "cursor-pointer")}>
<input
type="text"
value={color}
onChange={(event) => handleColorChange(event.target.value)}
placeholder="#000000"
readOnly={props.readOnly}
title={!props.readOnly ? "کلیک برای باز کردن انتخابگر رنگ" : undefined}
onClick={togglePicker}
className={`${inputClass} ${props.readOnly ? "" : "cursor-pointer"}`}
/>
<span
aria-hidden
className={clx("absolute top-0 bottom-0 my-auto left-1 flex items-center justify-center min-h-[34px] min-w-[34px] rounded-lg pointer-events-none", props.readOnly && "opacity-50")}
>
{/* <img
src={ColorfilterIcon}
alt=''
className='size-7 select-none'
/> */}
</span>
<span
aria-hidden
className={clx("absolute top-0 bottom-0 my-auto right-1 flex items-center justify-center min-h-[34px] min-w-[34px] rounded-lg pointer-events-none", props.readOnly && "opacity-50")}
>
<span className="block w-5 h-5 rounded-full border border-border shadow-sm" style={{ backgroundColor: color }} />
</span>
{isOpen && !props.readOnly && (
<div className="absolute right-0 z-50 mt-2 w-full rounded-xl border border-border bg-white p-3 shadow-lg space-y-3">
<div className="space-y-1">
{showOpacity ? (
<HexAlphaColorPicker color={toHexAlpha(color, Number(opacityDisplay) || 100)} onChange={handleAlphaPickerChange} />
) : (
<HexColorPicker color={color} onChange={handleColorChange} />
)}
</div>
<div className="flex items-center gap-2">
<HexColorInput color={color} onChange={handleColorChange} prefixed className="h-9 max-w-[115px] flex-1 rounded-lg border border-border px-2 text-xs" />
{showOpacity && (
<div className="flex-1">
<input
type="number"
min={0}
max={100}
value={opacityDisplay}
onChange={(e) => handleOpacityChange(e.target.value)}
onBlur={(e) => handleOpacityBlur(e.target.value)}
className="h-9 w-full rounded-lg border border-border px-2 text-xs"
title={props.opacityLabel ?? "شفافیت"}
/>
</div>
)}
</div>
{showOpacity && (
<div className="text-xs text-gray-500">
{props.opacityLabel ?? "شفافیت"}: {opacityDisplay}%
</div>
)}
</div>
)}
</div>
</div>
);
return (
<div ref={wrapperRef} className={clx("w-full", props.className)}>
{colorField}
{props.error_text && <Error errorText={props.error_text} />}
</div>
);
};
export default ColorPicker;