Files
2026-10-14 04:20:24 +02:00

89 lines
3.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2026 half144 and PS5 React contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution term: see LICENSE-ATTRIBUTION.
// Value parsing shared by the utilities: lengths, fractions, arbitrary values, and colors.
const NUMBER = String.raw`-?(?:\d+\.?\d*|\.\d+)`;
const LENGTH = new RegExp(`^(${NUMBER})(px|rem|%)?$`);
/** `[12px]` → `12px`; underscores stand for spaces, as in Tailwind. */
export function arbitrary(value) {
return /^\[.+\]$/.test(value) ? value.slice(1, -1).replaceAll('_', ' ') : null;
}
export function parseNumber(value) {
return new RegExp(`^${NUMBER}$`).test(String(value)) ? Number(value) : null;
}
/**
* A theme or arbitrary length: a number (px), `Npx`, `Nrem`, or `N%`. Returns `{px}` in logical
* pixels, `{pct}` as the engine's percentage string, or null.
*/
export function parseLength(value) {
if (typeof value === 'number') return {px: value};
const match = LENGTH.exec(String(value).trim());
if (!match) return null;
const [, number, unit] = match;
if (unit === '%') return {pct: `${formatNumber(Number(number))}%`};
if (unit === 'rem') return {px: Number(number) * 16};
if (unit === 'px' || Number(number) === 0) return {px: Number(number)};
return null;
}
/** `1/2` → `50%`, `full` → `100%`. */
export function fraction(value) {
if (value === 'full') return '100%';
const match = /^(\d+)\/(\d+)$/.exec(value);
if (!match || Number(match[2]) === 0) return null;
return `${formatNumber(Number(match[1]) / Number(match[2]) * 100)}%`;
}
export function formatNumber(value) {
return String(Number(value.toFixed(6)));
}
const HEX = /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
const RGB = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:[\s,/]+([\d.]+%?))?\s*\)$/i;
// The engine's named colors (parse_css_color in native_ui_bridge.c).
const NAMED = new Set(['transparent', 'black', 'white', 'red', 'green', 'blue', 'gray', 'grey',
'yellow', 'cyan', 'magenta', 'orange']);
export function isColor(value) {
return HEX.test(value) || RGB.test(value) || NAMED.has(value);
}
/** Looks a color up in a nested palette: `sky-500`, `white`, or a `DEFAULT` entry. */
export function paletteColor(palette, name) {
if (typeof palette[name] === 'string') return palette[name];
if (palette[name]?.DEFAULT) return palette[name].DEFAULT;
const dash = name.lastIndexOf('-');
if (dash < 0) return null;
const group = palette[name.slice(0, dash)];
return typeof group === 'object' ? paletteColor(group, name.slice(dash + 1)) : null;
}
/** Applies an opacity (0–1) by emitting `#rrggbbaa`, which the engine parses natively. */
export function withAlpha(color, alpha) {
if (alpha === null || alpha === undefined) return color;
if (color === 'transparent') return color;
const [r, g, b, a] = rgba(color);
const hex = n => Math.round(n).toString(16).padStart(2, '0');
return `#${hex(r)}${hex(g)}${hex(b)}${hex(a * alpha * 255)}`;
}
function rgba(color) {
const hex = HEX.exec(color)?.[1];
if (hex) {
const full = hex.length <= 4 ? [...hex].map(c => c + c).join('') : hex;
const channels = full.match(/../g).map(h => parseInt(h, 16));
return [...channels.slice(0, 3), channels.length === 4 ? channels[3] / 255 : 1];
}
const match = RGB.exec(color);
if (match) {
const alpha = match[4] === undefined ? 1
: match[4].endsWith('%') ? parseFloat(match[4]) / 100 : Number(match[4]);
return [Number(match[1]), Number(match[2]), Number(match[3]), alpha];
}
throw new Error(`cannot apply an opacity modifier to "${color}"`);
}