Refactor messages.ts by removing class-transformer and JS classes (#3858)

* Fix gamma correction with HTML-based editable Text tool text

* Migrate simple, undecorated classes to types

* Remove TupleToVec2 transformation

* Remove @Transform from tooltips

* Cleanup: replace value.toString() with String(value) everywhere

* Convert documentId from string to bigint

* Migrate the rest of the easy @Transform/@Type decorations

* Migrate FillChoice

* Migrate WidgetDiffUpdate

* Migrate WidgetInstance

* Migrate away from classes that extend WidgetProps

* Remove class-transformer and all classes in messages.ts

* Migrate UI layout passing

* Remove dead code

* Remove unnecessary export and readonly prefixes

* Remove HSVA type

* Break out Color, Gradient, and FillChoice functions into a utility-functions file

* Move widget helper functions from messages.ts into a new utility-functions file; restructure type imports

* Reduce internal type defs

* Rename JsMessage to FrontendMessage

* Code review fixes

* Fix other usages

* Tidying up
This commit is contained in:
Keavon Chambers
2026-03-05 01:43:21 -08:00
committed by GitHub
parent f00a15a4c9
commit 8a1dfb9d8f
64 changed files with 1436 additions and 2093 deletions
+257
View File
@@ -0,0 +1,257 @@
import { sampleInterpolatedGradient } from "@graphite/../wasm/pkg/graphite_wasm";
import type { Color, FillChoice, Gradient } from "@graphite/messages";
// 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, none: false };
}
export function createNoneColor(): Color {
return { red: 0, green: 0, blue: 0, alpha: 1, none: true };
}
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, none: false };
}
// 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 colorEquals(c1: Color, c2: Color): boolean {
if (c1.none !== c2.none) return false;
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 colorToHSV(color: Color): HSV | undefined {
if (color.none) return undefined;
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 | undefined {
if (color.none) return undefined;
return createColor(color.red, color.green, color.blue, 1);
}
export function colorLuminance(color: Color): number | undefined {
if (color.none) return undefined;
// 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): "black" | "white" {
if (color.none) return "black";
const luminance = colorLuminance(color);
return luminance && 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)) || createNoneColor());
const contrast = (color: Color): number => {
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 = Math.max(0, rangeLuminance1 - lum, lum - rangeLuminance2);
return (1 - Math.min(distance / proximityRange, 1)) * (1 - (colorToHSV(color)?.s || 0));
};
if (isGradient(value)) {
if (value.color.length === 0) return 0;
const first = contrast(value.color[0]);
const last = contrast(value.color[value.color.length - 1]);
return Math.min(first, last);
}
return contrast(value);
}
// GRADIENT UTILITY FUNCTIONS
export function isGradient(value: unknown): value is Gradient {
return typeof value === "object" && value !== null && "position" in value && "midpoint" in value;
}
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%)`;
}
const pieces = sampleInterpolatedGradient(new Float64Array(gradient.position), new Float64Array(gradient.midpoint), gradient.color, false);
return `linear-gradient(to right, ${pieces})`;
}
export function gradientFirstColor(gradient: Gradient): Color | undefined {
return gradient.color[0];
}
export function gradientLastColor(gradient: Gradient): Color | undefined {
return gradient.color[gradient.color.length - 1];
}
// FILL CHOICE UTILITY FUNCTIONS
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function parseFillChoice(value: any): FillChoice {
if (isColor(value)) return value;
if (isGradient(value)) return value;
const gradient: Gradient | undefined = value["Gradient"];
if (gradient) {
const color = gradient.color.map((c) => createColor(c.red, c.green, c.blue, c.alpha));
return { ...gradient, color };
}
const solid = value["Solid"];
if (solid) return createColor(solid.red, solid.green, solid.blue, solid.alpha);
return createNoneColor();
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { type Editor } from "@graphite/editor";
import type { Editor } from "@graphite/editor";
import { extractPixelData } from "@graphite/utility-functions/rasterization";
export function downloadFileURL(filename: string, url: string) {
@@ -10,7 +10,7 @@ export function panicProxy<T extends object>(module: T): T {
if (!isFunction) return targetValue;
// Special handling to wrap the return of a constructor in the proxy
const isClass = isFunction && /^\s*class\s+/.test(targetValue.toString());
const isClass = isFunction && /^\s*class\s+/.test(String(targetValue));
if (isClass) {
return function (...args: unknown[]): unknown {
// All three of these comment lines are necessary to suppress errors at both compile time and while editing this file (@ts-expect-error doesn't work here while editing the file)
+1 -1
View File
@@ -1,4 +1,4 @@
import { type Editor } from "@graphite/editor";
import type { Editor } from "@graphite/editor";
let resizeObserver: ResizeObserver | undefined;
+131
View File
@@ -0,0 +1,131 @@
import type { Layout, LayoutGroup, UIItem, WidgetDiff, WidgetInstance, WidgetSection, WidgetSpanColumn, WidgetSpanRow, WidgetTable } from "@graphite/messages";
export function isWidgetSpanColumn(layoutColumn: LayoutGroup): layoutColumn is WidgetSpanColumn {
return Boolean((layoutColumn as WidgetSpanColumn)?.columnWidgets);
}
export function isWidgetSpanRow(layoutRow: LayoutGroup): layoutRow is WidgetSpanRow {
return Boolean((layoutRow as WidgetSpanRow)?.rowWidgets);
}
export function isWidgetTable(layoutTable: LayoutGroup): layoutTable is WidgetTable {
return Boolean((layoutTable as WidgetTable)?.tableWidgets);
}
export function isWidgetSection(layoutRow: LayoutGroup): layoutRow is WidgetSection {
return Boolean((layoutRow as WidgetSection)?.layout);
}
/// Unwraps the Serde tagged enum `{ widgetId, widget: { Kind: props } }` into `{ widgetId, props: { kind, ...props } }`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function parseWidgetInstance(widgetInstance: any): WidgetInstance {
const widgetId = widgetInstance.widgetId;
const kind = Object.keys(widgetInstance.widget)[0];
const props = widgetInstance.widget[kind];
props.kind = kind;
return { widgetId, props };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function parseWidgetDiffs(rawDiffs: any): WidgetDiff[] {
return rawDiffs.map((diff: WidgetDiff) => {
const { widgetPath, newValue } = diff;
if ("layout" in newValue) return { widgetPath, newValue: newValue.layout.map(createLayoutGroup) };
if ("layoutGroup" in newValue) return { widgetPath, newValue: createLayoutGroup(newValue.layoutGroup) };
if ("widget" in newValue) return { widgetPath, newValue: parseWidgetInstance(newValue.widget) };
// This code should be unreachable
throw new Error("DiffUpdate invalid");
});
}
// Updates a widget layout based on a list of updates, giving the new layout by mutating the `layout` argument
export function patchLayout(layout: /* &mut */ Layout, diffs: WidgetDiff[]) {
diffs.forEach((update) => {
// Find the object where the diff applies to
const diffObject = update.widgetPath.reduce((targetLayout: UIItem | undefined, index: number): UIItem | undefined => {
if (targetLayout && "columnWidgets" in targetLayout) return targetLayout.columnWidgets[index];
if (targetLayout && "rowWidgets" in targetLayout) return targetLayout.rowWidgets[index];
if (targetLayout && "tableWidgets" in targetLayout) return targetLayout.tableWidgets[index];
if (targetLayout && "layout" in targetLayout) return targetLayout.layout[index];
if (targetLayout && "props" in targetLayout && "widgetId" in targetLayout) {
if (targetLayout.props.kind === "PopoverButton" && "popoverLayout" in targetLayout.props && targetLayout.props.popoverLayout) {
targetLayout.props.popoverLayout = targetLayout.props.popoverLayout.map(createLayoutGroup);
return targetLayout.props.popoverLayout[index];
}
// eslint-disable-next-line no-console
console.error("Tried to index widget");
return targetLayout;
}
return targetLayout?.[index];
}, layout as UIItem);
// Exit if we failed to produce a valid patch for the existing layout.
// This means that the backend assumed an existing layout that doesn't exist in the frontend. This can happen, for
// example, if a panel is destroyed in the frontend but was never cleared in the backend, so the next time the backend
// tries to update the layout, it attempts to insert only the changes against the old layout that no longer exists.
if (diffObject === undefined) {
// eslint-disable-next-line no-console
console.error("In `patchLayout`, the `diffObject` is undefined. The layout has not been updated. See the source code comment above this error for hints.");
return;
}
// If this is a list with a length, then set the length to 0 to clear the list
if ("length" in diffObject) {
diffObject.length = 0;
}
// Remove all of the keys from the old object
Object.keys(diffObject).forEach((key) => delete (diffObject as Record<string, unknown>)[key]);
// Assign keys to the new object
// `Object.assign` works but `diffObject = update.newValue;` doesn't.
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign
Object.assign(diffObject, update.newValue);
});
}
// Unpacking a layout group
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function createLayoutGroup(layoutGroup: any): LayoutGroup {
// Detect if this has already been parsed and, if so, return it as-is so this function can be idempotent
if ("columnWidgets" in layoutGroup || "rowWidgets" in layoutGroup || "tableWidgets" in layoutGroup || ("name" in layoutGroup && "layout" in layoutGroup)) return layoutGroup;
if (layoutGroup.column) {
const columnWidgets = layoutGroup.column.columnWidgets.map(parseWidgetInstance);
const result: WidgetSpanColumn = { columnWidgets };
return result;
}
if (layoutGroup.row) {
const result: WidgetSpanRow = { rowWidgets: layoutGroup.row.rowWidgets.map(parseWidgetInstance) };
return result;
}
if (layoutGroup.section) {
const result: WidgetSection = {
name: layoutGroup.section.name,
description: layoutGroup.section.description,
visible: layoutGroup.section.visible,
pinned: layoutGroup.section.pinned,
id: layoutGroup.section.id,
layout: layoutGroup.section.layout.map(createLayoutGroup),
};
return result;
}
if (layoutGroup.table) {
const result: WidgetTable = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tableWidgets: layoutGroup.table.tableWidgets.map((row: any) => row.map(parseWidgetInstance)),
unstyled: layoutGroup.table.unstyled,
};
return result;
}
throw new Error("Layout row type does not exist");
}