Files
ComfyUI_frontend/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts
Alexander Brown a7c2115166 feat: add WidgetValueStore for centralized widget value management (#8594)
## Summary

Implements Phase 1 of the **Vue-owns-truth** pattern for widget values.
Widget values are now canonical in a Pinia store; `widget.value`
delegates to the store while preserving full backward compatibility.

## Changes

- **New store**: `src/stores/widgetValueStore.ts` - centralized widget
value storage with `get/set/remove/removeNode` API
- **BaseWidget integration**: `widget.value` getter/setter now delegates
to store when widget is associated with a node
- **LGraphNode wiring**: `addCustomWidget()` automatically calls
`widget.setNodeId(this.id)` to wire widgets to their nodes
- **Test fixes**: Added Pinia setup to test files that use widgets

## Why

This foundation enables:
- Vue components to reactively bind to widget values via `computed(() =>
store.get(...))`
- Future Yjs/CRDT backing for real-time collaboration
- Cleaner separation between Vue state and LiteGraph rendering

## Backward Compatibility

| Extension Pattern | Status |
|-------------------|--------|
| `widget.value = x` |  Works unchanged |
| `node.widgets[i].value` |  Works unchanged |
| `widget.callback` |  Still fires |
| `node.onWidgetChanged` |  Still fires |

## Testing

-  4252 unit tests pass
-  Build succeeds

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8594-feat-add-WidgetValueStore-for-centralized-widget-value-management-2fc6d73d36508160886fcb9f3ebd941e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-10 19:37:17 -08:00

149 lines
4.6 KiB
TypeScript

import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { isStringInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
const TRACKPAD_DETECTION_THRESHOLD = 50
// TODO: This widget manually syncs with widgetValueStore via getValue/setValue.
// Consolidate with useMarkdownWidget into shared helpers (domWidgetHelpers.ts).
function addMultilineWidget(
node: LGraphNode,
name: string,
opts: { defaultVal: string; placeholder?: string }
) {
const widgetStore = useWidgetValueStore()
const inputEl = document.createElement('textarea')
inputEl.className = 'comfy-multiline-input'
inputEl.value = opts.defaultVal
inputEl.placeholder = opts.placeholder || name
inputEl.spellcheck = useSettingStore().get('Comfy.TextareaWidget.Spellcheck')
const widget = node.addDOMWidget(name, 'customtext', inputEl, {
getValue(): string {
const widgetState = widgetStore.getWidget(node.id, name)
return (widgetState?.value as string) ?? inputEl.value
},
setValue(v: string) {
inputEl.value = v
const widgetState = widgetStore.getWidget(node.id, name)
if (widgetState) widgetState.value = v
}
})
widget.element = inputEl
widget.options.minNodeSize = [400, 200]
inputEl.addEventListener('input', (event) => {
if (event.target instanceof HTMLTextAreaElement) {
widget.value = event.target.value
}
widget.callback?.(widget.value)
})
// Allow middle mouse button panning
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseDown(event)
}
})
inputEl.addEventListener('pointermove', (event: PointerEvent) => {
if ((event.buttons & 4) === 4) {
app.canvas.processMouseMove(event)
}
})
inputEl.addEventListener('pointerup', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseUp(event)
}
})
inputEl.addEventListener('wheel', (event: WheelEvent) => {
const gesturesEnabled = useSettingStore().get(
'LiteGraph.Pointer.TrackpadGestures'
)
const deltaX = event.deltaX
const deltaY = event.deltaY
const canScrollY = inputEl.scrollHeight > inputEl.clientHeight
const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY)
// Prevent pinch zoom from zooming the page
if (event.ctrlKey) {
event.preventDefault()
event.stopPropagation()
app.canvas.processMouseWheel(event)
return
}
// Detect if this is likely a trackpad gesture vs mouse wheel
// Trackpads usually have deltaX or smaller deltaY values (< TRACKPAD_DETECTION_THRESHOLD)
// Mouse wheels typically have larger discrete deltaY values (>= TRACKPAD_DETECTION_THRESHOLD)
const isLikelyTrackpad =
Math.abs(deltaX) > 0 || Math.abs(deltaY) < TRACKPAD_DETECTION_THRESHOLD
// Trackpad gestures: when enabled, trackpad panning goes to canvas
if (gesturesEnabled && isLikelyTrackpad) {
event.preventDefault()
event.stopPropagation()
app.canvas.processMouseWheel(event)
return
}
// When gestures disabled: horizontal always goes to canvas (no horizontal scroll in textarea)
if (isHorizontal) {
event.preventDefault()
event.stopPropagation()
app.canvas.processMouseWheel(event)
return
}
// Vertical scrolling when gestures disabled: let textarea scroll if scrollable
if (canScrollY) {
event.stopPropagation()
return
}
// If textarea can't scroll vertically, pass to canvas
event.preventDefault()
app.canvas.processMouseWheel(event)
})
return widget
}
export const useStringWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
if (!isStringInputSpec(inputSpec)) {
throw new Error(`Invalid input data: ${inputSpec}`)
}
const defaultVal = inputSpec.default ?? ''
const multiline = inputSpec.multiline
const widget = multiline
? addMultilineWidget(node, inputSpec.name, {
defaultVal,
placeholder: inputSpec.placeholder
})
: node.addWidget('text', inputSpec.name, defaultVal, () => {}, {})
if (typeof inputSpec.dynamicPrompts === 'boolean') {
widget.dynamicPrompts = inputSpec.dynamicPrompts
}
return widget
}
return widgetConstructor
}