210 lines
6.5 KiB
TypeScript
210 lines
6.5 KiB
TypeScript
import type { Color, FillChoice, GradientStops } from "/wrapper/pkg/graphite_wasm_wrapper";
|
|
|
|
// Channels can have any range (0-1, 0-255, 0-100, 0-360) in the context they are being used in, these are just containers for the numbers
|
|
export type HSV = { h: number; s: number; v: number };
|
|
export type RGB = { r: number; g: number; b: number };
|
|
|
|
// COLOR FACTORY FUNCTIONS
|
|
|
|
export function createColor(red: number, green: number, blue: number, alpha: number): Color {
|
|
return { red, green, blue, alpha };
|
|
}
|
|
|
|
export function createColorFromHSVA(h: number, s: number, v: number, a: number): Color {
|
|
const convert = (n: number): number => {
|
|
const k = (n + h * 6) % 6;
|
|
return v - v * s * Math.max(Math.min(...[k, 4 - k, 1]), 0);
|
|
};
|
|
|
|
return { red: convert(5), green: convert(3), blue: convert(1), alpha: a };
|
|
}
|
|
|
|
// COLOR UTILITY FUNCTIONS
|
|
|
|
export function isColor(value: unknown): value is Color {
|
|
return typeof value === "object" && value !== null && "red" in value;
|
|
}
|
|
|
|
export function colorFromCSS(colorCode: string): Color | undefined {
|
|
// Allow single-digit hex value inputs
|
|
let colorValue = colorCode.trim();
|
|
if (colorValue.length === 2 && colorValue.charAt(0) === "#" && /[0-9a-f]/i.test(colorValue.charAt(1))) {
|
|
const digit = colorValue.charAt(1);
|
|
colorValue = `#${digit}${digit}${digit}`;
|
|
}
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = 1;
|
|
canvas.height = 1;
|
|
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
if (!context) return undefined;
|
|
|
|
context.clearRect(0, 0, 1, 1);
|
|
|
|
context.fillStyle = "black";
|
|
context.fillStyle = colorValue;
|
|
const comparisonA = context.fillStyle;
|
|
|
|
context.fillStyle = "white";
|
|
context.fillStyle = colorValue;
|
|
const comparisonB = context.fillStyle;
|
|
|
|
// Invalid color
|
|
if (comparisonA !== comparisonB) {
|
|
// If this color code didn't start with a #, add it and try again
|
|
if (colorValue.trim().charAt(0) !== "#") return colorFromCSS(`#${colorValue.trim()}`);
|
|
return undefined;
|
|
}
|
|
|
|
context.fillRect(0, 0, 1, 1);
|
|
|
|
const [r, g, b, a] = [...context.getImageData(0, 0, 1, 1).data];
|
|
return createColor(r / 255, g / 255, b / 255, a / 255);
|
|
}
|
|
|
|
export function colorToHexNoAlpha(color: Color): string {
|
|
const r = Math.round(color.red * 255)
|
|
.toString(16)
|
|
.padStart(2, "0");
|
|
const g = Math.round(color.green * 255)
|
|
.toString(16)
|
|
.padStart(2, "0");
|
|
const b = Math.round(color.blue * 255)
|
|
.toString(16)
|
|
.padStart(2, "0");
|
|
|
|
return `#${r}${g}${b}`;
|
|
}
|
|
|
|
export function colorToRgb255(color: Color): RGB {
|
|
return {
|
|
r: Math.round(color.red * 255),
|
|
g: Math.round(color.green * 255),
|
|
b: Math.round(color.blue * 255),
|
|
};
|
|
}
|
|
|
|
export function colorToRgbCSS(color: Color): string {
|
|
const rgb = colorToRgb255(color);
|
|
|
|
return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
|
|
}
|
|
|
|
export function colorToRgbaCSS(color: Color): string {
|
|
const rgb = colorToRgb255(color);
|
|
|
|
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${color.alpha})`;
|
|
}
|
|
|
|
export function colorToHSV(color: Color): HSV {
|
|
const { red: r, green: g, blue: b } = color;
|
|
|
|
const max = Math.max(r, g, b);
|
|
const min = Math.min(r, g, b);
|
|
|
|
const d = max - min;
|
|
const s = max === 0 ? 0 : d / max;
|
|
const v = max;
|
|
|
|
let h = 0;
|
|
if (max !== min) {
|
|
switch (max) {
|
|
case r:
|
|
h = (g - b) / d + (g < b ? 6 : 0);
|
|
break;
|
|
case g:
|
|
h = (b - r) / d + 2;
|
|
break;
|
|
case b:
|
|
h = (r - g) / d + 4;
|
|
break;
|
|
default:
|
|
}
|
|
h /= 6;
|
|
}
|
|
|
|
return { h, s, v };
|
|
}
|
|
|
|
export function colorOpaque(color: Color): Color {
|
|
return createColor(color.red, color.green, color.blue, 1);
|
|
}
|
|
|
|
export function colorLuminance(color: Color): number {
|
|
// Convert alpha into white
|
|
const r = color.red * color.alpha + (1 - color.alpha);
|
|
const g = color.green * color.alpha + (1 - color.alpha);
|
|
const b = color.blue * color.alpha + (1 - color.alpha);
|
|
|
|
// https://stackoverflow.com/a/3943023/775283
|
|
|
|
const linearR = r <= 0.04045 ? r / 12.92 : ((r + 0.055) / 1.055) ** 2.4;
|
|
const linearG = g <= 0.04045 ? g / 12.92 : ((g + 0.055) / 1.055) ** 2.4;
|
|
const linearB = b <= 0.04045 ? b / 12.92 : ((b + 0.055) / 1.055) ** 2.4;
|
|
|
|
return linearR * 0.2126 + linearG * 0.7152 + linearB * 0.0722;
|
|
}
|
|
|
|
export function colorContrastingColor(color: Color | undefined): "black" | "white" {
|
|
if (!color) return "black";
|
|
|
|
const luminance = colorLuminance(color);
|
|
|
|
return luminance > Math.sqrt(1.05 * 0.05) - 0.05 ? "black" : "white";
|
|
}
|
|
|
|
export function contrastingOutlineFactor(value: FillChoice, proximityColor: string | [string, string], proximityRange: number): number {
|
|
const pair = Array.isArray(proximityColor) ? [proximityColor[0], proximityColor[1]] : [proximityColor, proximityColor];
|
|
const [range1, range2] = pair.map((color) => colorFromCSS(window.getComputedStyle(document.body).getPropertyValue(color)));
|
|
|
|
const contrast = (color: Color | undefined): number => {
|
|
if (!color) return 0;
|
|
|
|
const lum = colorLuminance(color);
|
|
let rangeLuminance1 = range1 ? colorLuminance(range1) : 0;
|
|
let rangeLuminance2 = range2 ? colorLuminance(range2) : 0;
|
|
[rangeLuminance1, rangeLuminance2] = [Math.min(rangeLuminance1, rangeLuminance2), Math.max(rangeLuminance1, rangeLuminance2)];
|
|
|
|
const distance = Math.max(0, rangeLuminance1 - lum, lum - rangeLuminance2);
|
|
|
|
return (1 - Math.min(distance / proximityRange, 1)) * (1 - colorToHSV(color).s);
|
|
};
|
|
|
|
const gradientStops = fillChoiceGradientStops(value);
|
|
if (gradientStops) {
|
|
if (gradientStops.color.length === 0) return 0;
|
|
|
|
const first = contrast(gradientStops.color[0]);
|
|
const last = contrast(gradientStops.color[gradientStops.color.length - 1]);
|
|
|
|
return Math.min(first, last);
|
|
}
|
|
|
|
return contrast(fillChoiceColor(value));
|
|
}
|
|
|
|
// GRADIENT UTILITY FUNCTIONS
|
|
|
|
export function isGradientStops(value: unknown): value is GradientStops {
|
|
return typeof value === "object" && value !== null && "position" in value && "midpoint" in value && "color" in value;
|
|
}
|
|
|
|
// FILL CHOICE UTILITY FUNCTIONS
|
|
|
|
export function fillChoiceColor(value: FillChoice): Color | undefined {
|
|
if (typeof value === "object" && "Solid" in value) return value.Solid;
|
|
return undefined;
|
|
}
|
|
|
|
export function fillChoiceGradientStops(value: FillChoice): GradientStops | undefined {
|
|
if (typeof value === "object" && "Gradient" in value) return value.Gradient;
|
|
return undefined;
|
|
}
|
|
|
|
export function parseFillChoice(value: unknown): FillChoice {
|
|
if (value === "None" || value === undefined || value === null) return "None";
|
|
if (typeof value === "object" && value !== null && "Solid" in value && isColor(value.Solid)) return { Solid: value.Solid };
|
|
if (typeof value === "object" && value !== null && "Gradient" in value && isGradientStops(value.Gradient)) return { Gradient: value.Gradient };
|
|
return "None";
|
|
}
|