UNPKG

@electrovir/color

Version:

A wrapper for culori with an extremely simple API.

324 lines (323 loc) 12.8 kB
import { assert, assertWrap, check } from '@augment-vir/assert'; import { copyThroughJson, filterMap, getEnumValues, getObjectTypedEntries, getObjectTypedKeys, joinWithFinalConjunction, mapObjectValues, round, } from '@augment-vir/common'; import colorNames from 'color-name'; import { clampGamut, converter, differenceEuclidean, formatHex, parse, } from 'culori'; import { ColorFormatName, colorFormats, ColorSyntaxName, getColorSyntaxFromCssString, } from './color-formats.js'; import { maxColorNameLength } from './color-name-length.js'; const distanceRgb = differenceEuclidean('rgb'); /** * A `Color` class with state and the following features: * * - Color coordinates do not change when they become `'none'`, they stay at their previous value. * This makes for a much smoother UI experience. * - All relevant color spaces and formats are always set (no extra conversions necessary). * - Matching CSS color names are tracked. * - This class is exported correctly and the types aren't a mess. * * @category Color */ export class Color { constructor( /** Any valid CSS color string or an object of color coordinate values. */ initValue) { this.set(initValue); } /** Checks if the input string can be converted into a color. */ static isValidColorString(value) { try { new Color(value); return true; } catch { return false; } } /** Checks, with a type guard, that a given input is a Color class instance. */ static isColor(value) { return value instanceof Color; } /** * Create a new {@link Color} instance by parsing the output of another instance's * {@link Color.serialize} method. */ static deserialize(input) { const parsed = JSON.parse(input); const newColor = new Color('black'); getObjectTypedEntries(parsed).forEach(([key, value,]) => { if (key === 'originalColorSyntax') { newColor.originalColorSyntax = assertWrap.isEnumValue(value, ColorSyntaxName, 'Cannot deserialize: invalid color syntax.'); } else { newColor._allColors[key] = value; } }); return newColor; } /** Get the distance from this Color class instance to the given CSS color string. */ getRgbDistance(distanceFrom) { return distanceRgb(this.#internalColor, distanceFrom); } /** Get the closest named CSS color to this Color class instance's current color. */ getClosestNamedColor() { return getObjectTypedKeys(colorNames).reduce((best, currentName) => { const colorDistance = this.getRgbDistance(currentName); if (colorDistance < best.distance) { return { distance: colorDistance, name: currentName, }; } else { return best; } }, { name: '', distance: Infinity, }).name; } /** * Converts the color class to a CSS string format in the color space and format that it was * originally set with. */ toString() { return this.toCss()[this.originalColorSyntax]; } /** The color syntax the this color was set with. */ originalColorSyntax = ColorSyntaxName.hex; #internalColor = assertWrap.isDefined(parse('black')); /** All current color values. These are updated whenever {@link Color.set} is called. */ _allColors = { names: ['black'], [ColorSyntaxName.name]: 'black', hexString: '#000000', [ColorSyntaxName.hex]: { r: 0, g: 0, b: 0, }, [ColorSyntaxName.rgb]: { r: 0, g: 0, b: 0, }, [ColorSyntaxName.hsl]: { h: 0, s: 0, l: 0, }, [ColorSyntaxName.hwb]: { h: 0, w: 0, b: 0, }, [ColorSyntaxName.lab]: { l: 0, a: 0, b: 0, }, [ColorSyntaxName.lch]: { l: 0, c: 0, h: 0, }, [ColorSyntaxName.oklab]: { l: 0, a: 0, b: 0, }, [ColorSyntaxName.oklch]: { l: 0, c: 0, h: 0, }, }; /** Create a new {@link Color} instance that matches this one exactly. */ clone() { return Color.deserialize(this.serialize()); } /** * Update the color to match the given string. * * @throws Error if `cssColorString` is not able to be parsed. */ setByString(cssColorString) { const newColor = parse(cssColorString); if (!newColor) { throw new Error(`Unable to parse invalid color string: '${cssColorString}'`); } this.originalColorSyntax = getColorSyntaxFromCssString(cssColorString); this.#internalColor = newColor; this.pullFromInternalColor(); } /** * Update the current color by setting a whole new color, a single coordinate in a single color * format, or multiple coordinates in a single color format. This mutates the current * {@link Color} instance. */ set(newValue) { if (check.isString(newValue)) { return this.setByString(newValue); } assert.isLengthExactly(Object.keys(newValue), 1, `Cannot set multiple color formats at once: got '${joinWithFinalConjunction(Object.keys(newValue))}'`); if (newValue.hexString || newValue.name) { this.setByString(newValue.hexString || newValue.name); } else { const [colorFormatName, colorValues,] = assertWrap.isDefined(getObjectTypedEntries(newValue)[0]); const colorFormatDefinition = colorFormats[colorFormatName]; const orderedColorCoords = Object.values(mapObjectValues(colorFormatDefinition.coords, (coordName) => { const coordValue = colorValues[coordName]; const coordDefinition = colorFormatDefinition.coords[assertWrap.isKeyOf(coordName, colorFormatDefinition.coords)]; const rawCoordValue = coordValue != undefined && coordValue >= coordDefinition.min && coordValue <= coordDefinition.max ? colorValues[coordName] : this[colorFormatName][coordName]; return assertWrap.isDefined(rawCoordValue); })); this.setByString(`${colorFormatDefinition.conversionFormat}(${orderedColorCoords.join(' ')})`); } } /** * Update all internally stored color format values ({@link Color.allColors}) from the updated * internal color object. */ pullFromInternalColor() { getEnumValues(ColorFormatName).forEach((colorFormatName) => { const colorFormatDefinition = colorFormats[colorFormatName]; const mappedColorFormatName = colorFormatDefinition.conversionFormat; const originalColorDefinition = check.isKeyOf(this.#internalColor.mode, colorFormats) ? colorFormats[this.#internalColor.mode] : undefined; const converted = clampGamut(colorFormatDefinition.colorSpace === originalColorDefinition?.colorSpace ? mappedColorFormatName : 'rgb')(converter(mappedColorFormatName)(this.#internalColor)); /* node:coverage ignore next 5: technically this shouldn't happen, idk how to manually trigger it. */ if (!converted) { assert.never(`Failed to convert color '${JSON.stringify(this.#internalColor)}' to '${colorFormatName}'.`); } getObjectTypedKeys(this[colorFormatName]).forEach((coordName) => { const coordValue = converted[coordName]; const coordinateDefinition = colorFormatDefinition.coords[assertWrap.isKeyOf(coordName, colorFormatDefinition.coords)]; if (coordValue != undefined) { this._allColors[colorFormatName][coordName] = round((coordValue || 0) * (coordinateDefinition.factor || 1), { digits: coordinateDefinition.digits || 0, }); } }); }); this._allColors.hexString = formatHex(this.#internalColor); this._allColors.names = findMatchingColorNames(this.rgb); this._allColors[ColorSyntaxName.name] = this._allColors.names[0] || ''; } /** * Create a string that can be serialized into a new {@link Color} instance which will exactly * match the current {@link Color} instance. */ serialize() { return JSON.stringify({ ...this.allColors, originalColorSyntax: this.originalColorSyntax, }); } /** This individual color expressed in all the supported color formats. */ get allColors() { return copyThroughJson(this._allColors); } /** * Converts the values for each supported color format into a padded string for easy display * purposes. * * @see `.toCss()` */ toFormattedStrings() { const colorFormatStrings = mapObjectValues(colorFormats, (colorFormatName) => { const coordValues = Object.values(this[colorFormatName]); return coordValues.map((coordValue) => String(coordValue).padStart(6, ' ')).join(' '); }); return { ...colorFormatStrings, names: this.names.join(', ').padEnd(maxColorNameLength, ' '), [ColorSyntaxName.name]: (this.names[0] || '').padEnd(maxColorNameLength, ' '), [ColorSyntaxName.hexString]: this[ColorSyntaxName.hexString], }; } /** * Converts the values for each supported color format in a CSS string that can be directly used * in any modern CSS code. * * @see `.toFormattedStrings()` */ toCss() { const colorFormatStrings = mapObjectValues(colorFormats, (colorFormatName) => { const coordValues = Object.values(this[colorFormatName]); return `${colorFormatName}(${coordValues.join(' ')})`; }); return { ...colorFormatStrings, [ColorSyntaxName.hexString]: this[ColorSyntaxName.hexString], [ColorSyntaxName.name]: this.names[0] || '', }; } /** * The current color expressed as hardcoded CSS color keywords. If no CSS color keywords match * the current color, this array will be empty. */ get names() { return copyThroughJson(this._allColors.names); } /** * The current color expressed as a single CSS color name string. If there is no color name that * matches the current color, this will be an empty string. */ get name() { return this._allColors.names[0] || ''; } /** The current color expressed as an RGB hex string. */ get hexString() { return this._allColors[ColorSyntaxName.hexString]; } /** The current color expressed as an RGB hex coordinates. */ get hex() { return copyThroughJson(this._allColors[ColorSyntaxName.hex]); } /** The current color expressed as its RGB coordinate values. */ get rgb() { return copyThroughJson(this._allColors[ColorSyntaxName.rgb]); } /** The current color expressed as its HSL coordinate values. */ get hsl() { return copyThroughJson(this._allColors[ColorSyntaxName.hsl]); } /** The current color expressed as its HWB coordinate values. */ get hwb() { return copyThroughJson(this._allColors[ColorSyntaxName.hwb]); } /** The current color expressed as its LAB coordinate values. */ get lab() { return copyThroughJson(this._allColors[ColorSyntaxName.lab]); } /** The current color expressed as its LCH coordinate values. */ get lch() { return copyThroughJson(this._allColors[ColorSyntaxName.lch]); } /** The current color expressed as its Oklab coordinate values. */ get oklab() { return copyThroughJson(this._allColors[ColorSyntaxName.oklab]); } /** The current color expressed as its Oklch coordinate values. */ get oklch() { return copyThroughJson(this._allColors[ColorSyntaxName.oklch]); } } function findMatchingColorNames(rgb) { return filterMap(getObjectTypedEntries(colorNames), ([colorName,]) => { return colorName; }, (colorName, [, colorValues,]) => { return check.deepEquals(colorValues, [ rgb.r, rgb.g, rgb.b, ]); }); }