mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-17 23:38:06 +08:00
* Add InputType based filtering capabilites to NodeCatalog. Send InputTypes through SendUiMetadata under odeTypes. Update NodeCatalog.svelte component to support ype based filtering. Update ContextMenuData to support compatibleType as an input to the searchTerm for the NodeCatalog. Update Graph.svelte component to support new ContextMenuData enum types. Send CompatibleType data from rust backend on wire drag and release to NodeCatalog to already show filtered data. * Add InputType based filtering capabilites to NodeCatalog. Send InputTypes through SendUiMetadata under odeTypes. Update NodeCatalog.svelte component to support ype based filtering. Update ContextMenuData to support compatibleType as an input to the searchTerm for the NodeCatalog. Update Graph.svelte component to support new ContextMenuData enum types. Send CompatibleType data from rust backend on wire drag and release to NodeCatalog to already show filtered data. * Open NodeCatalog on DoubleClick in empty node graph area * Capture Node implementations and filter out uncatogrised nodes before sending metadata. Update NodeCatalog Search filter to support single type search alongside name and category search * Take union of DocumentNodeTypes and registered node implementations, Update missing categories and make sure to remove nodes with empty categories * Code review --------- Co-authored-by: Keavon Chambers <keavon@keavon.com>
1699 lines
45 KiB
TypeScript
1699 lines
45 KiB
TypeScript
/* 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<bigint, number>;
|
|
@ChainWidths
|
|
readonly chainWidths!: Map<bigint, number>;
|
|
@HasLeftInputWire
|
|
readonly hasLeftInputWire!: Map<bigint, boolean>;
|
|
}
|
|
|
|
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 InputTypeDescriptions = Transform(({ obj }) => new Map(obj.inputTypeDescriptions));
|
|
const NodeDescriptions = Transform(({ obj }) => new Map(obj.nodeDescriptions));
|
|
|
|
export class SendUIMetadata extends JsMessage {
|
|
@InputTypeDescriptions
|
|
readonly inputTypeDescriptions!: Map<string, string>;
|
|
@NodeDescriptions
|
|
readonly nodeDescriptions!: Map<string, string>;
|
|
@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 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 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<string, unknown>;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// TODO: Eventually remove this document upgrade code
|
|
export class TriggerUpgradeDocumentToVectorManipulationFormat extends JsMessage {
|
|
readonly documentId!: bigint;
|
|
readonly documentName!: string;
|
|
readonly documentIsAutoSaved!: boolean;
|
|
readonly documentIsSaved!: boolean;
|
|
readonly documentSerializedContent!: 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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export class NodeCatalog extends WidgetProps {
|
|
disabled!: boolean;
|
|
}
|
|
|
|
export class PopoverButton extends WidgetProps {
|
|
style!: PopoverButtonStyle | undefined;
|
|
|
|
icon!: IconName | undefined;
|
|
|
|
disabled!: boolean;
|
|
|
|
@Transform(({ value }: { value: string }) => value || undefined)
|
|
tooltip!: string | undefined;
|
|
|
|
// Body
|
|
popoverLayout!: LayoutGroup[];
|
|
|
|
popoverMinWidth: number | undefined;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export type PivotPosition = "None" | "TopLeft" | "TopCenter" | "TopRight" | "CenterLeft" | "Center" | "CenterRight" | "BottomLeft" | "BottomCenter" | "BottomRight";
|
|
|
|
export class PivotInput extends WidgetProps {
|
|
position!: PivotPosition;
|
|
|
|
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: PivotInput, name: "PivotInput" },
|
|
{ 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<T["value"]> };
|
|
export type WidgetPropsNames = keyof WidgetKindMap;
|
|
export type WidgetPropsSet = WidgetKindMap[WidgetPropsNames];
|
|
|
|
export function narrowWidgetProps<K extends WidgetPropsNames>(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; 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,
|
|
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 UpdateLayersPanelControlBarLayout 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<string, MessageMaker> = {
|
|
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,
|
|
TriggerUpgradeDocumentToVectorManipulationFormat,
|
|
TriggerVisitLink,
|
|
UpdateActiveDocument,
|
|
UpdateBox,
|
|
UpdateClickTargets,
|
|
UpdateContextMenuInformation,
|
|
UpdateDialogButtons,
|
|
UpdateDialogColumn1,
|
|
UpdateDialogColumn2,
|
|
UpdateDocumentArtwork,
|
|
UpdateDocumentBarLayout,
|
|
UpdateDocumentLayerDetails,
|
|
UpdateDocumentLayerStructureJs,
|
|
UpdateDocumentModeLayout,
|
|
UpdateDocumentRulers,
|
|
UpdateDocumentScrollbars,
|
|
UpdateExportReorderIndex,
|
|
UpdateEyedropperSamplingState,
|
|
UpdateGraphFadeArtwork,
|
|
UpdateGraphViewOverlay,
|
|
UpdateSpreadsheetState,
|
|
UpdateImportReorderIndex,
|
|
UpdateImportsExports,
|
|
UpdateInputHints,
|
|
UpdateInSelectedNetwork,
|
|
UpdateLayersPanelControlBarLayout,
|
|
UpdateLayerWidths,
|
|
UpdateMenuBarLayout,
|
|
UpdateMouseCursor,
|
|
UpdateNodeGraph,
|
|
UpdateNodeGraphControlBarLayout,
|
|
UpdateNodeGraphSelection,
|
|
UpdateNodeGraphTransform,
|
|
UpdateNodeThumbnail,
|
|
UpdateOpenDocumentsList,
|
|
UpdatePropertyPanelSectionsLayout,
|
|
UpdateSpreadsheetLayout,
|
|
UpdateToolOptionsLayout,
|
|
UpdateToolShelfLayout,
|
|
UpdateWirePathInProgress,
|
|
UpdateWorkingColorsLayout,
|
|
} as const;
|
|
export type JsMessageType = keyof typeof messageMakers;
|