/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable max-classes-per-file */ import { Transform, Type, plainToClass } from "class-transformer"; import { type PopoverButtonStyle, type IconName, type IconSize } from "@graphite/utility-functions/icons"; import { type EditorHandle } from "@graphite-frontend/wasm/pkg/graphite_wasm.js"; export class JsMessage { // The marker provides a way to check if an object is a sub-class constructor for a jsMessage. static readonly jsMessageMarker = true; } const TupleToVec2 = Transform(({ value }: { value: [number, number] | undefined }) => (value === undefined ? undefined : { x: value[0], y: value[1] })); const ImportsToVec2Array = Transform(({ obj: { imports } }: { obj: { imports: [FrontendGraphOutput, number, number][] } }) => imports.map(([outputMetadata, x, y]) => ({ outputMetadata, position: { x, y } })), ); const ExportsToVec2Array = Transform(({ obj: { exports } }: { obj: { exports: [FrontendGraphInput, number, number][] } }) => exports.map(([inputMetadata, x, y]) => ({ inputMetadata, position: { x, y } })), ); // const BigIntTupleToVec2 = Transform(({ value }: { value: [bigint, bigint] | undefined }) => (value === undefined ? undefined : { x: Number(value[0]), y: Number(value[1]) })); export type XY = { x: number; y: number }; // ============================================================================ // Add additional classes below to replicate Rust's `FrontendMessage`s and data structures. // // Remember to add each message to the `messageConstructors` export at the bottom of the file. // // Read class-transformer docs at https://github.com/typestack/class-transformer#table-of-contents // for details about how to transform the JSON from wasm-bindgen into classes. // ============================================================================ export class UpdateBox extends JsMessage { readonly box!: Box | undefined; } export class UpdateClickTargets extends JsMessage { readonly clickTargets!: FrontendClickTargets | undefined; } const ContextTupleToVec2 = Transform((data) => { if (data.obj.contextMenuInformation === undefined) return undefined; const contextMenuCoordinates = { x: data.obj.contextMenuInformation.contextMenuCoordinates[0], y: data.obj.contextMenuInformation.contextMenuCoordinates[1] }; let contextMenuData = data.obj.contextMenuInformation.contextMenuData; if (contextMenuData.ToggleLayer !== undefined) { contextMenuData = { nodeId: contextMenuData.ToggleLayer.nodeId, currentlyIsNode: contextMenuData.ToggleLayer.currentlyIsNode }; } else if (contextMenuData.CreateNode !== undefined) { contextMenuData = { type: "CreateNode", compatibleType: contextMenuData.CreateNode.compatibleType }; } return { contextMenuCoordinates, contextMenuData }; }); export class UpdateContextMenuInformation extends JsMessage { @ContextTupleToVec2 readonly contextMenuInformation!: ContextMenuInformation | undefined; } export class UpdateImportsExports extends JsMessage { @ImportsToVec2Array readonly imports!: { outputMetadata: FrontendGraphOutput; position: XY }[]; @ExportsToVec2Array readonly exports!: { inputMetadata: FrontendGraphInput; position: XY }[]; @TupleToVec2 readonly addImport!: XY | undefined; @TupleToVec2 readonly addExport!: XY | undefined; } export class UpdateInSelectedNetwork extends JsMessage { readonly inSelectedNetwork!: boolean; } export class UpdateImportReorderIndex extends JsMessage { readonly importIndex!: number | undefined; } export class UpdateExportReorderIndex extends JsMessage { readonly exportIndex!: number | undefined; } const LayerWidths = Transform(({ obj }) => obj.layerWidths); const ChainWidths = Transform(({ obj }) => obj.chainWidths); const HasLeftInputWire = Transform(({ obj }) => obj.hasLeftInputWire); export class UpdateLayerWidths extends JsMessage { @LayerWidths readonly layerWidths!: Map; @ChainWidths readonly chainWidths!: Map; @HasLeftInputWire readonly hasLeftInputWire!: Map; } export class UpdateNodeGraph extends JsMessage { @Type(() => FrontendNode) readonly nodes!: FrontendNode[]; @Type(() => FrontendNodeWire) readonly wires!: FrontendNodeWire[]; readonly wiresDirectNotGridAligned!: boolean; } export class UpdateNodeGraphTransform extends JsMessage { readonly transform!: NodeGraphTransform; } const NodeDescriptions = Transform(({ obj }) => new Map(obj.nodeDescriptions)); export class SendUIMetadata extends JsMessage { @NodeDescriptions readonly nodeDescriptions!: Map; @Type(() => FrontendNode) readonly nodeTypes!: FrontendNodeType[]; } export class UpdateNodeThumbnail extends JsMessage { readonly id!: bigint; readonly value!: string; } export class UpdateNodeGraphSelection extends JsMessage { @Type(() => BigInt) readonly selected!: bigint[]; } export class UpdateOpenDocumentsList extends JsMessage { @Type(() => FrontendDocumentDetails) readonly openDocuments!: FrontendDocumentDetails[]; } export class UpdateWirePathInProgress extends JsMessage { readonly wirePath!: WirePath | undefined; } // Allows the auto save system to use a string for the id rather than a BigInt. // IndexedDb does not allow for BigInts as primary keys. // TypeScript does not allow subclasses to change the type of class variables in subclasses. // It is an abstract class to point out that it should not be instantiated directly. export abstract class DocumentDetails { readonly name!: string; readonly isAutoSaved!: boolean; readonly isSaved!: boolean; // This field must be provided by the subclass implementation // readonly id!: bigint | string; get displayName(): string { return `${this.name}${this.isSaved ? "" : "*"}`; } } export class FrontendDocumentDetails extends DocumentDetails { readonly id!: bigint; } export class Box { readonly startX!: number; readonly startY!: number; readonly endX!: number; readonly endY!: number; } export type FrontendClickTargets = { readonly nodeClickTargets: string[]; readonly layerClickTargets: string[]; readonly portClickTargets: string[]; readonly iconClickTargets: string[]; readonly allNodesBoundingBox: string; readonly importExportsBoundingBox: string; readonly modifyImportExport: string[]; }; export type ContextMenuInformation = { contextMenuCoordinates: XY; contextMenuData: "CreateNode" | { type: "CreateNode"; compatibleType: string } | { nodeId: bigint; currentlyIsNode: boolean }; }; export type FrontendGraphDataType = "General" | "Raster" | "VectorData" | "Number" | "Group" | "Artboard"; export class Node { readonly index!: bigint; // Omitted if this Node is an Import or Export to/from the node network readonly nodeId?: bigint; } const CreateOutputConnectorOptional = Transform(({ obj }) => { if (obj.connectedTo == undefined) { return undefined; } if (obj.connectedTo?.export !== undefined) { return { index: obj.connectedTo?.export }; } else if (obj.connectedTo?.import !== undefined) { return { index: obj.connectedTo?.import }; } else { if (obj.connectedTo?.node.inputIndex !== undefined) { return { nodeId: obj.connectedTo?.node.nodeId, index: obj.connectedTo?.node.inputIndex }; } else { return { nodeId: obj.connectedTo?.node.nodeId, index: obj.connectedTo?.node.outputIndex }; } } }); export class FrontendGraphInput { readonly dataType!: FrontendGraphDataType; readonly name!: string; readonly description!: string; readonly resolvedType!: string | undefined; readonly validTypes!: string[]; @CreateOutputConnectorOptional connectedTo!: Node | undefined; } const CreateInputConnectorArray = Transform(({ obj }) => { const newInputConnectors: Node[] = []; obj.connectedTo.forEach((connector: any) => { if (connector.export !== undefined) { newInputConnectors.push({ index: connector.export }); } else if (connector.import !== undefined) { newInputConnectors.push({ index: connector.import }); } else { if (connector.node.inputIndex !== undefined) { newInputConnectors.push({ nodeId: connector.node.nodeId, index: connector.node.inputIndex }); } else { newInputConnectors.push({ nodeId: connector.node.nodeId, index: connector.node.outputIndex }); } } }); return newInputConnectors; }); export class FrontendGraphOutput { readonly dataType!: FrontendGraphDataType; readonly name!: string; readonly description!: string; readonly resolvedType!: string | undefined; @CreateInputConnectorArray connectedTo!: Node[]; } export class FrontendNode { readonly isLayer!: boolean; readonly canBeLayer!: boolean; readonly id!: bigint; readonly reference!: string | undefined; readonly displayName!: string; @Type(() => FrontendGraphInput) readonly primaryInput!: FrontendGraphInput | undefined; @Type(() => FrontendGraphInput) readonly exposedInputs!: FrontendGraphInput[]; @Type(() => FrontendGraphOutput) readonly primaryOutput!: FrontendGraphOutput | undefined; @Type(() => FrontendGraphOutput) readonly exposedOutputs!: FrontendGraphOutput[]; @TupleToVec2 readonly position!: XY | undefined; // TODO: Store field for the width of the left node chain readonly previewed!: boolean; readonly visible!: boolean; readonly unlocked!: boolean; readonly errors!: string | undefined; readonly uiOnly!: boolean; } const CreateOutputConnector = Transform(({ obj }) => { if (obj.wireStart.export !== undefined) { return { index: obj.wireStart.export }; } else if (obj.wireStart.import !== undefined) { return { index: obj.wireStart.import }; } else { if (obj.wireStart.node.inputIndex !== undefined) { return { nodeId: obj.wireStart.node.nodeId, index: obj.wireStart.node.inputIndex }; } else { return { nodeId: obj.wireStart.node.nodeId, index: obj.wireStart.node.outputIndex }; } } }); const CreateInputConnector = Transform(({ obj }) => { if (obj.wireEnd.export !== undefined) { return { index: obj.wireEnd.export }; } else if (obj.wireEnd.import !== undefined) { return { index: obj.wireEnd.import }; } else { if (obj.wireEnd.node.inputIndex !== undefined) { return { nodeId: obj.wireEnd.node.nodeId, index: obj.wireEnd.node.inputIndex }; } else { return { nodeId: obj.wireEnd.node.nodeId, index: obj.wireEnd.node.outputIndex }; } } }); export class FrontendNodeWire { @CreateOutputConnector readonly wireStart!: Node; @CreateInputConnector readonly wireEnd!: Node; readonly dashed!: boolean; } export class FrontendNodeType { readonly name!: string; readonly category!: string; readonly inputTypes!: string[]; } export class NodeGraphTransform { readonly scale!: number; readonly x!: number; readonly y!: number; } export class WirePath { readonly pathString!: string; readonly dataType!: FrontendGraphDataType; readonly thick!: boolean; readonly dashed!: boolean; } export class IndexedDbDocumentDetails extends DocumentDetails { @Transform(({ value }: { value: bigint }) => value.toString()) id!: string; } export class TriggerIndexedDbWriteDocument extends JsMessage { document!: string; @Type(() => IndexedDbDocumentDetails) details!: IndexedDbDocumentDetails; version!: string; } export class TriggerIndexedDbRemoveDocument extends JsMessage { // Use a string since IndexedDB can not use BigInts for keys @Transform(({ value }: { value: bigint }) => value.toString()) documentId!: string; } export class UpdateInputHints extends JsMessage { @Type(() => HintInfo) readonly hintData!: HintData; } export type HintData = HintGroup[]; export type HintGroup = HintInfo[]; export class HintInfo { readonly keyGroups!: LayoutKeysGroup[]; readonly keyGroupsMac!: LayoutKeysGroup[] | undefined; readonly mouse!: MouseMotion | undefined; readonly label!: string; readonly plus!: boolean; readonly slash!: boolean; } // Rust enum `Key` export type KeyRaw = string; // Serde converts a Rust `Key` enum variant into this format (via a custom serializer) with both the `Key` variant name (called `RawKey` in TS) and the localized `label` for the key export type Key = { key: KeyRaw; label: string }; export type LayoutKeysGroup = Key[]; export type ActionKeys = { keys: LayoutKeysGroup }; export type MouseMotion = string; // 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 { readonly stops!: { position: number; color: Color }[]; constructor(stops: { position: number; color: Color }[]) { this.stops = stops; } toLinearGradientCSS(): string { if (this.stops.length === 1) { return `linear-gradient(to right, ${this.stops[0].color.toHexOptionalAlpha()} 0%, ${this.stops[0].color.toHexOptionalAlpha()} 100%)`; } const pieces = this.stops.map((stop) => `${stop.color.toHexOptionalAlpha()} ${stop.position * 100}%`); return `linear-gradient(to right, ${pieces.join(", ")})`; } toLinearGradientCSSNoAlpha(): string { if (this.stops.length === 1) { return `linear-gradient(to right, ${this.stops[0].color.toHexNoAlpha()} 0%, ${this.stops[0].color.toHexNoAlpha()} 100%)`; } const pieces = this.stops.map((stop) => `${stop.color.toHexNoAlpha()} ${stop.position * 100}%`); return `linear-gradient(to right, ${pieces.join(", ")})`; } firstColor(): Color | undefined { return this.stops[0]?.color; } lastColor(): Color | undefined { return this.stops[this.stops.length - 1]?.color; } atIndex(index: number): { position: number; color: Color } | undefined { return this.stops[index]; } colorAtIndex(index: number): Color | undefined { return this.stops[index]?.color; } positionAtIndex(index: number): number | undefined { return this.stops[index]?.position; } } // 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(); constructor(none: "none"); constructor(hsva: HSVA); constructor(red: number, green: number, blue: number, alpha: number); 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; } } 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}`; } const canvas = document.createElement("canvas"); canvas.width = 1; canvas.height = 1; const context = canvas.getContext("2d"); 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 Color.fromCSS(`#${colorValue.trim()}`); return undefined; } context.fillRect(0, 0, 1, 1); const [r, g, b, a] = [...context.getImageData(0, 0, 1, 1).data]; return new Color(r / 255, g / 255, b / 255, a / 255); } 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; } lerp(other: Color, t: number): Color { return new Color(this.red * (1 - t) + other.red * t, this.green * (1 - t) + other.green * t, this.blue * (1 - t) + other.blue * t, this.alpha * (1 - t) + other.alpha * t); } toHexNoAlpha(): string | undefined { if (this.none) return undefined; 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"); return `#${r}${g}${b}`; } toHexOptionalAlpha(): string | undefined { if (this.none) return undefined; 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 class UpdateActiveDocument extends JsMessage { readonly documentId!: bigint; } export class DisplayDialogPanic extends JsMessage { readonly panicInfo!: string; } export class DisplayDialog extends JsMessage { readonly title!: string; readonly icon!: IconName; } export class UpdateDocumentArtwork extends JsMessage { readonly svg!: string; } export class UpdateDocumentScrollbars extends JsMessage { @TupleToVec2 readonly position!: XY; @TupleToVec2 readonly size!: XY; @TupleToVec2 readonly multiplier!: XY; } export class UpdateDocumentRulers extends JsMessage { @TupleToVec2 readonly origin!: XY; readonly spacing!: number; readonly interval!: number; readonly visible!: boolean; } export class UpdateEyedropperSamplingState extends JsMessage { @TupleToVec2 readonly mousePosition!: XY | undefined; readonly primaryColor!: string; readonly secondaryColor!: string; readonly setColorChoice!: "Primary" | "Secondary" | undefined; } const mouseCursorIconCSSNames = { Default: "default", None: "none", ZoomIn: "zoom-in", ZoomOut: "zoom-out", Grabbing: "grabbing", Crosshair: "crosshair", Text: "text", Move: "move", NSResize: "ns-resize", EWResize: "ew-resize", NESWResize: "nesw-resize", NWSEResize: "nwse-resize", Rotate: "custom-rotate", } as const; export type MouseCursor = keyof typeof mouseCursorIconCSSNames; export type MouseCursorIcon = (typeof mouseCursorIconCSSNames)[MouseCursor]; export class UpdateGraphViewOverlay extends JsMessage { open!: boolean; } export class UpdateGraphFadeArtwork extends JsMessage { readonly percentage!: number; } export class UpdateSpreadsheetState extends JsMessage { readonly open!: boolean; readonly node!: bigint | undefined; } export class UpdateMouseCursor extends JsMessage { @Transform(({ value }: { value: MouseCursor }) => mouseCursorIconCSSNames[value] || "alias") readonly cursor!: MouseCursorIcon; } export class TriggerLoadFirstAutoSaveDocument extends JsMessage {} export class TriggerLoadRestAutoSaveDocuments extends JsMessage {} export class TriggerLoadPreferences extends JsMessage {} export class TriggerFetchAndOpenDocument extends JsMessage { readonly name!: string; readonly filename!: string; } export class TriggerOpenDocument extends JsMessage {} export class TriggerImport extends JsMessage {} export class TriggerPaste extends JsMessage {} export class TriggerDelayedZoomCanvasToFitAll extends JsMessage {} export class TriggerDownloadImage extends JsMessage { readonly svg!: string; readonly name!: string; readonly mime!: string; @TupleToVec2 readonly size!: XY; } export class TriggerDownloadTextFile extends JsMessage { readonly document!: string; readonly name!: string; } export class TriggerSavePreferences extends JsMessage { readonly preferences!: Record; } export class TriggerSaveActiveDocument extends JsMessage { readonly documentId!: bigint; } export class DocumentChanged extends JsMessage {} export type DataBuffer = { pointer: bigint; length: bigint; }; export class UpdateDocumentLayerStructureJs extends JsMessage { readonly dataBuffer!: DataBuffer; } export class DisplayEditableTextbox extends JsMessage { readonly text!: string; readonly lineHeightRatio!: number; readonly fontSize!: number; @Type(() => Color) readonly color!: Color; readonly url!: string; readonly transform!: number[]; readonly maxWidth!: undefined | number; readonly maxHeight!: undefined | number; } export class DisplayEditableTextboxTransform extends JsMessage { readonly transform!: number[]; } export class DisplayRemoveEditableTextbox extends JsMessage {} export class UpdateDocumentLayerDetails extends JsMessage { @Type(() => LayerPanelEntry) readonly data!: LayerPanelEntry; } export class LayerPanelEntry { id!: bigint; name!: string; alias!: string; @Transform(({ value }: { value: string }) => value || undefined) tooltip!: string | undefined; inSelectedNetwork!: boolean; childrenAllowed!: boolean; childrenPresent!: boolean; expanded!: boolean; @Transform(({ value }: { value: bigint }) => Number(value)) depth!: number; visible!: boolean; parentsVisible!: boolean; unlocked!: boolean; parentsUnlocked!: boolean; parentId!: bigint | undefined; selected!: boolean; ancestorOfSelected!: boolean; descendantOfSelected!: boolean; clipped!: boolean; clippable!: boolean; } export class DisplayDialogDismiss extends JsMessage {} export class Font { fontFamily!: string; fontStyle!: string; } export class TriggerFontLoad extends JsMessage { @Type(() => Font) font!: Font; } export class TriggerVisitLink extends JsMessage { url!: string; } export class TriggerTextCommit extends JsMessage {} export class TriggerTextCopy extends JsMessage { readonly copyText!: string; } export class TriggerAboutGraphiteLocalizedCommitDate extends JsMessage { readonly commitDate!: string; } // WIDGET PROPS export abstract class WidgetProps { kind!: WidgetPropsNames; } export class CheckboxInput extends WidgetProps { checked!: boolean; disabled!: boolean; icon!: IconName; @Transform(({ value }: { value: string }) => value || undefined) tooltip!: string | undefined; forLabel!: bigint | undefined; } export class ColorInput extends WidgetProps { @Transform(({ value }) => { if (value instanceof Gradient) return value; const gradient = value["Gradient"]; if (gradient) { const stops = gradient.map(([position, color]: [number, color: { red: number; green: number; blue: number; alpha: number }]) => ({ position, color: new Color(color.red, color.green, color.blue, color.alpha), })); return new Gradient(stops); } if (value instanceof Color) return value; const solid = value["Solid"]; if (solid) { return new Color(solid.red, solid.green, solid.blue, solid.alpha); } return new Color("none"); }) value!: FillChoice; disabled!: boolean; allowNone!: boolean; // allowTransparency!: boolean; // TODO: Implement @Transform(({ value }: { value: string }) => value || undefined) tooltip!: string | undefined; } 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 contrast = (color: Color): number => { const colorLuminance = color.luminance() || 0; let rangeLuminance1 = range1.luminance() || 0; let rangeLuminance2 = range2.luminance() || 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; })(); return (1 - Math.min(distance / proximityRange, 1)) * (1 - (color.toHSV()?.s || 0)); }; if (value instanceof Gradient) { if (value.stops.length === 0) return 0; const first = contrast(value.stops[0].color); const last = contrast(value.stops[value.stops.length - 1].color); return Math.min(first, last); } return contrast(value); } type MenuEntryCommon = { label: string; icon?: IconName; shortcut?: ActionKeys; }; // The entry in the expanded menu or a sub-menu as received from the Rust backend export type MenuBarEntry = MenuEntryCommon & { action: Widget; children?: MenuBarEntry[][]; disabled?: boolean; }; // An entry in the all-encompassing MenuList component which defines all types of menus (which are spawned by widgets like `TextButton` and `DropdownInput`) export type MenuListEntry = MenuEntryCommon & { action?: () => void; children?: MenuListEntry[][]; value: string; shortcutRequiresLock?: boolean; disabled?: boolean; tooltip?: string; font?: URL; }; export class CurveManipulatorGroup { anchor!: [number, number]; handles!: [[number, number], [number, number]]; } export class Curve { manipulatorGroups!: CurveManipulatorGroup[]; firstHandle!: [number, number]; lastHandle!: [number, number]; } export class CurveInput extends WidgetProps { value!: Curve; disabled!: boolean; @Transform(({ value }: { value: string }) => value || undefined) tooltip!: string | undefined; } export class DropdownInput extends WidgetProps { entries!: MenuListEntry[][]; selectedIndex!: number | undefined; drawIcon!: boolean; interactive!: boolean; disabled!: boolean; @Transform(({ value }: { value: string }) => value || undefined) tooltip!: string | undefined; // Styling minWidth!: number; maxWidth!: number; } export class FontInput extends WidgetProps { fontFamily!: string; fontStyle!: string; isStyle!: boolean; disabled!: boolean; @Transform(({ value }: { value: string }) => value || undefined) tooltip!: string | undefined; } export class IconButton extends WidgetProps { icon!: IconName; hoverIcon!: IconName | undefined; size!: IconSize; disabled!: boolean; active!: boolean; @Transform(({ value }: { value: string }) => value || undefined) tooltip!: string | undefined; } export class IconLabel extends WidgetProps { icon!: IconName; disabled!: boolean; @Transform(({ value }: { value: string }) => value || undefined) tooltip!: string | undefined; } export class ImageButton extends WidgetProps { image!: IconName; @Transform(({ value }: { value: string }) => value || undefined) width!: string | undefined; @Transform(({ value }: { value: string }) => value || undefined) height!: string | undefined; @Transform(({ value }: { value: string }) => value || undefined) tooltip!: string | undefined; } export type NumberInputIncrementBehavior = "Add" | "Multiply" | "Callback" | "None"; export type NumberInputMode = "Increment" | "Range"; export class NumberInput extends WidgetProps { // Label label!: string | undefined; @Transform(({ value }: { value: string }) => value || undefined) tooltip!: string | undefined; // Disabled disabled!: boolean; // Value value!: number | undefined; min!: number | undefined; max!: number | undefined; isInteger!: boolean; // Number presentation displayDecimalPlaces!: number; unit!: string; unitIsHiddenWhenEditing!: boolean; // Mode behavior mode!: NumberInputMode; incrementBehavior!: NumberInputIncrementBehavior; step!: number; rangeMin!: number | undefined; rangeMax!: number | undefined; // Styling minWidth!: number; maxWidth!: number; } export class NodeCatalog extends WidgetProps { disabled!: boolean; } export class PopoverButton extends WidgetProps { style!: PopoverButtonStyle | undefined; menuDirection!: MenuDirection | undefined; icon!: IconName | undefined; disabled!: boolean; @Transform(({ value }: { value: string }) => value || undefined) tooltip!: string | undefined; // Body popoverLayout!: LayoutGroup[]; popoverMinWidth: number | undefined; } export type MenuDirection = "Top" | "Bottom" | "Left" | "Right" | "TopLeft" | "TopRight" | "BottomLeft" | "BottomRight" | "Center"; export type RadioEntryData = { value?: string; label?: string; icon?: IconName; tooltip?: string; // Callbacks action?: () => void; }; export type RadioEntries = RadioEntryData[]; export class RadioInput extends WidgetProps { entries!: RadioEntries; disabled!: boolean; selectedIndex!: number | undefined; minWidth!: number; } export type SeparatorDirection = "Horizontal" | "Vertical"; export type SeparatorType = "Related" | "Unrelated" | "Section"; export class Separator extends WidgetProps { direction!: SeparatorDirection; type!: SeparatorType; } export class WorkingColorsInput extends WidgetProps { @Type(() => Color) primary!: Color; @Type(() => Color) secondary!: Color; } export class TextAreaInput extends WidgetProps { value!: string; label!: string | undefined; disabled!: boolean; @Transform(({ value }: { value: string }) => value || undefined) tooltip!: string | undefined; } export class ParameterExposeButton extends WidgetProps { exposed!: boolean; dataType!: FrontendGraphDataType; @Transform(({ value }: { value: string }) => value || undefined) tooltip!: string | undefined; } export class TextButton extends WidgetProps { label!: string; icon!: IconName | undefined; hoverIcon!: IconName | undefined; emphasized!: boolean; flush!: boolean; minWidth!: number; disabled!: boolean; @Transform(({ value }: { value: string }) => value || undefined) tooltip!: string | undefined; menuListChildren!: MenuListEntry[][]; } export type TextButtonWidget = { tooltip?: string; message?: string | object; callback?: () => void; props: { kind: "TextButton"; label: string; icon?: IconName; emphasized?: boolean; flush?: boolean; minWidth?: number; disabled?: boolean; tooltip?: string; // Callbacks // `action` is used via `IconButtonWidget.callback` }; }; export class BreadcrumbTrailButtons extends WidgetProps { labels!: string[]; disabled!: boolean; @Transform(({ value }: { value: string }) => value || undefined) tooltip!: string | undefined; } export class TextInput extends WidgetProps { value!: string; label!: string | undefined; disabled!: boolean; minWidth!: number; @Transform(({ value }: { value: string }) => value || undefined) tooltip!: string | undefined; } export class TextLabel extends WidgetProps { // Body value!: string; // Props disabled!: boolean; bold!: boolean; italic!: boolean; centerAlign!: boolean; tableAlign!: boolean; minWidth!: number; multiline!: boolean; @Transform(({ value }: { value: string }) => value || undefined) tooltip!: string | undefined; checkboxId!: bigint | undefined; } export type ReferencePoint = "None" | "TopLeft" | "TopCenter" | "TopRight" | "CenterLeft" | "Center" | "CenterRight" | "BottomLeft" | "BottomCenter" | "BottomRight"; export class ReferencePointInput extends WidgetProps { value!: ReferencePoint; disabled!: boolean; } // WIDGET const widgetSubTypes = [ { value: BreadcrumbTrailButtons, name: "BreadcrumbTrailButtons" }, { value: CheckboxInput, name: "CheckboxInput" }, { value: ColorInput, name: "ColorInput" }, { value: CurveInput, name: "CurveInput" }, { value: DropdownInput, name: "DropdownInput" }, { value: FontInput, name: "FontInput" }, { value: IconButton, name: "IconButton" }, { value: ImageButton, name: "ImageButton" }, { value: IconLabel, name: "IconLabel" }, { value: NodeCatalog, name: "NodeCatalog" }, { value: NumberInput, name: "NumberInput" }, { value: ParameterExposeButton, name: "ParameterExposeButton" }, { value: ReferencePointInput, name: "ReferencePointInput" }, { value: PopoverButton, name: "PopoverButton" }, { value: RadioInput, name: "RadioInput" }, { value: Separator, name: "Separator" }, { value: WorkingColorsInput, name: "WorkingColorsInput" }, { value: TextAreaInput, name: "TextAreaInput" }, { value: TextButton, name: "TextButton" }, { value: TextInput, name: "TextInput" }, { value: TextLabel, name: "TextLabel" }, ] as const; type WidgetSubTypes = (typeof widgetSubTypes)[number]; type WidgetKindMap = { [T in WidgetSubTypes as T["name"]]: InstanceType }; export type WidgetPropsNames = keyof WidgetKindMap; export type WidgetPropsSet = WidgetKindMap[WidgetPropsNames]; export function narrowWidgetProps(props: WidgetPropsSet, kind: K) { if (props.kind === kind) return props as WidgetKindMap[K]; else return undefined; } export class Widget { constructor(props: WidgetPropsSet, widgetId: bigint) { this.props = props; this.widgetId = widgetId; } @Type(() => WidgetProps, { discriminator: { property: "kind", subTypes: [...widgetSubTypes] }, keepDiscriminatorProperty: true }) props!: WidgetPropsSet; widgetId!: bigint; } // eslint-disable-next-line @typescript-eslint/no-explicit-any function hoistWidgetHolder(widgetHolder: any): Widget { const kind = Object.keys(widgetHolder.widget)[0]; const props = widgetHolder.widget[kind]; props.kind = kind; if (kind === "PopoverButton") { props.popoverLayout = props.popoverLayout.map(createLayoutGroup); } const { widgetId } = widgetHolder; return plainToClass(Widget, { props, widgetId }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any function hoistWidgetHolders(widgetHolders: any[]): Widget[] { return widgetHolders.map(hoistWidgetHolder); } // WIDGET LAYOUT export type WidgetLayout = { layoutTarget: unknown; layout: LayoutGroup[]; }; export class WidgetDiffUpdate extends JsMessage { layoutTarget!: unknown; // TODO: Replace `any` with correct typing // eslint-disable-next-line @typescript-eslint/no-explicit-any @Transform(({ value }: { value: any }) => createWidgetDiff(value)) diff!: WidgetDiff[]; } type UIItem = LayoutGroup[] | LayoutGroup | Widget | Widget[] | MenuBarEntry[] | MenuBarEntry; type WidgetDiff = { widgetPath: number[]; newValue: UIItem }; export function defaultWidgetLayout(): WidgetLayout { return { layoutTarget: undefined, layout: [], }; } // Updates a widget layout based on a list of updates, giving the new layout by mutating the `layout` argument export function patchWidgetLayout(layout: /* &mut */ WidgetLayout, updates: WidgetDiffUpdate) { layout.layoutTarget = updates.layoutTarget; updates.diff.forEach((update) => { // Find the object where the diff applies to const diffObject = update.widgetPath.reduce((targetLayout, index) => { if ("columnWidgets" in targetLayout) return targetLayout.columnWidgets[index]; if ("rowWidgets" in targetLayout) return targetLayout.rowWidgets[index]; if ("tableWidgets" in targetLayout) return targetLayout.tableWidgets[index]; if ("layout" in targetLayout) return targetLayout.layout[index]; if (targetLayout instanceof Widget) { if (targetLayout.props.kind === "PopoverButton" && targetLayout.props instanceof PopoverButton && targetLayout.props.popoverLayout) { return targetLayout.props.popoverLayout[index]; } // eslint-disable-next-line no-console console.error("Tried to index widget"); return targetLayout; } // This is a path traversal so we can assume from the backend that it exists // eslint-disable-next-line @typescript-eslint/no-non-null-assertion if ("action" in targetLayout) return targetLayout.children![index]; return targetLayout[index]; }, layout.layout as UIItem); // 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any Object.keys(diffObject).forEach((key) => delete (diffObject as any)[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); }); } export type LayoutGroup = WidgetSpanRow | WidgetSpanColumn | WidgetTable | WidgetSection; export type WidgetSpanColumn = { columnWidgets: Widget[] }; export function isWidgetSpanColumn(layoutColumn: LayoutGroup): layoutColumn is WidgetSpanColumn { return Boolean((layoutColumn as WidgetSpanColumn)?.columnWidgets); } export type WidgetSpanRow = { rowWidgets: Widget[] }; export function isWidgetSpanRow(layoutRow: LayoutGroup): layoutRow is WidgetSpanRow { return Boolean((layoutRow as WidgetSpanRow)?.rowWidgets); } export type WidgetTable = { tableWidgets: Widget[][] }; export function isWidgetTable(layoutTable: LayoutGroup): layoutTable is WidgetTable { return Boolean((layoutTable as WidgetTable)?.tableWidgets); } export type WidgetSection = { name: string; description: string; visible: boolean; pinned: boolean; id: bigint; layout: LayoutGroup[] }; export function isWidgetSection(layoutRow: LayoutGroup): layoutRow is WidgetSection { return Boolean((layoutRow as WidgetSection)?.layout); } // Unpacking rust types to more usable type in the frontend // eslint-disable-next-line @typescript-eslint/no-explicit-any function createWidgetDiff(diffs: any[]): WidgetDiff[] { return diffs.map((diff) => { const { widgetPath, newValue } = diff; if (newValue.subLayout) { return { widgetPath, newValue: newValue.subLayout.map(createLayoutGroup) }; } if (newValue.layoutGroup) { return { widgetPath, newValue: createLayoutGroup(newValue.layoutGroup) }; } if (newValue.widget) { return { widgetPath, newValue: hoistWidgetHolder(newValue.widget) }; } // This code should be unreachable throw new Error("DiffUpdate invalid"); }); } // Unpacking a layout group // eslint-disable-next-line @typescript-eslint/no-explicit-any function createLayoutGroup(layoutGroup: any): LayoutGroup { if (layoutGroup.column) { const columnWidgets = hoistWidgetHolders(layoutGroup.column.columnWidgets); const result: WidgetSpanColumn = { columnWidgets }; return result; } if (layoutGroup.row) { const result: WidgetSpanRow = { rowWidgets: hoistWidgetHolders(layoutGroup.row.rowWidgets) }; 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 = { tableWidgets: layoutGroup.table.tableWidgets.map(hoistWidgetHolders), }; return result; } throw new Error("Layout row type does not exist"); } // WIDGET LAYOUTS export class UpdateDialogButtons extends WidgetDiffUpdate {} export class UpdateDialogColumn1 extends WidgetDiffUpdate {} export class UpdateDialogColumn2 extends WidgetDiffUpdate {} export class UpdateDocumentBarLayout extends WidgetDiffUpdate {} export class UpdateDocumentModeLayout extends WidgetDiffUpdate {} export class UpdateLayersPanelControlBarLeftLayout extends WidgetDiffUpdate {} export class UpdateLayersPanelControlBarRightLayout extends WidgetDiffUpdate {} export class UpdateLayersPanelBottomBarLayout extends WidgetDiffUpdate {} // Extends JsMessage instead of WidgetDiffUpdate because the menu bar isn't diffed export class UpdateMenuBarLayout extends JsMessage { layoutTarget!: unknown; // TODO: Replace `any` with correct typing // eslint-disable-next-line @typescript-eslint/no-explicit-any @Transform(({ value }: { value: any }) => createMenuLayout(value)) layout!: MenuBarEntry[]; } export class UpdateNodeGraphControlBarLayout extends WidgetDiffUpdate {} export class UpdatePropertyPanelSectionsLayout extends WidgetDiffUpdate {} export class UpdateSpreadsheetLayout extends WidgetDiffUpdate {} export class UpdateToolOptionsLayout extends WidgetDiffUpdate {} export class UpdateToolShelfLayout extends WidgetDiffUpdate {} export class UpdateWorkingColorsLayout extends WidgetDiffUpdate {} // eslint-disable-next-line @typescript-eslint/no-explicit-any function createMenuLayout(menuBarEntry: any[]): MenuBarEntry[] { return menuBarEntry.map((entry) => ({ ...entry, children: createMenuLayoutRecursive(entry.children), })); } // eslint-disable-next-line @typescript-eslint/no-explicit-any function createMenuLayoutRecursive(children: any[][]): MenuBarEntry[][] { return children.map((groups) => groups.map((entry) => ({ ...entry, action: hoistWidgetHolders([entry.action])[0], children: entry.children ? createMenuLayoutRecursive(entry.children) : undefined, disabled: entry.disabled ?? false, })), ); } // `any` is used since the type of the object should be known from the Rust side // eslint-disable-next-line @typescript-eslint/no-explicit-any type JSMessageFactory = (data: any, wasm: WebAssembly.Memory, handle: EditorHandle) => JsMessage; type MessageMaker = typeof JsMessage | JSMessageFactory; export const messageMakers: Record = { DisplayDialog, DisplayDialogDismiss, DisplayDialogPanic, DisplayEditableTextbox, DisplayEditableTextboxTransform, DisplayRemoveEditableTextbox, SendUIMetadata, TriggerAboutGraphiteLocalizedCommitDate, TriggerDelayedZoomCanvasToFitAll, TriggerDownloadImage, TriggerDownloadTextFile, TriggerFetchAndOpenDocument, TriggerFontLoad, TriggerImport, TriggerIndexedDbRemoveDocument, TriggerIndexedDbWriteDocument, TriggerLoadFirstAutoSaveDocument, TriggerLoadPreferences, TriggerLoadRestAutoSaveDocuments, TriggerOpenDocument, TriggerPaste, TriggerSaveActiveDocument, TriggerSavePreferences, TriggerTextCommit, TriggerTextCopy, TriggerVisitLink, UpdateActiveDocument, UpdateBox, UpdateClickTargets, UpdateContextMenuInformation, UpdateDialogButtons, UpdateDialogColumn1, UpdateDialogColumn2, UpdateDocumentArtwork, UpdateDocumentBarLayout, UpdateDocumentLayerDetails, UpdateDocumentLayerStructureJs, UpdateDocumentModeLayout, UpdateDocumentRulers, UpdateDocumentScrollbars, UpdateExportReorderIndex, UpdateEyedropperSamplingState, UpdateGraphFadeArtwork, UpdateGraphViewOverlay, UpdateSpreadsheetState, UpdateImportReorderIndex, UpdateImportsExports, UpdateInputHints, UpdateInSelectedNetwork, UpdateLayersPanelControlBarLeftLayout, UpdateLayersPanelControlBarRightLayout, UpdateLayersPanelBottomBarLayout, UpdateLayerWidths, UpdateMenuBarLayout, UpdateMouseCursor, UpdateNodeGraph, UpdateNodeGraphControlBarLayout, UpdateNodeGraphSelection, UpdateNodeGraphTransform, UpdateNodeThumbnail, UpdateOpenDocumentsList, UpdatePropertyPanelSectionsLayout, UpdateSpreadsheetLayout, UpdateToolOptionsLayout, UpdateToolShelfLayout, UpdateWirePathInProgress, UpdateWorkingColorsLayout, } as const; export type JsMessageType = keyof typeof messageMakers;