mirror of
https://github.com/MrLowPr/overdrive.git
synced 2026-10-18 19:01:08 +02:00
89 lines
3.5 KiB
JavaScript
89 lines
3.5 KiB
JavaScript
// 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}"`);
|
||
}
|