fix: promote DOM widget textareas to subgraph node via adapter

Replace ProxyWidget with PromotedWidgetSlot and add
PromotedDomWidgetAdapter to wrap interior DOM widgets for display on
the SubgraphNode. The adapter overrides `node` and `y` so
DomWidgets.vue positions the textarea on the parent graph.

Fix litegraph:set-graph handler in app.ts that was setting adapter
widget states to active=false because adapters are not in any node's
widgets array. Now checks if the widget's host node is in the new
graph before deactivating.

Export NodeId from layout/types.ts and fix NodeId-to-string
conversions at Yjs and Map boundaries across the layout system.

Amp-Thread-ID: https://ampcode.com/threads/T-019c547f-15bf-716a-8abc-278dc9106c16
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Alexander Brown
2026-02-12 17:28:21 -08:00
parent 5d0a6e2caa
commit ad4ee8dee0
13 changed files with 370 additions and 38 deletions

View File

@@ -0,0 +1,195 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type {
BaseDOMWidget,
ComponentWidget,
DOMWidgetOptions
} from '@/scripts/domWidget'
import { isDOMWidget, isComponentWidget } from '@/scripts/domWidget'
import { generateUUID } from '@/utils/formatUtil'
import type { PromotedWidgetSlot } from './PromotedWidgetSlot'
/**
* Adapts an interior DOM widget for display on a SubgraphNode.
*
* When a DOM widget is promoted to a subgraph node, `DomWidgets.vue` positions
* it using `widget.node.pos` and `widget.y`. This adapter overrides those to
* reference the SubgraphNode (host) and the PromotedWidgetSlot's y position,
* so the DOM element renders at the correct location on the parent graph.
*
* Only ONE of {adapter, interior widget} should be registered in
* `domWidgetStore` at a time. The adapter is registered on creation and the
* interior widget is deactivated. On dispose the interior is reactivated.
* `DomWidgets.vue` therefore only ever sees a single `DomWidget.vue` instance
* per shared `HTMLElement`, avoiding the "elementtheft" race condition that
* occurs when two instances try to `appendChild` the same element.
*/
export class PromotedDomWidgetAdapter<
V extends object | string
> implements BaseDOMWidget<V> {
// IBaseWidget requires a symbol index signature for Vue reactivity tracking.
[symbol: symbol]: boolean
readonly id = generateUUID()
private readonly inner: BaseDOMWidget<V>
private readonly hostNode: LGraphNode
private readonly slot: PromotedWidgetSlot
constructor(
inner: BaseDOMWidget<V>,
hostNode: LGraphNode,
slot: PromotedWidgetSlot
) {
this.inner = inner
this.hostNode = hostNode
this.slot = slot
}
get node(): LGraphNode {
return this.hostNode
}
get y(): number {
return this.slot.y
}
set y(_v: number) {
// Position is managed by the slot; ignore external writes.
}
get last_y(): number | undefined {
return this.slot.last_y
}
set last_y(_v: number | undefined) {
// Managed by the slot.
}
get name(): string {
return this.inner.name
}
get type(): string {
return this.inner.type
}
get options(): DOMWidgetOptions<V> {
return this.inner.options
}
get value(): V {
return this.inner.value
}
set value(v: V) {
this.inner.value = v
}
get promoted(): boolean {
return true
}
get margin(): number {
return this.inner.margin
}
get width(): number | undefined {
return (this.inner as IBaseWidget).width
}
get computedHeight(): number | undefined {
return this.slot.computedHeight
}
set computedHeight(v: number | undefined) {
this.slot.computedHeight = v
}
get computedDisabled(): boolean | undefined {
return (this.inner as IBaseWidget).computedDisabled
}
get hidden(): boolean | undefined {
return (this.inner as IBaseWidget).hidden
}
get serialize(): boolean | undefined {
return false
}
isVisible(): boolean {
return (
!this.hidden &&
this.hostNode.isWidgetVisible(this as unknown as IBaseWidget)
)
}
get callback(): BaseDOMWidget<V>['callback'] {
return this.inner.callback
}
set callback(v: BaseDOMWidget<V>['callback']) {
this.inner.callback = v
}
/** The interior DOM widget this adapter wraps. */
get innerWidget(): BaseDOMWidget<V> {
return this.inner
}
}
/**
* Expose the `element` property so `DomWidget.vue` can access it via
* the `isDOMWidget` type guard.
*/
Object.defineProperty(PromotedDomWidgetAdapter.prototype, 'element', {
get(this: PromotedDomWidgetAdapter<object | string>) {
const inner = this.innerWidget
if (isDOMWidget(inner)) return inner.element
return undefined
},
enumerable: true,
configurable: true
})
/**
* Expose the `component` property so `DomWidget.vue` can access it via
* the `isComponentWidget` type guard.
*/
Object.defineProperty(PromotedDomWidgetAdapter.prototype, 'component', {
get(this: PromotedDomWidgetAdapter<object | string>) {
const inner = this.innerWidget
if (isComponentWidget(inner)) return inner.component
return undefined
},
enumerable: true,
configurable: true
})
/**
* Expose the `inputSpec` property for component widgets.
*/
Object.defineProperty(PromotedDomWidgetAdapter.prototype, 'inputSpec', {
get(this: PromotedDomWidgetAdapter<object | string>) {
const inner = this.innerWidget
if (isComponentWidget(inner))
return (inner as ComponentWidget<object | string>).inputSpec
return undefined
},
enumerable: true,
configurable: true
})
/**
* Expose the `props` property for component widgets.
*/
Object.defineProperty(PromotedDomWidgetAdapter.prototype, 'props', {
get(this: PromotedDomWidgetAdapter<object | string>) {
const inner = this.innerWidget
if (isComponentWidget(inner))
return (inner as ComponentWidget<object | string>).props
return undefined
},
enumerable: true,
configurable: true
})

View File

@@ -8,11 +8,16 @@ import type {
} from '@/lib/litegraph/src/widgets/BaseWidget'
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import { isDOMWidget, isComponentWidget } from '@/scripts/domWidget'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { PromotedDomWidgetAdapter } from './PromotedDomWidgetAdapter'
type WidgetValue = IBaseWidget['value']
/**
@@ -35,6 +40,13 @@ export class PromotedWidgetSlot
readonly sourceWidgetName: string
private readonly subgraphNode: SubgraphNode
/**
* When the interior widget is a DOM widget, this adapter is registered in
* `domWidgetStore` so that `DomWidgets.vue` positions the DOM element on the
* SubgraphNode rather than the interior node.
*/
private domAdapter?: PromotedDomWidgetAdapter<object | string>
constructor(
subgraphNode: SubgraphNode,
sourceNodeId: NodeId,
@@ -75,6 +87,46 @@ export class PromotedWidgetSlot
if (!resolved) return
resolved.widget.callback?.(value, canvas, resolved.node, pos, e)
}
this.syncDomAdapter()
this.syncLayoutSize()
}
/**
* Delegates to the interior widget's `computeLayoutSize` so that
* `_arrangeWidgets` treats this slot as a growable widget (e.g. textarea)
* and allocates the correct height on the SubgraphNode.
*
* Assigned dynamically in the constructor via `syncLayoutSize` because
* `computeLayoutSize` is an optional method on the base class — it must
* either exist or not exist, not return `undefined`.
*/
declare computeLayoutSize?: (node: LGraphNode) => {
minHeight: number
maxHeight?: number
minWidth: number
maxWidth?: number
}
/**
* Copies `computeLayoutSize` from the interior widget when it has one
* (e.g. textarea / DOM widgets), so `_arrangeWidgets` allocates the
* correct growable height on the SubgraphNode.
*/
private syncLayoutSize(): void {
let resolved: ReturnType<PromotedWidgetSlot['resolve']>
try {
resolved = this.resolve()
} catch {
return
}
const interiorWidget = resolved?.widget
if (interiorWidget?.computeLayoutSize) {
this.computeLayoutSize = (node) => interiorWidget.computeLayoutSize!(node)
} else {
this.computeLayoutSize = undefined
}
}
private resolve(): {
@@ -164,7 +216,77 @@ export class PromotedWidgetSlot
return v != null ? String(v) : ''
}
/**
* Creates or removes the DOM adapter based on whether the resolved interior
* widget is a DOM widget. Call after construction and whenever the interior
* widget might change (e.g. reconnection).
*
* Only one of {adapter, interior widget} is active in `domWidgetStore` at a
* time. The adapter is registered and the interior is deactivated, so
* `DomWidgets.vue` never mounts two `DomWidget.vue` instances for the same
* `HTMLElement`.
*/
syncDomAdapter(): void {
// resolve() may fail during construction if the subgraph is not yet
// fully wired (e.g. in tests or during deserialization).
let resolved: ReturnType<PromotedWidgetSlot['resolve']>
try {
resolved = this.resolve()
} catch {
return
}
if (!resolved) return
const interiorWidget = resolved.widget
const isDom =
isDOMWidget(interiorWidget) || isComponentWidget(interiorWidget)
if (isDom && !this.domAdapter) {
const domWidget = interiorWidget as BaseDOMWidget<object | string>
const adapter = new PromotedDomWidgetAdapter(
domWidget,
this.subgraphNode,
this
)
this.domAdapter = adapter
const store = useDomWidgetStore()
// The adapter satisfies BaseDOMWidget but TypeScript cannot verify
// the IBaseWidget symbol index signature on plain classes.
// Start invisible — `updateWidgets()` will set `visible: true` on the
// first canvas draw when the SubgraphNode is in the current graph.
// This prevents a race where both adapter and interior DomWidget.vue
// instances try to mount the same HTMLElement during `onMounted`.
store.registerWidget(
adapter as unknown as BaseDOMWidget<object | string>,
{ visible: false }
)
} else if (!isDom && this.domAdapter) {
this.disposeDomAdapter()
}
}
/**
* Removes the DOM adapter from the store.
*/
disposeDomAdapter(): void {
if (!this.domAdapter) return
useDomWidgetStore().unregisterWidget(this.domAdapter.id)
this.domAdapter = undefined
}
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
// Lazily create the DOM adapter if it wasn't ready at construction time.
// During deserialization the interior widget may not exist yet when the
// PromotedWidgetSlot constructor runs, so syncDomAdapter() is retried here
// on every draw until it succeeds.
if (!this.domAdapter) {
this.syncDomAdapter()
this.syncLayoutSize()
}
const resolved = this.resolve()
if (!resolved) {
this.drawDisconnectedPlaceholder(ctx, options)

View File

@@ -64,7 +64,11 @@ function syncPromotedWidgets(
)
// Remove existing PromotedWidgetSlot instances and native widgets
// that will be re-ordered by the parsed list
// that will be re-ordered by the parsed list.
// Dispose DOM adapters on slots being removed.
for (const w of widgets) {
if (w instanceof PromotedWidgetSlot) w.disposeDomAdapter()
}
node.widgets = widgets.filter((w) => {
if (w instanceof PromotedWidgetSlot) return false
return !parsed.some(([, name]) => w.name === name)

View File

@@ -234,14 +234,14 @@ class LayoutStoreImpl implements LayoutStore {
return {
get: () => {
track()
const ynode = this.ynodes.get(nodeId)
const ynode = this.ynodes.get(String(nodeId))
const layout = ynode ? yNodeToLayout(ynode) : null
return layout
},
set: (newLayout: NodeLayout | null) => {
if (newLayout === null) {
// Delete operation
const existing = this.ynodes.get(nodeId)
const existing = this.ynodes.get(String(nodeId))
if (existing) {
this.applyOperation({
type: 'deleteNode',
@@ -255,7 +255,7 @@ class LayoutStoreImpl implements LayoutStore {
}
} else {
// Update operation - detect what changed
const existing = this.ynodes.get(nodeId)
const existing = this.ynodes.get(String(nodeId))
if (!existing) {
// Create operation
this.applyOperation({
@@ -685,7 +685,7 @@ class LayoutStoreImpl implements LayoutStore {
// Precise hit test only on candidates
for (const key of candidateKeys) {
const segmentLayout = this.linkSegmentLayouts.get(key)
const segmentLayout = this.linkSegmentLayouts.get(String(key))
if (!segmentLayout) continue
if (ctx && segmentLayout.path) {
@@ -748,7 +748,7 @@ class LayoutStoreImpl implements LayoutStore {
// Check precise bounds for candidates
for (const key of candidateSlotKeys) {
const slotLayout = this.slotLayouts.get(key)
const slotLayout = this.slotLayouts.get(String(key))
if (slotLayout && pointInBounds(point, slotLayout.bounds)) {
return slotLayout
}
@@ -812,7 +812,7 @@ class LayoutStoreImpl implements LayoutStore {
const segmentKeys = this.linkSegmentSpatialIndex.query(bounds)
const linkIds = new Set<LinkId>()
for (const key of segmentKeys) {
const segment = this.linkSegmentLayouts.get(key)
const segment = this.linkSegmentLayouts.get(String(key))
if (segment) {
linkIds.add(segment.linkId)
}
@@ -821,7 +821,7 @@ class LayoutStoreImpl implements LayoutStore {
return {
nodes: this.queryNodesInBounds(bounds),
links: Array.from(linkIds),
slots: this.slotSpatialIndex.query(bounds),
slots: this.slotSpatialIndex.query(bounds).map(String),
reroutes: this.rerouteSpatialIndex
.query(bounds)
.map((key) => asRerouteId(key))
@@ -1002,7 +1002,7 @@ class LayoutStoreImpl implements LayoutStore {
}
}
this.ynodes.set(layout.id, layoutToYNode(layout))
this.ynodes.set(String(layout.id), layoutToYNode(layout))
// Add to spatial index
this.spatialIndex.insert(layout.id, layout.bounds)
@@ -1018,7 +1018,7 @@ class LayoutStoreImpl implements LayoutStore {
operation: MoveNodeOperation,
change: LayoutChange
): void {
const ynode = this.ynodes.get(operation.nodeId)
const ynode = this.ynodes.get(String(operation.nodeId))
if (!ynode) {
return
}
@@ -1046,7 +1046,7 @@ class LayoutStoreImpl implements LayoutStore {
operation: ResizeNodeOperation,
change: LayoutChange
): void {
const ynode = this.ynodes.get(operation.nodeId)
const ynode = this.ynodes.get(String(operation.nodeId))
if (!ynode) return
const position = yNodeToLayout(ynode).position
@@ -1072,7 +1072,7 @@ class LayoutStoreImpl implements LayoutStore {
operation: SetNodeZIndexOperation,
change: LayoutChange
): void {
const ynode = this.ynodes.get(operation.nodeId)
const ynode = this.ynodes.get(String(operation.nodeId))
if (!ynode) return
ynode.set('zIndex', operation.zIndex)
@@ -1084,7 +1084,7 @@ class LayoutStoreImpl implements LayoutStore {
change: LayoutChange
): void {
const ynode = layoutToYNode(operation.layout)
this.ynodes.set(operation.nodeId, ynode)
this.ynodes.set(String(operation.nodeId), ynode)
// Add to spatial index
this.spatialIndex.insert(operation.nodeId, operation.layout.bounds)
@@ -1097,9 +1097,9 @@ class LayoutStoreImpl implements LayoutStore {
operation: DeleteNodeOperation,
change: LayoutChange
): void {
if (!this.ynodes.has(operation.nodeId)) return
if (!this.ynodes.has(String(operation.nodeId))) return
this.ynodes.delete(operation.nodeId)
this.ynodes.delete(String(operation.nodeId))
// Note: We intentionally do NOT delete nodeRefs and nodeTriggers here.
// During undo/redo, Vue components may still hold references to the old ref.
// If we delete the trigger, Vue won't be notified when the node is re-created.
@@ -1132,7 +1132,7 @@ class LayoutStoreImpl implements LayoutStore {
for (const nodeId of operation.nodeIds) {
const data = operation.bounds[nodeId]
const ynode = this.ynodes.get(nodeId)
const ynode = this.ynodes.get(String(nodeId))
if (!ynode || !data) continue
ynode.set('position', { x: data.bounds.x, y: data.bounds.y })
@@ -1440,7 +1440,7 @@ class LayoutStoreImpl implements LayoutStore {
const boundsRecord: BatchUpdateBoundsOperation['bounds'] = {}
for (const { nodeId, bounds } of updates) {
const ynode = this.ynodes.get(nodeId)
const ynode = this.ynodes.get(String(nodeId))
if (!ynode) continue
const currentLayout = yNodeToLayout(ynode)

View File

@@ -32,7 +32,7 @@ export function useLayoutSync() {
const layout = layoutStore.getNodeLayoutRef(nodeId).value
if (!layout) continue
const liteNode = canvas.graph?.getNodeById(parseInt(nodeId))
const liteNode = canvas.graph?.getNodeById(nodeId)
if (!liteNode) continue
if (

View File

@@ -7,6 +7,8 @@
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { ComputedRef, Ref } from 'vue'
export type { NodeId }
// Enum for layout source types
export enum LayoutSource {
Canvas = 'canvas',

View File

@@ -43,7 +43,7 @@ export class SpatialIndexManager {
* Insert a node into the spatial index
*/
insert(nodeId: NodeId, bounds: Bounds): void {
this.quadTree.insert(nodeId, bounds, nodeId)
this.quadTree.insert(String(nodeId), bounds, nodeId)
this.invalidateCache()
}
@@ -51,7 +51,7 @@ export class SpatialIndexManager {
* Update a node's bounds in the spatial index
*/
update(nodeId: NodeId, bounds: Bounds): void {
this.quadTree.update(nodeId, bounds)
this.quadTree.update(String(nodeId), bounds)
this.invalidateCache()
}
@@ -61,7 +61,7 @@ export class SpatialIndexManager {
*/
batchUpdate(updates: Array<{ nodeId: NodeId; bounds: Bounds }>): void {
for (const { nodeId, bounds } of updates) {
this.quadTree.update(nodeId, bounds)
this.quadTree.update(String(nodeId), bounds)
}
this.invalidateCache()
}
@@ -70,7 +70,7 @@ export class SpatialIndexManager {
* Remove a node from the spatial index
*/
remove(nodeId: NodeId): void {
this.quadTree.remove(nodeId)
this.quadTree.remove(String(nodeId))
this.invalidateCache()
}

View File

@@ -283,11 +283,11 @@ const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =
useNodeEventHandlers()
const { bringNodeToFront } = useNodeZIndex()
useVueElementTracking(() => nodeData.id, 'node')
useVueElementTracking(() => String(nodeData.id), 'node')
const { selectedNodeIds } = storeToRefs(useCanvasStore())
const isSelected = computed(() => {
return selectedNodeIds.value.has(nodeData.id)
return selectedNodeIds.value.has(String(nodeData.id))
})
const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
@@ -343,8 +343,10 @@ onErrorCaptured((error) => {
return false // Prevent error propagation
})
const { position, size, zIndex } = useNodeLayout(() => nodeData.id)
const { pointerHandlers } = useNodePointerInteractions(() => nodeData.id)
const { position, size, zIndex } = useNodeLayout(() => String(nodeData.id))
const { pointerHandlers } = useNodePointerInteractions(() =>
String(nodeData.id)
)
const { onPointerdown, ...remainingPointerHandlers } = pointerHandlers
const { startDrag } = useNodeDrag()
const badges = usePartitionedBadges(nodeData)
@@ -487,7 +489,7 @@ const hasCustomContent = computed(() => {
const progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
() => nodeData.id,
() => String(nodeData.id),
{
isCollapsed
}

View File

@@ -32,7 +32,7 @@ function useNodeEventHandlersIndividual() {
if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeId)
const node = nodeManager.value.getNode(String(nodeId))
if (!node) return
const multiSelect = isMultiSelectKey(event)
@@ -69,7 +69,7 @@ function useNodeEventHandlersIndividual() {
if (!nodeManager.value) return
const node = nodeManager.value.getNode(nodeId)
const node = nodeManager.value.getNode(String(nodeId))
if (!node) return
// Use LiteGraph's collapse method if the state needs to change
@@ -88,7 +88,7 @@ function useNodeEventHandlersIndividual() {
if (!nodeManager.value) return
const node = nodeManager.value.getNode(nodeId)
const node = nodeManager.value.getNode(String(nodeId))
if (!node) return
// Update the node title in LiteGraph for persistence
@@ -104,7 +104,7 @@ function useNodeEventHandlersIndividual() {
if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeId)
const node = nodeManager.value.getNode(String(nodeId))
if (!node) return
// Prevent default context menu
@@ -127,7 +127,7 @@ function useNodeEventHandlersIndividual() {
if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeId)
const node = nodeManager.value.getNode(String(nodeId))
if (!node) return
if (!multiSelect) {

View File

@@ -355,7 +355,7 @@ export function useSlotLinkInteraction({
if (slotCandidate) {
const key = getSlotKey(
slotCandidate.layout.nodeId,
String(slotCandidate.layout.nodeId),
slotCandidate.layout.index,
slotCandidate.layout.type === 'input'
)
@@ -363,7 +363,7 @@ export function useSlotLinkInteraction({
}
if (nodeCandidate && !slotCandidate?.compatible) {
const key = getSlotKey(
nodeCandidate.layout.nodeId,
String(nodeCandidate.layout.nodeId),
nodeCandidate.layout.index,
nodeCandidate.layout.type === 'input'
)
@@ -374,7 +374,7 @@ export function useSlotLinkInteraction({
const newCandidate = candidate?.compatible ? candidate : null
const newCandidateKey = newCandidate
? getSlotKey(
newCandidate.layout.nodeId,
String(newCandidate.layout.nodeId),
newCandidate.layout.index,
newCandidate.layout.type === 'input'
)

View File

@@ -58,7 +58,7 @@ function useNodeDragIndividual() {
// capture the starting positions of all other selected nodes
// Only move other selected items if the dragged node is part of the selection
const isDraggedNodeInSelection = selectedNodes?.has(nodeId)
const isDraggedNodeInSelection = selectedNodes?.has(String(nodeId))
if (isDraggedNodeInSelection && selectedNodes.size > 1) {
otherSelectedNodesStartPositions = new Map()

View File

@@ -849,6 +849,8 @@ export class ComfyApp {
.map((w) => [w.id, w])
)
const newGraphNodeSet = new Set(newGraph.nodes)
for (const [
widgetId,
widgetState
@@ -856,6 +858,10 @@ export class ComfyApp {
if (widgetId in activeWidgets) {
widgetState.active = true
widgetState.widget = activeWidgets[widgetId]
} else if (newGraphNodeSet.has(widgetState.widget.node)) {
// Adapter widgets (e.g. PromotedDomWidgetAdapter) are not in any
// node's widgets array but their host node IS in the graph.
widgetState.active = true
} else {
widgetState.active = false
}

View File

@@ -30,11 +30,12 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
// Register a widget with the store
const registerWidget = <V extends object | string>(
widget: BaseDOMWidget<V>
widget: BaseDOMWidget<V>,
options?: { visible?: boolean }
) => {
widgetStates.value.set(widget.id, {
widget: markRaw(widget) as unknown as Raw<BaseDOMWidget<object | string>>,
visible: true,
visible: options?.visible ?? true,
readonly: false,
zIndex: 0,
pos: [0, 0],