UNPKG

@electrovir/color

Version:

A wrapper for culori with an extremely simple API.

244 lines (243 loc) 9.25 kB
import { assert, assertWrap, check } from '@augment-vir/assert'; import { copyThroughJson, filterMap, getObjectTypedEntries, joinWithFinalConjunction, mapObjectValues, round, } from '@augment-vir/common'; import colorNames from 'color-name'; import { clampGamut, converter, formatHex, parse } from 'culori'; import { colorFormatNames, colorFormats, } from './color-formats.js'; import { maxColorNameLength } from './color-name-length.js'; /** * 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 { /** * 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,]) => { newColor._allColors[key] = value; }); return newColor; } #internalColor = assertWrap.isDefined(parse('black')); /** All current color values. These are updated whenever {@link Color.set} is called. */ _allColors = { names: ['black'], hex: '#000000', rgb: { r: 0, g: 0, b: 0, }, hsl: { h: 0, s: 0, l: 0, }, hwb: { h: 0, w: 0, b: 0, }, lab: { l: 0, a: 0, b: 0, }, lch: { l: 0, c: 0, h: 0, }, oklab: { l: 0, a: 0, b: 0, }, oklch: { l: 0, c: 0, h: 0, }, }; constructor( /** Any valid CSS color string or an object of color coordinate values. */ initValue) { this.set(initValue); } /** 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.#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.hex || newValue.name) { this.setByString(newValue.hex || 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 = assertWrap.isDefined(colorFormatDefinition.coords[coordName]); const rawCoordValue = coordValue != undefined && coordValue >= coordDefinition.min && coordValue <= coordDefinition.max ? colorValues[coordName] : this[colorFormatName][coordName]; return assertWrap.isDefined(rawCoordValue); })); this.setByString(`${colorFormatName}(${orderedColorCoords.join(' ')})`); } } /** * Update all internally stored color format values ({@link Color.allColors}) from the updated * internal color object. */ pullFromInternalColor() { colorFormatNames.forEach((colorFormatName) => { const colorFormatDefinition = colorFormats[colorFormatName]; const originalColorDefinition = check.isKeyOf(this.#internalColor.mode, colorFormats) ? colorFormats[this.#internalColor.mode] : undefined; const converted = clampGamut(colorFormatDefinition.colorSpace === originalColorDefinition?.colorSpace ? colorFormatName : 'rgb')(converter(colorFormatName)(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}'.`); } Object.keys(this[colorFormatName]).forEach((coordName) => { const coordValue = converted[coordName]; if (coordValue != undefined) { this._allColors[colorFormatName][coordName] = round((coordValue || 0) * (colorFormatDefinition.coords[coordName]?.factor || 1), { digits: colorFormatDefinition.coords[coordName]?.digits || 0, }); } }); }); this._allColors.hex = formatHex(this.#internalColor); this._allColors.names = findMatchingColorNames(this.rgb); } /** * 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); } /** 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. */ toFormattedStrings() { const colorFormatStrings = mapObjectValues(colorFormats, (colorFormatName) => { const coordValues = Object.values(this[colorFormatName]); return coordValues.map((coordValue) => String(coordValue).padStart(6, ' ')).join(' '); }); return { hex: this.hex, ...colorFormatStrings, names: this.names.join(', ').padEnd(maxColorNameLength, ' '), }; } /** * Converts the values for each supported color format in a CSS string that can be directly used * in any modern CSS code. */ toCss() { const colorFormatStrings = mapObjectValues(colorFormats, (colorFormatName) => { const coordValues = Object.values(this[colorFormatName]); return `${colorFormatName}(${coordValues.join(' ')})`; }); return { hex: this.hex, ...colorFormatStrings, 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 an RGB hex string. */ get hex() { return copyThroughJson(this._allColors.hex); } /** The current color expressed as its RGB coordinate values. */ get rgb() { return copyThroughJson(this._allColors.rgb); } /** The current color expressed as its HSL coordinate values. */ get hsl() { return copyThroughJson(this._allColors.hsl); } /** The current color expressed as its HWB coordinate values. */ get hwb() { return copyThroughJson(this._allColors.hwb); } /** The current color expressed as its LAB coordinate values. */ get lab() { return copyThroughJson(this._allColors.lab); } /** The current color expressed as its LCH coordinate values. */ get lch() { return copyThroughJson(this._allColors.lch); } /** The current color expressed as its Oklab coordinate values. */ get oklab() { return copyThroughJson(this._allColors.oklab); } /** The current color expressed as its Oklch coordinate values. */ get oklch() { return copyThroughJson(this._allColors.oklch); } } function findMatchingColorNames(rgb) { return filterMap(getObjectTypedEntries(colorNames), ([colorName,]) => { return colorName; }, (colorName, [, colorValues,]) => { return check.deepEquals(colorValues, [ rgb.r, rgb.g, rgb.b, ]); }); }