Skip to content

01 color

color2rgba

ts
type RGBA = { r: number; g: number; b: number; a?: number }

const FALLBACK_RGB: RGBA = { r: 0, g: 212, b: 255 }

let parserEl: HTMLElement | null = null

function getParserEl(): HTMLElement | null {
    if (typeof document === "undefined") return null

    if (!parserEl) {
        parserEl = document.createElement("span")
        parserEl.style.position = "absolute"
        parserEl.style.visibility = "hidden"
        parserEl.style.pointerEvents = "none"
        document.body.appendChild(parserEl)
    }
    return parserEl
}

function clampAlpha(a: number): number {
    if (Number.isNaN(a)) return 1
    return Math.min(1, Math.max(0, a))
}

function toRgba({ r, g, b }: RGBA, alpha: number): string {
    return `rgba(${r}, ${g}, ${b}, ${alpha})`
}

// -------- hex --------
export function parseHex(hex: string): RGBA | null {
    const clean = hex.replace(/[^0-9a-fA-F]/g, "")

    if (clean.length === 3) {
        return {
            r: parseInt((clean[0] ?? "0") + (clean[0] ?? "0"), 16),
            g: parseInt((clean[1] ?? "0") + (clean[1] ?? "0"), 16),
            b: parseInt((clean[2] ?? "0") + (clean[2] ?? "0"), 16),
        }
    }

    if (clean.length === 6) {
        return {
            r: parseInt(clean.slice(0, 2), 16),
            g: parseInt(clean.slice(2, 4), 16),
            b: parseInt(clean.slice(4, 6), 16),
        }
    }

    if (clean.length === 8) {
        return {
            r: parseInt(clean.slice(0, 2), 16),
            g: parseInt(clean.slice(2, 4), 16),
            b: parseInt(clean.slice(4, 6), 16),
            a: parseInt(clean.slice(6, 8), 16) / 255,
        }
    }

    return null
}

// -------- rgb --------
export function parseRgb(input: string): RGBA | null {
    const match = input.match(
        /rgba?\s*\(\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)(?:\s*[,/]\s*([\d.]+%?))?\s*\)/
    )
    if (!match) return null

    const parseChannel = (v: string) =>
        v.endsWith("%")
            ? Math.round((parseFloat(v) / 100) * 255)
            : parseInt(v, 10)

    const r = Math.min(255, Math.max(0, parseChannel(match[1] ?? "0")))
    const g = Math.min(255, Math.max(0, parseChannel(match[2] ?? "0")))
    const b = Math.min(255, Math.max(0, parseChannel(match[3] ?? "0")))

    const result: RGBA = { r, g, b }
    const alphaStr = match[4]
    if (alphaStr) {
        result.a = alphaStr.endsWith("%")
            ? parseFloat(alphaStr) / 100
            : parseFloat(alphaStr)
        result.a = clampAlpha(result.a)
    }
    return result
}

// -------- browser fallback --------
function parseByBrowser(input: string): RGBA | null {
    const el = getParserEl()
    if (!el) return null

    el.style.color = input
    const resolved = getComputedStyle(el).color

    const m = resolved.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/)
    if (!m || !m[1] || !m[2] || !m[3]) return null

    const result: RGBA = {
        r: parseInt(m[1], 10),
        g: parseInt(m[2], 10),
        b: parseInt(m[3], 10),
    }
    if (m[4]) result.a = clampAlpha(parseFloat(m[4]))
    return result
}

// -------- main --------
export function color2rgba(color: string, opacity?: number): string {
    if (!color || typeof color !== "string") {
        return toRgba(FALLBACK_RGB, opacity === undefined ? 1 : clampAlpha(opacity))
    }

    const input = color.trim()
    if (!input) {
        return toRgba(FALLBACK_RGB, opacity === undefined ? 1 : clampAlpha(opacity))
    }

    let rgb: RGBA | null = null

    if (input.startsWith("#")) {
        rgb = parseHex(input)
    }

    if (!rgb && input.startsWith("rgb")) {
        rgb = parseRgb(input)
    }

    if (!rgb) {
        rgb = parseByBrowser(input)
    }

    const resolved = rgb ?? FALLBACK_RGB
    const alpha =
        opacity !== undefined ? clampAlpha(opacity) : clampAlpha(resolved.a ?? 1)
    return toRgba(resolved, alpha)
}