From f00a15a4c9579432e6d05694d721f62c59448419 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 4 Mar 2026 02:39:07 -0800 Subject: [PATCH] Clean up Gradient and Color classes in TS by removing their methods (#3857) --- .../floating-menus/ColorPicker.svelte | 91 ++-- .../src/components/panels/Document.svelte | 11 +- .../widgets/inputs/ColorInput.svelte | 8 +- .../widgets/inputs/SpectrumInput.svelte | 12 +- .../widgets/inputs/WorkingColorsInput.svelte | 10 +- frontend/src/messages.ts | 456 ++++++++---------- 6 files changed, 274 insertions(+), 314 deletions(-) diff --git a/frontend/src/components/floating-menus/ColorPicker.svelte b/frontend/src/components/floating-menus/ColorPicker.svelte index a9b6972f..c2547a4c 100644 --- a/frontend/src/components/floating-menus/ColorPicker.svelte +++ b/frontend/src/components/floating-menus/ColorPicker.svelte @@ -2,7 +2,25 @@ import { getContext, onDestroy, createEventDispatcher, tick } from "svelte"; import type { HSV, RGB, FillChoice, MenuDirection } from "@graphite/messages"; - import { Color, contrastingOutlineFactor, Gradient } from "@graphite/messages"; + import { + type Color, + contrastingOutlineFactor, + isColor, + isGradient, + createColor, + createNoneColor, + createColorFromHSVA, + colorFromCSS, + colorToRgb255, + colorToHSVA, + colorToHexOptionalAlpha, + colorToHexNoAlpha, + colorToRgbCSS, + colorContrastingColor, + colorOpaque, + colorEquals, + gradientFirstColor, + } from "@graphite/messages"; import type { TooltipState } from "@graphite/state-providers/tooltip"; import { clamp } from "@graphite/utility-functions/math"; import { isDesktop } from "@graphite/utility-functions/platform"; @@ -51,16 +69,17 @@ // TODO: See if this should be made to follow the pattern of DropdownInput.svelte so this could be removed export let open: boolean; - const hsvaOrNone = colorOrGradient instanceof Color ? colorOrGradient.toHSVA() : colorOrGradient.firstColor()?.toHSVA(); + const colorForHSVA = isColor(colorOrGradient) ? colorOrGradient : gradientFirstColor(colorOrGradient); + const hsvaOrNone = colorForHSVA ? colorToHSVA(colorForHSVA) : undefined; const hsva = hsvaOrNone || { h: 0, s: 0, v: 0, a: 1 }; // Gradient color stops - $: gradient = colorOrGradient instanceof Gradient ? colorOrGradient : undefined; + $: gradient = isGradient(colorOrGradient) ? colorOrGradient : undefined; let activeIndex = 0 as number | undefined; let activeIndexIsMidpoint = false; - $: selectedGradientColor = (activeIndex !== undefined && gradient?.color[activeIndex]) || (Color.fromCSS("black") as Color); + $: selectedGradientColor = (activeIndex !== undefined && gradient?.color[activeIndex]) || (colorFromCSS("black") as Color); // Currently viewed color - $: color = colorOrGradient instanceof Color ? colorOrGradient : selectedGradientColor; + $: color = isColor(colorOrGradient) ? colorOrGradient : selectedGradientColor; // New color components let hue = hsva.h; let saturation = hsva.s; @@ -97,16 +116,16 @@ $: oldColor = generateColor(oldHue, oldSaturation, oldValue, oldAlpha, oldIsNone); $: newColor = generateColor(hue, saturation, value, alpha, isNone); - $: rgbChannels = Object.entries(newColor.toRgb255() || { r: undefined, g: undefined, b: undefined }) as [keyof RGB, number | undefined][]; + $: rgbChannels = Object.entries(colorToRgb255(newColor) || { r: undefined, g: undefined, b: undefined }) as [keyof RGB, number | undefined][]; $: hsvChannels = Object.entries(!isNone ? { h: hue * 360, s: saturation * 100, v: value * 100 } : { h: undefined, s: undefined, v: undefined }) as [keyof HSV, number | undefined][]; - $: opaqueHueColor = new Color({ h: hue, s: 1, v: 1, a: 1 }); + $: opaqueHueColor = createColorFromHSVA(hue, 1, 1, 1); $: outlineFactor = Math.max(contrastingOutlineFactor(newColor, "--color-2-mildblack", 0.01), contrastingOutlineFactor(oldColor, "--color-2-mildblack", 0.01)); $: outlined = outlineFactor > 0.0001; $: transparency = newColor.alpha < 1 || oldColor.alpha < 1; function generateColor(h: number, s: number, v: number, a: number, none: boolean) { - if (none) return new Color("none"); - return new Color({ h, s, v, a }); + if (none) return createNoneColor(); + return createColorFromHSVA(h, s, v, a); } async function watchOpen(open: boolean) { @@ -119,7 +138,7 @@ } function watchColor(color: Color) { - const hsva = color.toHSVA(); + const hsva = colorToHSVA(color); if (hsva === undefined) { setNewHSVA(0, 0, 0, 1, true); @@ -185,7 +204,7 @@ strayCloses = false; } - const color = new Color({ h: hue, s: saturation, v: value, a: alpha }); + const color = createColorFromHSVA(hue, saturation, value, alpha); setColor(color); if (!e.shiftKey) { @@ -226,7 +245,7 @@ saturation = saturationRestoreWhenShiftReleased; value = valueRestoreWhenShiftReleased; - const color = new Color({ h: hue, s: saturation, v: value, a: alpha }); + const color = createColorFromHSVA(hue, saturation, value, alpha); setColor(color); } } @@ -282,14 +301,14 @@ value = valueBeforeDrag; alpha = alphaBeforeDrag; - const color = new Color({ h: hue, s: saturation, v: value, a: alpha }); + const color = createColorFromHSVA(hue, saturation, value, alpha); setColor(color); } function setColor(color?: Color) { - const colorToEmit = color || new Color({ h: hue, s: saturation, v: value, a: alpha }); + const colorToEmit = color || createColorFromHSVA(hue, saturation, value, alpha); - if (gradientSpectrumInputWidget && activeIndex !== undefined && gradient?.position[activeIndex] !== undefined && colorOrGradient instanceof Gradient) { + if (gradientSpectrumInputWidget && activeIndex !== undefined && gradient?.position[activeIndex] !== undefined && isGradient(colorOrGradient)) { colorOrGradient.color[activeIndex] = colorToEmit; } @@ -312,7 +331,7 @@ } function setColorCode(colorCode: string) { - const color = Color.fromCSS(colorCode); + const color = colorFromCSS(colorCode); if (color) setColor(color); } @@ -320,9 +339,9 @@ // Do nothing if the given value is undefined if (strength === undefined) return undefined; // Set the specified channel to the given value - else if (channel === "r") setColor(new Color(strength / 255, newColor.green, newColor.blue, newColor.alpha)); - else if (channel === "g") setColor(new Color(newColor.red, strength / 255, newColor.blue, newColor.alpha)); - else if (channel === "b") setColor(new Color(newColor.red, newColor.green, strength / 255, newColor.alpha)); + else if (channel === "r") setColor(createColor(strength / 255, newColor.green, newColor.blue, newColor.alpha)); + else if (channel === "g") setColor(createColor(newColor.red, strength / 255, newColor.blue, newColor.alpha)); + else if (channel === "b") setColor(createColor(newColor.red, newColor.green, strength / 255, newColor.alpha)); } function setColorHSV(channel: keyof HSV, strength: number | undefined) { @@ -353,10 +372,10 @@ if (preset === "none") { setNewHSVA(0, 0, 0, 1, true); - setColor(new Color("none")); + setColor(createNoneColor()); } else { - const presetColor = new Color(...PURE_COLORS[preset], 1); - const hsva = presetColor.toHSVA() || { h: 0, s: 0, v: 0, a: 0 }; + const presetColor = createColor(...PURE_COLORS[preset], 1); + const hsva = colorToHSVA(presetColor) || { h: 0, s: 0, v: 0, a: 0 }; setNewHSVA(hsva.h, hsva.s, hsva.v, hsva.a, false); setColor(presetColor); @@ -406,7 +425,7 @@ activeIndexIsMidpoint = activeMarkerIsMidpoint; const color = activeMarkerIndex === undefined ? undefined : gradient?.color[activeMarkerIndex]; - const hsva = color?.toHSVA(); + const hsva = color ? colorToHSVA(color) : undefined; if (!color || !hsva) return; setColor(color); @@ -427,14 +446,14 @@ {@const hueDescription = "The shade along the spectrum of the rainbow."} @@ -528,18 +547,18 @@ class="choice-preview" classes={{ outlined, transparency }} styles={{ "--outline-amount": outlineFactor }} - tooltipDescription={!newColor.equals(oldColor) ? "Comparison between the present color choice (left) and the color before it was changed (right)." : "The present color choice."} + tooltipDescription={!colorEquals(newColor, oldColor) ? "Comparison between the present color choice (left) and the color before it was changed (right)." : "The present color choice."} > - {#if !newColor.equals(oldColor) && !disabled} + {#if !colorEquals(newColor, oldColor) && !disabled}
{/if} - {#if !newColor.equals(oldColor)} + {#if !colorEquals(newColor, oldColor)} New {/if} - {#if !newColor.equals(oldColor)} + {#if !colorEquals(newColor, oldColor)} Old @@ -552,7 +571,7 @@ { dispatch("startHistoryTransaction"); diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index d8453997..8bfa0ea3 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -6,7 +6,10 @@ type MenuDirection, type MouseCursorIcon, type XY, - Color, + type Color, + isColor, + createColor, + colorToHexOptionalAlpha, DisplayEditableTextbox, DisplayEditableTextboxUpdateFontData, DisplayEditableTextboxTransform, @@ -360,7 +363,7 @@ textInput.style.height = height; textInput.style.lineHeight = `${data.lineHeightRatio}`; textInput.style.fontSize = `${data.fontSize}px`; - textInput.style.color = data.color.toHexOptionalAlpha() || "transparent"; + textInput.style.color = colorToHexOptionalAlpha(data.color) || "transparent"; textInput.style.textAlign = data.align; textInput.oninput = () => { @@ -609,9 +612,9 @@ gradientStopPickerColor = undefined; } }} - colorOrGradient={gradientStopPickerColor || new Color()} + colorOrGradient={gradientStopPickerColor || createColor(0, 0, 0, 1)} on:colorOrGradient={({ detail }) => { - if (detail instanceof Color) { + if (isColor(detail)) { editor.handle.updateGradientStopColor(detail.red, detail.green, detail.blue, detail.alpha); } }} diff --git a/frontend/src/components/widgets/inputs/ColorInput.svelte b/frontend/src/components/widgets/inputs/ColorInput.svelte index 3ead45ca..793ff393 100644 --- a/frontend/src/components/widgets/inputs/ColorInput.svelte +++ b/frontend/src/components/widgets/inputs/ColorInput.svelte @@ -2,7 +2,7 @@ import { createEventDispatcher } from "svelte"; import type { FillChoice, MenuDirection, ActionShortcut } from "@graphite/messages"; - import { Color, contrastingOutlineFactor, Gradient } from "@graphite/messages"; + import { type Color, contrastingOutlineFactor, isColor, isGradient, colorToHexOptionalAlpha, gradientToLinearGradientCSS } from "@graphite/messages"; import ColorPicker from "@graphite/components/floating-menus/ColorPicker.svelte"; import LayoutCol from "@graphite/components/layout/LayoutCol.svelte"; @@ -26,9 +26,9 @@ $: outlineFactor = contrastingOutlineFactor(value, ["--color-1-nearblack", "--color-3-darkgray"], 0.01); $: outlined = outlineFactor > 0.0001; - $: chosenGradient = value instanceof Gradient ? value.toLinearGradientCSS() : `linear-gradient(${value.toHexOptionalAlpha()}, ${value.toHexOptionalAlpha()})`; - $: none = value instanceof Color ? value.none : false; - $: transparency = value instanceof Gradient ? value.color.some((color) => color.alpha < 1) : value.alpha < 1; + $: chosenGradient = isGradient(value) ? gradientToLinearGradientCSS(value) : `linear-gradient(${colorToHexOptionalAlpha(value)}, ${colorToHexOptionalAlpha(value)})`; + $: none = isColor(value) ? value.none : false; + $: transparency = isGradient(value) ? value.color.some((color: Color) => color.alpha < 1) : value.alpha < 1; diff --git a/frontend/src/components/widgets/inputs/SpectrumInput.svelte b/frontend/src/components/widgets/inputs/SpectrumInput.svelte index 7d76da2c..9192c834 100644 --- a/frontend/src/components/widgets/inputs/SpectrumInput.svelte +++ b/frontend/src/components/widgets/inputs/SpectrumInput.svelte @@ -7,7 +7,7 @@ import { createEventDispatcher, onDestroy } from "svelte"; import { evaluateGradientAtPosition } from "@graphite/../wasm/pkg/graphite_wasm"; - import { Color, type Gradient } from "@graphite/messages"; + import { type Color, type Gradient, createColor, colorToHexOptionalAlpha, colorToRgbCSS, gradientFirstColor, gradientLastColor, gradientToLinearGradientCSS } from "@graphite/messages"; import { preventEscapeClosingParentFloatingMenu } from "@graphite/components/layout/FloatingMenu.svelte"; import LayoutCol from "@graphite/components/layout/LayoutCol.svelte"; @@ -115,7 +115,7 @@ // Determine the color of the new stop by evaluating the gradient at the position of the new stop type ReturnedColor = { red: number; green: number; blue: number; alpha: number }; const evaluated = evaluateGradientAtPosition(position, new Float64Array(gradient.position), new Float64Array(gradient.midpoint), gradient.color) as ReturnedColor; - const color = new Color(evaluated.red, evaluated.green, evaluated.blue, evaluated.alpha); + const color = createColor(evaluated.red, evaluated.green, evaluated.blue, evaluated.alpha); // Insert the new stop into the gradient gradient.position.splice(index, 0, position); @@ -368,9 +368,9 @@ class="spectrum-input" classes={{ disabled }} styles={{ - "--gradient-start": gradient.firstColor()?.toHexOptionalAlpha() || "black", - "--gradient-end": gradient.lastColor()?.toHexOptionalAlpha() || "black", - "--gradient-stops": gradient.toLinearGradientCSS(), + "--gradient-start": ((color) => (color ? colorToHexOptionalAlpha(color) : "black"))(gradientFirstColor(gradient)), + "--gradient-end": ((color) => (color ? colorToHexOptionalAlpha(color) : "black"))(gradientLastColor(gradient)), + "--gradient-stops": gradientToLinearGradientCSS(gradient), }} > @@ -396,7 +396,7 @@ class="marker" class:active={index === activeMarkerIndex && !activeMarkerIsMidpoint} style:--marker-position={marker.position} - style:--marker-color={marker.color.toRgbCSS()} + style:--marker-color={colorToRgbCSS(marker.color)} on:pointerdown={(e) => markerPointerDown(e, index)} data-gradient-marker xmlns="http://www.w3.org/2000/svg" diff --git a/frontend/src/components/widgets/inputs/WorkingColorsInput.svelte b/frontend/src/components/widgets/inputs/WorkingColorsInput.svelte index 8d5d6f6a..af6d4a56 100644 --- a/frontend/src/components/widgets/inputs/WorkingColorsInput.svelte +++ b/frontend/src/components/widgets/inputs/WorkingColorsInput.svelte @@ -2,7 +2,7 @@ import { getContext } from "svelte"; import type { Editor } from "@graphite/editor"; - import { Color } from "@graphite/messages"; + import { type Color, isColor, colorToRgbaCSS } from "@graphite/messages"; import ColorPicker from "@graphite/components/floating-menus/ColorPicker.svelte"; import LayoutCol from "@graphite/components/layout/LayoutCol.svelte"; @@ -38,22 +38,22 @@ - + (primaryOpen = detail)} colorOrGradient={primary} - on:colorOrGradient={({ detail }) => detail instanceof Color && primaryColorChanged(detail)} + on:colorOrGradient={({ detail }) => isColor(detail) && primaryColorChanged(detail)} direction="Right" /> - + (secondaryOpen = detail)} colorOrGradient={secondary} - on:colorOrGradient={({ detail }) => detail instanceof Color && secondaryColorChanged(detail)} + on:colorOrGradient={({ detail }) => isColor(detail) && secondaryColorChanged(detail)} direction="Right" /> diff --git a/frontend/src/messages.ts b/frontend/src/messages.ts index b4811eac..c11da922 100644 --- a/frontend/src/messages.ts +++ b/frontend/src/messages.ts @@ -365,282 +365,227 @@ export type ActionShortcut = { shortcut: LabeledShortcut }; // 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 HSVA = { h: number; s: number; v: number; a: number }; export type HSV = { h: number; s: number; v: number }; -export type RGBA = { r: number; g: number; b: number; a: number }; export type RGB = { r: number; g: number; b: number }; export class Gradient { position!: number[]; midpoint!: number[]; color!: Color[]; - - constructor(position: number[], midpoint: number[], color: Color[]) { - this.position = position; - this.midpoint = midpoint; - this.color = color; - } - - toLinearGradientCSS(): string { - if (this.position.length === 1) { - return `linear-gradient(to right, ${this.color[0].toHexOptionalAlpha()} 0%, ${this.color[0].toHexOptionalAlpha()} 100%)`; - } - - const pieces = sampleInterpolatedGradient(new Float64Array(this.position), new Float64Array(this.midpoint), this.color, false); - return `linear-gradient(to right, ${pieces})`; - } - - toLinearGradientCSSNoAlpha(): string { - if (this.position.length === 1) { - return `linear-gradient(to right, ${this.color[0].toHexNoAlpha()} 0%, ${this.color[0].toHexNoAlpha()} 100%)`; - } - - const pieces = sampleInterpolatedGradient(new Float64Array(this.position), new Float64Array(this.midpoint), this.color, true); - return `linear-gradient(to right, ${pieces})`; - } - - firstColor(): Color | undefined { - return this.color[0]; - } - - lastColor(): Color | undefined { - return this.color[this.color.length - 1]; - } } // All channels range are represented by 0-1, sRGB, gamma. export class Color { readonly red!: number; - readonly green!: number; - readonly blue!: number; - readonly alpha!: number; - readonly none!: boolean; +} - constructor(); +// COLOR FACTORY FUNCTIONS - constructor(none: "none"); +export function createColor(red: number, green: number, blue: number, alpha: number): Color { + return { red, green, blue, alpha, none: false }; +} - constructor(hsva: HSVA); +export function createNoneColor(): Color { + return { red: 0, green: 0, blue: 0, alpha: 1, none: true }; +} - constructor(red: number, green: number, blue: number, alpha: number); +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); + }; - constructor(firstArg?: "none" | HSVA | number, green?: number, blue?: number, alpha?: number) { - // Empty constructor - if (firstArg === undefined) { - this.red = 0; - this.green = 0; - this.blue = 0; - this.alpha = 1; - this.none = false; - } else if (firstArg === "none") { - this.red = 0; - this.green = 0; - this.blue = 0; - this.alpha = 1; - this.none = true; - } - // HSVA constructor - else if (typeof firstArg === "object" && green === undefined && blue === undefined && alpha === undefined) { - const { h, s, v } = firstArg; - const convert = (n: number): number => { - const k = (n + h * 6) % 6; - return v - v * s * Math.max(Math.min(...[k, 4 - k, 1]), 0); - }; - - this.red = convert(5); - this.green = convert(3); - this.blue = convert(1); - this.alpha = firstArg.a; - this.none = false; - } - // RGBA constructor - else if (typeof firstArg === "number" && typeof green === "number" && typeof blue === "number" && typeof alpha === "number") { - this.red = firstArg; - this.green = green; - this.blue = blue; - this.alpha = alpha; - this.none = false; + return { red: convert(5), green: convert(3), blue: convert(1), alpha: a, none: false }; +} + +// COLOR UTILITY FUNCTIONS + +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 colorEquals(c1: Color, c2: Color): boolean { + if (c1.none && c2.none) return true; + return Math.abs(c1.red - c2.red) < 1e-6 && Math.abs(c1.green - c2.green) < 1e-6 && Math.abs(c1.blue - c2.blue) < 1e-6 && Math.abs(c1.alpha - c2.alpha) < 1e-6; +} + +export function colorToHexNoAlpha(color: Color): string | undefined { + if (color.none) return undefined; + + 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 colorToHexOptionalAlpha(color: Color): string | undefined { + if (color.none) return undefined; + + const hex = colorToHexNoAlpha(color); + const a = Math.round(color.alpha * 255) + .toString(16) + .padStart(2, "0"); + + return a === "ff" ? hex : `${hex}${a}`; +} + +export function colorToRgb255(color: Color): RGB | undefined { + if (color.none) return undefined; + + 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 | undefined { + const rgb = colorToRgb255(color); + if (!rgb) return undefined; + + return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`; +} + +export function colorToRgbaCSS(color: Color): string | undefined { + const rgb = colorToRgb255(color); + if (!rgb) return undefined; + + return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${color.alpha})`; +} + +export function colorToHSVA(color: Color): HSVA | undefined { + if (color.none) return undefined; + + const { red: r, green: g, blue: b, alpha: a } = 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; } - static fromCSS(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}`; - } + return { h, s, v, a }; +} - const canvas = document.createElement("canvas"); - canvas.width = 1; - canvas.height = 1; - const context = canvas.getContext("2d", { willReadFrequently: true }); - if (!context) return undefined; +export function colorOpaque(color: Color): Color | undefined { + if (color.none) return undefined; - context.clearRect(0, 0, 1, 1); + return createColor(color.red, color.green, color.blue, 1); +} - context.fillStyle = "black"; - context.fillStyle = colorValue; - const comparisonA = context.fillStyle; +export function colorLuminance(color: Color): number | undefined { + if (color.none) return undefined; - context.fillStyle = "white"; - context.fillStyle = colorValue; - const comparisonB = context.fillStyle; + // 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); - // 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 Color.fromCSS(`#${colorValue.trim()}`); - return undefined; - } + // https://stackoverflow.com/a/3943023/775283 - context.fillRect(0, 0, 1, 1); + 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; - const [r, g, b, a] = [...context.getImageData(0, 0, 1, 1).data]; - return new Color(r / 255, g / 255, b / 255, a / 255); + return linearR * 0.2126 + linearG * 0.7152 + linearB * 0.0722; +} + +export function colorContrastingColor(color: Color): "black" | "white" { + if (color.none) return "black"; + + const luminance = colorLuminance(color); + + return luminance && luminance > Math.sqrt(1.05 * 0.05) - 0.05 ? "black" : "white"; +} + +// GRADIENT UTILITY FUNCTIONS + +export function gradientToLinearGradientCSS(gradient: Gradient): string { + if (gradient.position.length === 1) { + return `linear-gradient(to right, ${colorToHexOptionalAlpha(gradient.color[0])} 0%, ${colorToHexOptionalAlpha(gradient.color[0])} 100%)`; } - equals(other: Color): boolean { - if (this.none && other.none) return true; - return Math.abs(this.red - other.red) < 1e-6 && Math.abs(this.green - other.green) < 1e-6 && Math.abs(this.blue - other.blue) < 1e-6 && Math.abs(this.alpha - other.alpha) < 1e-6; - } + const pieces = sampleInterpolatedGradient(new Float64Array(gradient.position), new Float64Array(gradient.midpoint), gradient.color, false); + return `linear-gradient(to right, ${pieces})`; +} - toHexNoAlpha(): string | undefined { - if (this.none) return undefined; +export function gradientFirstColor(gradient: Gradient): Color | undefined { + return gradient.color[0]; +} - const r = Math.round(this.red * 255) - .toString(16) - .padStart(2, "0"); - const g = Math.round(this.green * 255) - .toString(16) - .padStart(2, "0"); - const b = Math.round(this.blue * 255) - .toString(16) - .padStart(2, "0"); +export function gradientLastColor(gradient: Gradient): Color | undefined { + return gradient.color[gradient.color.length - 1]; +} - return `#${r}${g}${b}`; - } +// COLOR/GRADIENT TYPE GUARDS - toHexOptionalAlpha(): string | undefined { - if (this.none) return undefined; +export function isColor(value: unknown): value is Color { + return typeof value === "object" && value !== null && "red" in value; +} - const hex = this.toHexNoAlpha(); - const a = Math.round(this.alpha * 255) - .toString(16) - .padStart(2, "0"); - - return a === "ff" ? hex : `${hex}${a}`; - } - - toRgb255(): RGB | undefined { - if (this.none) return undefined; - - return { - r: Math.round(this.red * 255), - g: Math.round(this.green * 255), - b: Math.round(this.blue * 255), - }; - } - - toRgbCSS(): string | undefined { - const rgb = this.toRgb255(); - if (!rgb) return undefined; - - return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`; - } - - toRgbaCSS(): string | undefined { - const rgb = this.toRgb255(); - if (!rgb) return undefined; - - return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${this.alpha})`; - } - - toHSV(): HSV | undefined { - const hsva = this.toHSVA(); - if (!hsva) return undefined; - - return { h: hsva.h, s: hsva.s, v: hsva.v }; - } - - toHSVA(): HSVA | undefined { - if (this.none) return undefined; - - const { red: r, green: g, blue: b, alpha: a } = this; - - 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, a }; - } - - toHsvDegreesAndPercent(): HSV | undefined { - const hsva = this.toHSVA(); - if (!hsva) return undefined; - - return { h: hsva.h * 360, s: hsva.s * 100, v: hsva.v * 100 }; - } - - toHsvaDegreesAndPercent(): HSVA | undefined { - const hsva = this.toHSVA(); - if (!hsva) return undefined; - - return { h: hsva.h * 360, s: hsva.s * 100, v: hsva.v * 100, a: hsva.a * 100 }; - } - - opaque(): Color | undefined { - if (this.none) return undefined; - - return new Color(this.red, this.green, this.blue, 1); - } - - luminance(): number | undefined { - if (this.none) return undefined; - - // Convert alpha into white - const r = this.red * this.alpha + (1 - this.alpha); - const g = this.green * this.alpha + (1 - this.alpha); - const b = this.blue * this.alpha + (1 - this.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; - } - - contrastingColor(): "black" | "white" { - if (this.none) return "black"; - - const luminance = this.luminance(); - - return luminance && luminance > Math.sqrt(1.05 * 0.05) - 0.05 ? "black" : "white"; - } +export function isGradient(value: unknown): value is Gradient { + return typeof value === "object" && value !== null && "position" in value && "midpoint" in value; } export class UpdateActiveDocument extends JsMessage { @@ -963,22 +908,19 @@ export class CheckboxInput extends WidgetProps { export class ColorInput extends WidgetProps { // Content @Transform(({ value }) => { - if (value instanceof Gradient) return value; + if (isGradient(value)) return value; const gradient: Gradient | undefined = value["Gradient"]; if (gradient) { - return new Gradient( - gradient.position, - gradient.midpoint, - gradient.color.map((color) => new Color(color.red, color.green, color.blue, color.alpha)), - ); + const color = gradient.color.map((color) => createColor(color.red, color.green, color.blue, color.alpha)); + return { ...gradient, color }; } - if (value instanceof Color) return value; + if (isColor(value)) return value; const solid = value["Solid"]; - if (solid) return new Color(solid.red, solid.green, solid.blue, solid.alpha); + if (solid) return createColor(solid.red, solid.green, solid.blue, solid.alpha); - return new Color("none"); + return createNoneColor(); }) value!: FillChoice; allowNone!: boolean; @@ -1002,24 +944,20 @@ export type FillChoice = Color | Gradient; 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) => Color.fromCSS(window.getComputedStyle(document.body).getPropertyValue(color)) || new Color("none")); + const [range1, range2] = pair.map((color) => colorFromCSS(window.getComputedStyle(document.body).getPropertyValue(color)) || createNoneColor()); const contrast = (color: Color): number => { - const colorLuminance = color.luminance() || 0; - let rangeLuminance1 = range1.luminance() || 0; - let rangeLuminance2 = range2.luminance() || 0; + const lum = colorLuminance(color) || 0; + let rangeLuminance1 = colorLuminance(range1) || 0; + let rangeLuminance2 = colorLuminance(range2) || 0; [rangeLuminance1, rangeLuminance2] = [Math.min(rangeLuminance1, rangeLuminance2), Math.max(rangeLuminance1, rangeLuminance2)]; - const distance = (() => { - if (colorLuminance < rangeLuminance1) return rangeLuminance1 - colorLuminance; - if (colorLuminance > rangeLuminance2) return colorLuminance - rangeLuminance2; - return 0; - })(); + const distance = Math.max(0, rangeLuminance1 - lum, lum - rangeLuminance2); - return (1 - Math.min(distance / proximityRange, 1)) * (1 - (color.toHSV()?.s || 0)); + return (1 - Math.min(distance / proximityRange, 1)) * (1 - (colorToHSVA(color)?.s || 0)); }; - if (value instanceof Gradient) { + if (isGradient(value)) { if (value.color.length === 0) return 0; const first = contrast(value.color[0]);