File size: 5,228 Bytes
7312c42 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 | import React from 'react'
import { createRoot, Root } from 'react-dom/client'
import { CUSTOM_NODE_CLASS } from './constants'
import type {
ComfyApp,
DOMWidget,
DOMWidgetOptions,
InputSpec,
} from '@comfyorg/comfyui-frontend-types'
/**
* Structural interface for the subset of LGraphNode methods we use.
* LGraphNode is not exported from @comfyorg/comfyui-frontend-types, so we
* declare only what we need and cast to this type inside the factory.
*/
interface ComfyNode {
setDirtyCanvas(fg: boolean, bg?: boolean): void
addDOMWidget<T extends HTMLElement, V extends object | string>(
name: string,
type: string,
element: T,
options?: DOMWidgetOptions<V>,
): DOMWidget<T, V>
}
/** ComfyUI adds serializeValue to DOM widgets at runtime. */
type ComfyDOMWidget<T extends HTMLElement, V extends object | string> =
DOMWidget<T, V> & { serializeValue?: () => V }
export interface ReactWidgetProps<T extends object | string = object> {
value: T
onChange: (value: T) => void
/** The input name as declared in the node schema */
inputName: string
/** The LGraphNode instance this widget belongs to */
node: any
/** The DOMWidget instance for this widget */
widget: DOMWidget<HTMLDivElement, string>
/** The ComfyApp instance */
app: ComfyApp
}
export interface ReactWidgetOptions {
/** Default serialized value (JSON string) when the widget is first created */
defaultValue?: string
/** Custom height for the widget container in pixels */
height?: number
/** Keep this widget sized to its LiteGraph node despite legacy width writes. */
keepResponsiveWidthInLiteGraph?: boolean
/** Extra options merged into DOMWidgetOptions passed to addDOMWidget */
domWidgetOptions?: Omit<DOMWidgetOptions<string>, 'getValue' | 'setValue'>
}
interface LiteGraphRuntime {
vueNodesMode?: boolean
}
function isVueNodesMode(): boolean {
const liteGraph = (globalThis as typeof globalThis & { LiteGraph?: LiteGraphRuntime }).LiteGraph
return Boolean(liteGraph?.vueNodesMode)
}
function keepWidgetWidthResponsive(widget: DOMWidget<HTMLDivElement, string>) {
let width = widget.width
Object.defineProperty(widget, 'width', {
configurable: true,
enumerable: true,
get: () => width,
set: (nextWidth: number | undefined) => {
if (isVueNodesMode()) width = nextWidth
},
})
if (!isVueNodesMode()) width = undefined
}
/**
* Creates a ComfyUI custom widget factory that renders a React component
* inside a DOM widget slot.
*
* Usage in getCustomWidgets:
* return {
* TIMELINE: createReactWidget(TimelineWidget, { defaultValue: '' })
* }
*/
export function createReactWidget<T extends object | string = object>(
Component: React.ComponentType<ReactWidgetProps<T>>,
options: ReactWidgetOptions = {},
) {
// node is typed as `any` so this function is assignable to ComfyWidgetConstructor
// (LGraphNode is not exported from the package, and using a narrower structural
// type would break contravariant parameter compatibility).
return function widgetFactory(
node: any, // eslint-disable-line @typescript-eslint/no-explicit-any
inputName: string,
_inputData: InputSpec,
_app: ComfyApp,
): { widget: DOMWidget<HTMLDivElement, string> } {
// Always store the value as a JSON string so ComfyUI can serialize it directly.
let currentValue: string = options.defaultValue ?? ''
let root: Root | null = null
const container = document.createElement('div')
container.classList.add('comfyui-react-widget', CUSTOM_NODE_CLASS)
if (options.height !== undefined) {
container.style.height = `${options.height}px`
}
const comfyNode = node as ComfyNode
function parseValue(): T {
try {
return JSON.parse(currentValue) as T
} catch {
return currentValue as unknown as T
}
}
function render() {
root?.render(
React.createElement(Component, {
value: parseValue(),
onChange: (v: T) => {
currentValue = typeof v === 'string' ? v : JSON.stringify(v)
comfyNode.setDirtyCanvas(true, true)
render()
},
inputName,
widget,
node: comfyNode,
app: _app,
}),
)
}
const widget = comfyNode.addDOMWidget<HTMLDivElement, string>(
inputName,
'react-widget',
container,
{
getValue: () => currentValue,
setValue: (v: string) => {
currentValue = v
render()
},
getMinHeight: () => 30,
getMaxHeight: () => node.size[1],
hideOnZoom: true,
serialize: true,
...options.domWidgetOptions,
},
) as ComfyDOMWidget<HTMLDivElement, string>
if (options.keepResponsiveWidthInLiteGraph) {
keepWidgetWidthResponsive(widget)
}
// Keep compatibility with node reloaders that only recognize string DOM
// widgets through the legacy inputEl field.
widget.inputEl = container
// serializeValue is called by ComfyUI when building the API prompt payload
widget.serializeValue = () => currentValue
root = createRoot(container)
render()
return { widget }
}
}
|