Refactor the TypeScript data flow for full type safety and auto-generation of Rust types (#3865)

* Migrate Specta to Tsify to auto-generate messages.ts, working except colors and widgets

* Adopt the generated FillColor/Color/GradientStops

* Fix widget typing

* Separate WidgetGroup enum variants into wrapper structs

* Small rename

* Simplify widgets further

* Clean up message type references

* Switch type imports to the auto-generated file

* Remove lowercase serde rename

* Fix FillChoice deserialization

* Fix small regression from #3837

* Improve type safety

* Make WidgetSpan type-safe

* More cleanup and type safety

* More type safety

* More type safety

* Get the rest to type-check without errors; improve widget builder macro to have optional icons; improve Svelte 5 configs

* Cargo fmt

* Fix imports

* Update outdated readme info

* Fix lint command rename references

* Fix typos

* One more typos fix

* Remove unnecessary dep: prefix from the edited Cargo.toml files

* Remove excess parts from Cargo.toml

* Fix compiling on desktop

* Revert "Remove excess parts from Cargo.toml"

This reverts commit 6b711117b3a5d5d8a3ee20f36a43bc74930b7c82.

* Update dev docs with simpler, more accurate instructions
This commit is contained in:
Keavon Chambers
2026-03-09 16:35:04 -07:00
parent fbd2658148
commit 52d2b38a82
199 changed files with 2265 additions and 2811 deletions
+49 -64
View File
@@ -1,5 +1,5 @@
import { sampleInterpolatedGradient } from "@graphite/../wasm/pkg/graphite_wasm";
import type { Color, FillChoice, Gradient } from "@graphite/messages";
import type { Color, FillChoice, GradientStops } from "@graphite/../wasm/pkg/graphite_wasm";
// 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 };
@@ -8,11 +8,7 @@ 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 };
return { red, green, blue, alpha };
}
export function createColorFromHSVA(h: number, s: number, v: number, a: number): Color {
@@ -21,7 +17,7 @@ export function createColorFromHSVA(h: number, s: number, v: number, a: number):
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 };
return { red: convert(5), green: convert(3), blue: convert(1), alpha: a };
}
// COLOR UTILITY FUNCTIONS
@@ -67,15 +63,13 @@ export function colorFromCSS(colorCode: string): Color | undefined {
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;
export function colorEquals(c1: Color | undefined, c2: Color | undefined): boolean {
if (c1 === undefined && c2 === undefined) return true;
if (c1 === undefined || c2 === undefined) return false;
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;
export function colorToHexNoAlpha(color: Color): string {
const r = Math.round(color.red * 255)
.toString(16)
.padStart(2, "0");
@@ -89,9 +83,7 @@ export function colorToHexNoAlpha(color: Color): string | undefined {
return `#${r}${g}${b}`;
}
export function colorToHexOptionalAlpha(color: Color): string | undefined {
if (color.none) return undefined;
export function colorToHexOptionalAlpha(color: Color): string {
const hex = colorToHexNoAlpha(color);
const a = Math.round(color.alpha * 255)
.toString(16)
@@ -100,9 +92,7 @@ export function colorToHexOptionalAlpha(color: Color): string | undefined {
return a === "ff" ? hex : `${hex}${a}`;
}
export function colorToRgb255(color: Color): RGB | undefined {
if (color.none) return undefined;
export function colorToRgb255(color: Color): RGB {
return {
r: Math.round(color.red * 255),
g: Math.round(color.green * 255),
@@ -110,23 +100,19 @@ export function colorToRgb255(color: Color): RGB | undefined {
};
}
export function colorToRgbCSS(color: Color): string | undefined {
export function colorToRgbCSS(color: Color): string {
const rgb = colorToRgb255(color);
if (!rgb) return undefined;
return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
}
export function colorToRgbaCSS(color: Color): string | undefined {
export function colorToRgbaCSS(color: Color): string {
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;
export function colorToHSV(color: Color): HSV {
const { red: r, green: g, blue: b } = color;
const max = Math.max(r, g, b);
@@ -156,15 +142,11 @@ export function colorToHSV(color: Color): HSV | undefined {
return { h, s, v };
}
export function colorOpaque(color: Color): Color | undefined {
if (color.none) return undefined;
export function colorOpaque(color: Color): Color {
return createColor(color.red, color.green, color.blue, 1);
}
export function colorLuminance(color: Color): number | undefined {
if (color.none) return undefined;
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);
@@ -179,48 +161,51 @@ export function colorLuminance(color: Color): number | undefined {
return linearR * 0.2126 + linearG * 0.7152 + linearB * 0.0722;
}
export function colorContrastingColor(color: Color): "black" | "white" {
if (color.none) return "black";
export function colorContrastingColor(color: Color | undefined): "black" | "white" {
if (!color) return "black";
const luminance = colorLuminance(color);
return luminance && luminance > Math.sqrt(1.05 * 0.05) - 0.05 ? "black" : "white";
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)) || createNoneColor());
const [range1, range2] = pair.map((color) => colorFromCSS(window.getComputedStyle(document.body).getPropertyValue(color)));
const contrast = (color: Color): number => {
const lum = colorLuminance(color) || 0;
let rangeLuminance1 = colorLuminance(range1) || 0;
let rangeLuminance2 = colorLuminance(range2) || 0;
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 || 0));
return (1 - Math.min(distance / proximityRange, 1)) * (1 - colorToHSV(color).s);
};
if (isGradient(value)) {
if (value.color.length === 0) return 0;
const gradientStops = fillChoiceGradientStops(value);
if (gradientStops) {
if (gradientStops.color.length === 0) return 0;
const first = contrast(value.color[0]);
const last = contrast(value.color[value.color.length - 1]);
const first = contrast(gradientStops.color[0]);
const last = contrast(gradientStops.color[gradientStops.color.length - 1]);
return Math.min(first, last);
}
return contrast(value);
return contrast(fillChoiceColor(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 isGradientStops(value: unknown): value is GradientStops {
return typeof value === "object" && value !== null && "position" in value && "midpoint" in value && "color" in value;
}
export function gradientToLinearGradientCSS(gradient: Gradient): string {
export function gradientToLinearGradientCSS(gradient: GradientStops): string {
if (gradient.position.length === 1) {
return `linear-gradient(to right, ${colorToHexOptionalAlpha(gradient.color[0])} 0%, ${colorToHexOptionalAlpha(gradient.color[0])} 100%)`;
}
@@ -229,29 +214,29 @@ export function gradientToLinearGradientCSS(gradient: Gradient): string {
return `linear-gradient(to right, ${pieces})`;
}
export function gradientFirstColor(gradient: Gradient): Color | undefined {
export function gradientFirstColor(gradient: GradientStops): Color | undefined {
return gradient.color[0];
}
export function gradientLastColor(gradient: Gradient): Color | undefined {
export function gradientLastColor(gradient: GradientStops): 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;
export function fillChoiceColor(value: FillChoice): Color | undefined {
if (typeof value === "object" && "Solid" in value) return value.Solid;
return undefined;
}
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 };
}
export function fillChoiceGradientStops(value: FillChoice): GradientStops | undefined {
if (typeof value === "object" && "Gradient" in value) return value.Gradient;
return undefined;
}
const solid = value["Solid"];
if (solid) return createColor(solid.red, solid.green, solid.blue, solid.alpha);
return createNoneColor();
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";
}
+14 -12
View File
@@ -18,16 +18,22 @@ export function downloadFileBlob(filename: string, blob: Blob) {
URL.revokeObjectURL(url);
}
export function downloadFile(filename: string, content: ArrayBuffer) {
export function downloadFile(filename: string, content: Uint8Array) {
const type = filename.endsWith(".svg") ? "image/svg+xml;charset=utf-8" : "application/octet-stream";
const blob = new Blob([new Uint8Array(content)], { type });
downloadFileBlob(filename, blob);
if (content.length > 0 && content.buffer instanceof ArrayBuffer) {
const contentView = new Uint8Array(content.buffer, content.byteOffset, content.byteLength);
const blob = new Blob([contentView], { type });
downloadFileBlob(filename, blob);
}
}
// See https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/file#accept for the `accept` string format
export async function upload<T extends "text" | "data" | "both">(accept: string, textOrData: T): Promise<UploadResult<T>> {
return new Promise<UploadResult<T>>((resolve, _) => {
export async function upload(accept: string, textOrData: "text"): Promise<UploadResult<string>>;
export async function upload(accept: string, textOrData: "data"): Promise<UploadResult<Uint8Array>>;
export async function upload(accept: string, textOrData: "both"): Promise<UploadResult<{ text: string; data: Uint8Array }>>;
export async function upload(accept: string, textOrData: "text" | "data" | "both"): Promise<UploadResult<string | Uint8Array | { text: string; data: Uint8Array }>> {
return new Promise((resolve) => {
const element = document.createElement("input");
element.type = "file";
element.accept = accept;
@@ -40,15 +46,12 @@ export async function upload<T extends "text" | "data" | "both">(accept: string,
const filename = file.name;
const type = file.type;
const content = (
const content =
textOrData === "text"
? await file.text()
: textOrData === "data"
? new Uint8Array(await file.arrayBuffer())
: textOrData === "both"
? { text: await file.text(), data: new Uint8Array(await file.arrayBuffer()) }
: undefined
) as UploadResultType<T>;
: { text: await file.text(), data: new Uint8Array(await file.arrayBuffer()) };
resolve({ filename, type, content });
}
@@ -61,8 +64,7 @@ export async function upload<T extends "text" | "data" | "both">(accept: string,
// Once `element` goes out of scope, it has no references so it gets garbage collected along with its event listener, so `removeEventListener` is not needed
});
}
export type UploadResult<T> = { filename: string; type: string; content: UploadResultType<T> };
type UploadResultType<T> = T extends "text" ? string : T extends "data" ? Uint8Array : T extends "both" ? { text: string; data: Uint8Array } : never;
export type UploadResult<T> = { filename: string; type: string; content: T };
export async function pasteFile(item: DataTransferItem, editor: Editor, mouse?: [number, number], insertParentId?: bigint, insertIndex?: number) {
const file = item.getAsFile();
@@ -94,10 +94,8 @@ export async function getLocalizedScanCode(e: KeyboardEvent): Promise<string> {
// It is likely a weird symbol that isn't in the A-Z range even with accents removed.
// It might be a symbol from an Option key combination on a Mac. Or it might be from a non-Latin alphabet like Cyrillic.
if (!KEY_ATTRIBUTE_VALUES.has(keyText)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (navigator && "keyboard" in navigator && "getLayoutMap" in (navigator as any).keyboard) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const layout = await (navigator as any).keyboard.getLayoutMap();
if (navigator.keyboard && "getLayoutMap" in navigator.keyboard) {
const layout = await navigator.keyboard.getLayoutMap();
type KeyCode = string;
type KeySymbol = string;
@@ -377,7 +375,7 @@ const LOCALE_SPECIFIC_KEY_CODES = LOCALE_SPECIFIC_KEY_CODES_INFO.map((info) => i
const WRITING_SYSTEM_SPECIAL_CHARS = Object.values(KEY_CODES)
.filter((info) => info.category === "writing-system")
.flatMap((info) => info.keys?.us?.split(" "))
.filter((character) => character && !/[a-zA-Z0-9]/.test(character)) as string[];
.filter((character): character is string => (!character ? false : !/[a-zA-Z0-9]/.test(character)));
const KEY_ATTRIBUTE_VALUES_INVOLVING_HANDEDNESS = ["Control", "Meta", "Shift"];
const KEY_ATTRIBUTE_VALUES = new Set([
@@ -1,5 +1,3 @@
import { isPlatformNative } from "@graphite/../wasm/pkg/graphite_wasm";
export function browserVersion(): string {
const agent = window.navigator.userAgent;
let match = agent.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || [];
@@ -37,24 +35,3 @@ export function operatingSystem(): OperatingSystem {
const userAgentOS = Object.keys(osTable).find((key) => window.navigator.userAgent.includes(key));
return osTable[userAgentOS || "Windows"];
}
export function isDesktop(): boolean {
return isPlatformNative();
}
export function isEventSupported(eventName: string) {
const onEventName = `on${eventName}`;
let tag = "div";
if (["select", "change"].includes(eventName)) tag = "select";
if (["submit", "reset"].includes(eventName)) tag = "form";
if (["error", "load", "abort"].includes(eventName)) tag = "img";
const element = document.createElement(tag);
if (onEventName in element) return true;
// Check if "return;" gets converted into a function, meaning the event is supported
element.setAttribute(eventName, "return;");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return typeof (element as Record<string, any>)[onEventName] === "function";
}
+2 -1
View File
@@ -11,7 +11,8 @@ export function setupViewportResizeObserver(editor: Editor) {
const viewports = Array.from(window.document.querySelectorAll("[data-viewport-container]"));
if (viewports.length <= 0) return;
const viewport = viewports[0] as HTMLElement;
const viewport = viewports[0];
if (!(viewport instanceof HTMLElement)) return;
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
+24 -98
View File
@@ -1,68 +1,36 @@
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");
});
}
import type { Layout, LayoutGroup, WidgetDiff, WidgetInstance } from "@graphite/../wasm/pkg/graphite_wasm";
type UIItem = Layout | LayoutGroup | WidgetInstance[] | WidgetInstance;
// 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) => {
// Extract the actual content from the DiffUpdate tagged enum
const { newValue } = update;
let newContent: Layout | LayoutGroup | WidgetInstance;
if ("layout" in newValue) newContent = newValue.layout;
else if ("layoutGroup" in newValue) newContent = newValue.layoutGroup;
else if ("widget" in newValue) newContent = newValue.widget;
else throw new Error("DiffUpdate invalid");
// 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];
const diffObject = update.widgetPath.reduce((targetLayout, index: bigint): UIItem | undefined => {
const i = Number(index);
if (targetLayout && "Column" in targetLayout) return targetLayout.Column.columnWidgets[i];
if (targetLayout && "Row" in targetLayout) return targetLayout.Row.rowWidgets[i];
if (targetLayout && "Table" in targetLayout) return targetLayout.Table.tableWidgets[i];
if (targetLayout && "Section" in targetLayout) return targetLayout.Section.layout[i];
if (targetLayout && "widget" in targetLayout && "widgetId" in targetLayout) {
if ("PopoverButton" in targetLayout.widget && targetLayout.widget.PopoverButton.popoverLayout) {
return targetLayout.widget.PopoverButton.popoverLayout[i];
}
// eslint-disable-next-line no-console
console.error("Tried to index widget");
return targetLayout;
}
return targetLayout?.[index];
}, layout as UIItem);
return targetLayout?.[i];
}, layout);
// 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
@@ -79,53 +47,11 @@ export function patchLayout(layout: /* &mut */ Layout, diffs: WidgetDiff[]) {
diffObject.length = 0;
}
// Remove all of the keys from the old object
Object.keys(diffObject).forEach((key) => delete (diffObject as Record<string, unknown>)[key]);
Object.keys(diffObject).forEach((key) => Reflect.deleteProperty(diffObject, 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);
Object.assign(diffObject, newContent);
});
}
// 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");
}