You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
116 lines
3.0 KiB
116 lines
3.0 KiB
import type { ColorValue, HSVColor } from './types';
|
|
import { hexToRgb, rgbToHsv, hslToHsv } from './convert';
|
|
|
|
const HEX_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null;
|
|
}
|
|
|
|
function isFiniteNumberInRange(value: unknown, min: number, max: number): value is number {
|
|
return typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max;
|
|
}
|
|
|
|
function isRgbColor(value: unknown): boolean {
|
|
if (!isRecord(value)) return false;
|
|
return (
|
|
isFiniteNumberInRange(value.r, 0, 255) &&
|
|
isFiniteNumberInRange(value.g, 0, 255) &&
|
|
isFiniteNumberInRange(value.b, 0, 255) &&
|
|
isFiniteNumberInRange(value.a, 0, 1)
|
|
);
|
|
}
|
|
|
|
function isHslColor(value: unknown): boolean {
|
|
if (!isRecord(value)) return false;
|
|
return (
|
|
isFiniteNumberInRange(value.h, 0, 360) &&
|
|
isFiniteNumberInRange(value.s, 0, 100) &&
|
|
isFiniteNumberInRange(value.l, 0, 100) &&
|
|
isFiniteNumberInRange(value.a, 0, 1)
|
|
);
|
|
}
|
|
|
|
function isHsvColor(value: unknown): boolean {
|
|
if (!isRecord(value)) return false;
|
|
return (
|
|
isFiniteNumberInRange(value.h, 0, 360) &&
|
|
isFiniteNumberInRange(value.s, 0, 100) &&
|
|
isFiniteNumberInRange(value.v, 0, 100) &&
|
|
isFiniteNumberInRange(value.a, 0, 1)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Parses any supported CSS color string to internal HSV.
|
|
* Supports: #hex, rgb(), rgba(), hsl(), hsla()
|
|
* Returns null if the string cannot be parsed.
|
|
*/
|
|
export function parseColor(value: string): HSVColor | null {
|
|
const str = value.trim().toLowerCase();
|
|
const numeric = String.raw`(?:\d+(?:\.\d+)?|\.\d+)`;
|
|
|
|
// HEX
|
|
if (str.startsWith('#')) {
|
|
const rgb = hexToRgb(str);
|
|
return rgb ? rgbToHsv(rgb) : null;
|
|
}
|
|
|
|
// rgb() / rgba()
|
|
const rgbMatch = str.match(
|
|
new RegExp(
|
|
String.raw`^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*(${numeric}))?\s*\)$`
|
|
)
|
|
);
|
|
if (rgbMatch) {
|
|
const [, r, g, b, a] = rgbMatch;
|
|
const rgb = {
|
|
r: parseInt(r!, 10),
|
|
g: parseInt(g!, 10),
|
|
b: parseInt(b!, 10),
|
|
a: a !== undefined ? parseFloat(a) : 1
|
|
};
|
|
return isRgbColor(rgb) ? rgbToHsv(rgb) : null;
|
|
}
|
|
|
|
// hsl() / hsla()
|
|
const hslMatch = str.match(
|
|
new RegExp(
|
|
String.raw`^hsla?\(\s*(${numeric})\s*,\s*(${numeric})%\s*,\s*(${numeric})%(?:\s*,\s*(${numeric}))?\s*\)$`
|
|
)
|
|
);
|
|
if (hslMatch) {
|
|
const [, h, s, l, a] = hslMatch;
|
|
const hsl = {
|
|
h: parseFloat(h!),
|
|
s: parseFloat(s!),
|
|
l: parseFloat(l!),
|
|
a: a !== undefined ? parseFloat(a) : 1
|
|
};
|
|
return isHslColor(hsl) ? hslToHsv(hsl) : null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Returns true if the string is a parseable CSS color.
|
|
*/
|
|
export function isValidColor(value: string): boolean {
|
|
return parseColor(value) !== null;
|
|
}
|
|
|
|
/**
|
|
* Type guard for the normalized `ColorValue` shape used across Active UI layers.
|
|
*/
|
|
export function isColorValue(value: unknown): value is ColorValue {
|
|
if (!isRecord(value)) return false;
|
|
return (
|
|
typeof value.hex === 'string' &&
|
|
HEX_RE.test(value.hex) &&
|
|
isRgbColor(value.rgb) &&
|
|
isHslColor(value.hsl) &&
|
|
isHsvColor(value.hsv)
|
|
);
|
|
}
|