Clean up generic widget handling code in the frontend (#3856)

* Clean up WidgetSpan component code to avoid the giant if-chain for choosing a widget

* Improve typing support
This commit is contained in:
Keavon Chambers 2026-03-04 01:00:09 -08:00 committed by GitHub
parent 8a75c0c1e1
commit 5834ee9ce4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 194 additions and 147 deletions

View File

@ -2,8 +2,8 @@
import { getContext } from "svelte";
import type { Editor } from "@graphite/editor";
import type { LayoutTarget, WidgetInstance, WidgetSpanColumn, WidgetSpanRow } from "@graphite/messages";
import { narrowWidgetProps, isWidgetSpanColumn, isWidgetSpanRow } from "@graphite/messages";
import type { LayoutTarget, WidgetInstance, WidgetPropsNames, WidgetPropsSet, WidgetTypes, WidgetSpanColumn, WidgetSpanRow } from "@graphite/messages";
import { isWidgetSpanColumn, isWidgetSpanRow } from "@graphite/messages";
import { debouncer } from "@graphite/utility-functions/debounce";
import NodeCatalog from "@graphite/components/floating-menus/NodeCatalog.svelte";
@ -71,134 +71,189 @@
editor.handle.widgetValueCommitAndUpdate(layoutTarget, widgets[widgetIndex].widgetId, value, resendWidget);
}
// TODO: This seems to work, but verify the correctness and terseness of this, it's adapted from https://stackoverflow.com/a/67434028/775283
function exclude<T extends object>(props: T, additional?: (keyof T)[]): Omit<T, typeof additional extends Array<infer K> ? K : never> {
const exclusions = ["kind", ...(additional || [])];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return Object.fromEntries(Object.entries(props).filter((entry) => !exclusions.includes(entry[0]))) as any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function exclude(props: WidgetPropsSet, additional?: string[]): Record<string, any> {
const exclusions = new Set(["kind", ...(additional || [])]);
return Object.fromEntries(Object.entries(props).filter(([key]) => !exclusions.has(key)));
}
type WidgetConfig = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
component: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getProps(props: WidgetPropsSet, widgetIndex: number): Record<string, any> | undefined;
getSlotContent?(props: WidgetPropsSet): string;
};
const widgetRegistry: Record<WidgetPropsNames, WidgetConfig> = {
CheckboxInput: {
component: CheckboxInput,
getProps: (props: WidgetTypes["CheckboxInput"], index) => ({
...exclude(props),
$$events: { checked: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, true) },
}),
},
ColorInput: {
component: ColorInput,
getProps: (props: WidgetTypes["ColorInput"], index) => ({
...exclude(props),
$$events: {
value: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false),
startHistoryTransaction: () => widgetValueCommit(index, props.value),
},
}),
},
CurveInput: {
// TODO: CurvesInput is currently unused
component: CurveInput,
getProps: (props: WidgetTypes["CurveInput"], index) => ({
...exclude(props),
$$events: {
value: (e: CustomEvent) => debouncer((value: unknown) => widgetValueCommitAndUpdate(index, value, false), { debounceTime: 120 }).debounceUpdateValue(e.detail),
},
}),
},
DropdownInput: {
component: DropdownInput,
getProps: (props: WidgetTypes["DropdownInput"], index) => ({
...exclude(props),
$$events: {
hoverInEntry: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false),
hoverOutEntry: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false),
selectedIndex: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, true),
},
}),
},
ParameterExposeButton: {
component: ParameterExposeButton,
getProps: (props: WidgetTypes["ParameterExposeButton"], index) => ({
...exclude(props),
action: () => widgetValueCommitAndUpdate(index, undefined, true),
}),
},
IconButton: {
component: IconButton,
getProps: (props: WidgetTypes["IconButton"], index) => ({
...exclude(props),
action: () => widgetValueCommitAndUpdate(index, undefined, true),
}),
},
IconLabel: {
component: IconLabel,
getProps: (props: WidgetTypes["IconLabel"]) => exclude(props),
},
ShortcutLabel: {
component: ShortcutLabel,
getProps: (props: WidgetTypes["ShortcutLabel"]) => {
if (!props.shortcut) return undefined;
return exclude(props);
},
},
ImageLabel: {
component: ImageLabel,
getProps: (props: WidgetTypes["ImageLabel"]) => exclude(props),
},
ImageButton: {
component: ImageButton,
getProps: (props: WidgetTypes["ImageButton"], index) => ({
...exclude(props),
action: () => widgetValueCommitAndUpdate(index, undefined, true),
}),
},
NodeCatalog: {
component: NodeCatalog,
getProps: (props: WidgetTypes["NodeCatalog"], index) => ({
...exclude(props),
$$events: { selectNodeType: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, false) },
}),
},
NumberInput: {
component: NumberInput,
getProps: (props: WidgetTypes["NumberInput"], index) => ({
...exclude(props),
incrementCallbackIncrease: () => widgetValueCommitAndUpdate(index, "Increment", false),
incrementCallbackDecrease: () => widgetValueCommitAndUpdate(index, "Decrement", false),
$$events: {
value: (e: CustomEvent) => debouncer((value: unknown) => widgetValueUpdate(index, value, true)).debounceUpdateValue(e.detail),
startHistoryTransaction: () => widgetValueCommit(index, props.value),
},
}),
},
ReferencePointInput: {
component: ReferencePointInput,
getProps: (props: WidgetTypes["ReferencePointInput"], index) => ({
...exclude(props),
$$events: { value: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, true) },
}),
},
PopoverButton: {
component: PopoverButton,
getProps: (props: WidgetTypes["PopoverButton"]) => ({
...exclude(props),
layoutTarget,
}),
},
RadioInput: {
component: RadioInput,
getProps: (props: WidgetTypes["RadioInput"], index) => ({
...exclude(props),
$$events: { selectedIndex: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, true) },
}),
},
Separator: {
component: Separator,
getProps: (props: WidgetTypes["Separator"]) => exclude(props),
},
WorkingColorsInput: {
component: WorkingColorsInput,
getProps: (props: WidgetTypes["WorkingColorsInput"]) => exclude(props),
},
TextAreaInput: {
component: TextAreaInput,
getProps: (props: WidgetTypes["TextAreaInput"], index) => ({
...exclude(props),
$$events: { commitText: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, false) },
}),
},
TextButton: {
component: TextButton,
getProps: (props: WidgetTypes["TextButton"], index) => ({
...exclude(props),
action: () => widgetValueCommitAndUpdate(index, [], true),
$$events: { selectedEntryValuePath: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, false) },
}),
},
BreadcrumbTrailButtons: {
component: BreadcrumbTrailButtons,
getProps: (props: WidgetTypes["BreadcrumbTrailButtons"], index) => ({
...exclude(props),
action: (breadcrumbIndex: number) => widgetValueCommitAndUpdate(index, breadcrumbIndex, true),
}),
},
TextInput: {
component: TextInput,
getProps: (props: WidgetTypes["TextInput"], index) => ({
...exclude(props),
$$events: { commitText: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, true) },
}),
},
TextLabel: {
component: TextLabel,
getProps: (props: WidgetTypes["TextLabel"]) => exclude(props, ["value"]),
getSlotContent: (props: WidgetTypes["TextLabel"]) => props.value,
},
};
</script>
<!-- TODO: Refactor this component to use `<svelte:component this={attributesObject} />` to avoid all the separate conditional components -->
<div class={`widget-span ${className} ${extraClasses}`.trim()} class:narrow class:row={direction === "row"} class:column={direction === "column"}>
{#each widgets as component, widgetIndex}
{@const checkboxInput = narrowWidgetProps(component.props, "CheckboxInput")}
{#if checkboxInput}
<CheckboxInput {...exclude(checkboxInput)} on:checked={({ detail }) => widgetValueCommitAndUpdate(widgetIndex, detail, true)} />
{/if}
{@const colorInput = narrowWidgetProps(component.props, "ColorInput")}
{#if colorInput}
<ColorInput
{...exclude(colorInput)}
on:value={({ detail }) => widgetValueUpdate(widgetIndex, detail, false)}
on:startHistoryTransaction={() => widgetValueCommit(widgetIndex, colorInput.value)}
/>
{/if}
<!-- TODO: Curves Input is currently unused -->
{@const curvesInput = narrowWidgetProps(component.props, "CurveInput")}
{#if curvesInput}
<CurveInput
{...exclude(curvesInput)}
on:value={({ detail }) => debouncer((value) => widgetValueCommitAndUpdate(widgetIndex, value, false), { debounceTime: 120 }).debounceUpdateValue(detail)}
/>
{/if}
{@const dropdownInput = narrowWidgetProps(component.props, "DropdownInput")}
{#if dropdownInput}
<DropdownInput
{...exclude(dropdownInput)}
on:hoverInEntry={({ detail }) => {
return widgetValueUpdate(widgetIndex, detail, false);
}}
on:hoverOutEntry={({ detail }) => {
return widgetValueUpdate(widgetIndex, detail, false);
}}
on:selectedIndex={({ detail }) => widgetValueCommitAndUpdate(widgetIndex, detail, true)}
/>
{/if}
{@const parameterExposeButton = narrowWidgetProps(component.props, "ParameterExposeButton")}
{#if parameterExposeButton}
<ParameterExposeButton {...exclude(parameterExposeButton)} action={() => widgetValueCommitAndUpdate(widgetIndex, undefined, true)} />
{/if}
{@const iconButton = narrowWidgetProps(component.props, "IconButton")}
{#if iconButton}
<IconButton {...exclude(iconButton)} action={() => widgetValueCommitAndUpdate(widgetIndex, undefined, true)} />
{/if}
{@const iconLabel = narrowWidgetProps(component.props, "IconLabel")}
{#if iconLabel}
<IconLabel {...exclude(iconLabel)} />
{/if}
{@const shortcutLabel = narrowWidgetProps(component.props, "ShortcutLabel")}
{@const shortcutLabelShortcut = shortcutLabel?.shortcut ? { ...shortcutLabel, shortcut: shortcutLabel.shortcut } : undefined}
{#if shortcutLabel && shortcutLabelShortcut}
<ShortcutLabel {...exclude(shortcutLabelShortcut)} />
{/if}
{@const imageLabel = narrowWidgetProps(component.props, "ImageLabel")}
{#if imageLabel}
<ImageLabel {...exclude(imageLabel)} />
{/if}
{@const imageButton = narrowWidgetProps(component.props, "ImageButton")}
{#if imageButton}
<ImageButton {...exclude(imageButton)} action={() => widgetValueCommitAndUpdate(widgetIndex, undefined, true)} />
{/if}
{@const nodeCatalog = narrowWidgetProps(component.props, "NodeCatalog")}
{#if nodeCatalog}
<NodeCatalog {...exclude(nodeCatalog)} on:selectNodeType={(e) => widgetValueCommitAndUpdate(widgetIndex, e.detail, false)} />
{/if}
{@const numberInput = narrowWidgetProps(component.props, "NumberInput")}
{#if numberInput}
<NumberInput
{...exclude(numberInput)}
on:value={({ detail }) => debouncer((value) => widgetValueUpdate(widgetIndex, value, true)).debounceUpdateValue(detail)}
on:startHistoryTransaction={() => widgetValueCommit(widgetIndex, numberInput.value)}
incrementCallbackIncrease={() => widgetValueCommitAndUpdate(widgetIndex, "Increment", false)}
incrementCallbackDecrease={() => widgetValueCommitAndUpdate(widgetIndex, "Decrement", false)}
/>
{/if}
{@const referencePointInput = narrowWidgetProps(component.props, "ReferencePointInput")}
{#if referencePointInput}
<ReferencePointInput {...exclude(referencePointInput)} on:value={({ detail }) => widgetValueCommitAndUpdate(widgetIndex, detail, true)} />
{/if}
{@const popoverButton = narrowWidgetProps(component.props, "PopoverButton")}
{#if popoverButton}
<PopoverButton {...exclude(popoverButton)} {layoutTarget} />
{/if}
{@const radioInput = narrowWidgetProps(component.props, "RadioInput")}
{#if radioInput}
<RadioInput {...exclude(radioInput)} on:selectedIndex={({ detail }) => widgetValueCommitAndUpdate(widgetIndex, detail, true)} />
{/if}
{@const separator = narrowWidgetProps(component.props, "Separator")}
{#if separator}
<Separator {...exclude(separator)} />
{/if}
{@const workingColorsInput = narrowWidgetProps(component.props, "WorkingColorsInput")}
{#if workingColorsInput}
<WorkingColorsInput {...exclude(workingColorsInput)} />
{/if}
{@const textAreaInput = narrowWidgetProps(component.props, "TextAreaInput")}
{#if textAreaInput}
<TextAreaInput {...exclude(textAreaInput)} on:commitText={({ detail }) => widgetValueCommitAndUpdate(widgetIndex, detail, false)} />
{/if}
{@const textButton = narrowWidgetProps(component.props, "TextButton")}
{#if textButton}
<TextButton
{...exclude(textButton)}
action={() => widgetValueCommitAndUpdate(widgetIndex, [], true)}
on:selectedEntryValuePath={({ detail }) => widgetValueCommitAndUpdate(widgetIndex, detail, false)}
/>
{/if}
{@const breadcrumbTrailButtons = narrowWidgetProps(component.props, "BreadcrumbTrailButtons")}
{#if breadcrumbTrailButtons}
<BreadcrumbTrailButtons {...exclude(breadcrumbTrailButtons)} action={(breadcrumbIndex) => widgetValueCommitAndUpdate(widgetIndex, breadcrumbIndex, true)} />
{/if}
{@const textInput = narrowWidgetProps(component.props, "TextInput")}
{#if textInput}
<TextInput {...exclude(textInput)} on:commitText={({ detail }) => widgetValueCommitAndUpdate(widgetIndex, detail, true)} />
{/if}
{@const textLabel = narrowWidgetProps(component.props, "TextLabel")}
{#if textLabel}
<TextLabel {...exclude(textLabel, ["value"])}>{textLabel.value}</TextLabel>
{#each widgets as widget, widgetIndex}
{@const config = widgetRegistry[widget.props.kind]}
{@const props = config?.getProps(widget.props, widgetIndex)}
{@const slot = config?.getSlotContent?.(widget.props)}
{#if props !== undefined && slot !== undefined}
<svelte:component this={config.component} {...props}>{slot}</svelte:component>
{:else if props !== undefined}
<svelte:component this={config.component} {...props} />
{/if}
{/each}
</div>
@ -213,8 +268,8 @@
.widget-span.row {
flex: 0 0 auto;
display: flex;
--row-height: 32px;
min-height: var(--row-height);
--row-height: 32px;
&.narrow {
--row-height: 24px;

View File

@ -1,5 +1,3 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Transform, Type, plainToClass } from "class-transformer";
import { sampleInterpolatedGradient, type EditorHandle } from "@graphite/../wasm/pkg/graphite_wasm";
@ -971,7 +969,7 @@ export class ColorInput extends WidgetProps {
return new Gradient(
gradient.position,
gradient.midpoint,
gradient.color.map((color: any) => new Color(color.red, color.green, color.blue, color.alpha)),
gradient.color.map((color) => new Color(color.red, color.green, color.blue, color.alpha)),
);
}
@ -1460,14 +1458,9 @@ const widgetSubTypes = [
] 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 type WidgetTypes = { [T in WidgetSubTypes as T["name"]]: InstanceType<T["value"]> };
export type WidgetPropsNames = keyof WidgetTypes;
export type WidgetPropsSet = WidgetTypes[WidgetPropsNames];
export class WidgetInstance {
@Type(() => WidgetProps, { discriminator: { property: "kind", subTypes: [...widgetSubTypes] }, keepDiscriminatorProperty: true })
@ -1476,6 +1469,7 @@ export class WidgetInstance {
widgetId!: bigint;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function hoistWidgetInstance(widgetInstance: any): WidgetInstance {
const kind = Object.keys(widgetInstance.widget)[0];
const props = widgetInstance.widget[kind];
@ -1490,10 +1484,6 @@ function hoistWidgetInstance(widgetInstance: any): WidgetInstance {
return plainToClass(WidgetInstance, { props, widgetId });
}
function hoistWidgetInstances(widgetInstance: any[]): WidgetInstance[] {
return widgetInstance.map(hoistWidgetInstance);
}
// WIDGET LAYOUT
export type LayoutTarget =
@ -1516,7 +1506,6 @@ export type LayoutTarget =
| "WorkingColors";
export class WidgetDiffUpdate extends JsMessage {
// TODO: Replace `any` with correct typing
@Transform(({ value }: { value: WidgetDiff[] }) => {
// Unpacking rust types to more usable type in the frontend
return value.map((diff) => {
@ -1574,7 +1563,7 @@ export function patchLayout(layout: /* &mut */ Layout, updates: WidgetDiffUpdate
diffObject.length = 0;
}
// Remove all of the keys from the old object
Object.keys(diffObject).forEach((key) => delete (diffObject as any)[key]);
Object.keys(diffObject).forEach((key) => delete (diffObject as Record<string, unknown>)[key]);
// Assign keys to the new object
// `Object.assign` works but `diffObject = update.newValue;` doesn't.
@ -1607,16 +1596,17 @@ export function isWidgetSection(layoutRow: LayoutGroup): layoutRow is WidgetSect
}
// Unpacking a layout group
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function createLayoutGroup(layoutGroup: any): LayoutGroup {
if (layoutGroup.column) {
const columnWidgets = hoistWidgetInstances(layoutGroup.column.columnWidgets);
const columnWidgets = layoutGroup.column.columnWidgets.map(hoistWidgetInstance);
const result: WidgetSpanColumn = { columnWidgets };
return result;
}
if (layoutGroup.row) {
const result: WidgetSpanRow = { rowWidgets: hoistWidgetInstances(layoutGroup.row.rowWidgets) };
const result: WidgetSpanRow = { rowWidgets: layoutGroup.row.rowWidgets.map(hoistWidgetInstance) };
return result;
}
@ -1634,7 +1624,8 @@ function createLayoutGroup(layoutGroup: any): LayoutGroup {
if (layoutGroup.table) {
const result: WidgetTable = {
tableWidgets: layoutGroup.table.tableWidgets.map(hoistWidgetInstances),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tableWidgets: layoutGroup.table.tableWidgets.map((row: any) => row.map(hoistWidgetInstance)),
unstyled: layoutGroup.table.unstyled,
};
return result;
@ -1679,6 +1670,7 @@ export class UpdateToolShelfLayout extends WidgetDiffUpdate {}
export class UpdateWorkingColorsLayout extends WidgetDiffUpdate {}
// `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;