@electrovir/color
Version:
A wrapper for culori with an extremely simple API.
244 lines (243 loc) • 9.25 kB
JavaScript
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,
]);
});
}