mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-13 17:10:06 +00:00
feat: synthetic widgets getter for SubgraphNode (proxy-widget-v2) (#8856)
## Summary Replace the Proxy-based proxy widget system with a store-driven architecture where `promotionStore` and `widgetValueStore` are the single sources of truth for subgraph widget promotion and widget values, and `SubgraphNode.widgets` is a synthetic getter composing lightweight `PromotedWidgetView` objects from store state. ## Motivation The subgraph widget promotion system previously scattered state across multiple unsynchronized layers: - **Persistence**: `node.properties.proxyWidgets` (tuples on the LiteGraph node) - **Runtime**: Proxy-based `proxyWidget.ts` with `Overlay` objects, `DisconnectedWidget` singleton, and `isProxyWidget` type guards - **UI**: Each Vue component independently calling `parseProxyWidgets()` via `customRef` hacks - **Mutation flags**: Imperative `widget.promoted = true/false` set on `subgraph-opened` events This led to 4+ independent parsings of the same data, complex cache invalidation, and no reactive contract between the promotion state and the rendering layer. Widget values were similarly owned by LiteGraph with no Vue-reactive backing. The core principle driving these changes: **Vue owns truth**. Pinia stores are the canonical source; LiteGraph objects delegate to stores via getters/setters; Vue components react to store state directly. ## Changes ### New stores (single sources of truth) - **`promotionStore`** — Reactive `Map<NodeId, PromotionEntry[]>` tracking which interior widgets are promoted on which SubgraphNode instances. Graph-scoped by root graph ID to prevent cross-workflow state collision. Replaces `properties.proxyWidgets` parsing, `customRef` hacks, `widget.promoted` mutation, and the `subgraph-opened` event listener. - **`widgetValueStore`** — Graph-scoped `Map<WidgetKey, WidgetState>` that is the canonical owner of widget values. `BaseWidget.value` delegates to this store via getter/setter when a node ID is assigned. Eliminates the need for Proxy-based value forwarding. ### Synthetic widgets getter (SubgraphNode) `SubgraphNode.widgets` is now a getter that reads `promotionStore.getPromotions(rootGraphId, nodeId)` and returns cached `PromotedWidgetView` objects. No stubs, no Proxies, no fake widgets persisted in the array. The setter is a no-op — mutations go through `promotionStore`. ### PromotedWidgetView A class behind a `createPromotedWidgetView` factory, implementing the `PromotedWidgetView` interface. Delegates value/type/options/drawing to the resolved interior widget and stores. Owns positional state (`y`, `computedHeight`) for canvas layout. Cached by `PromotedWidgetViewManager` for object-identity stability across frames. ### DOM widget promotion Promoted DOM widgets (textarea, image upload, etc.) render on the SubgraphNode surface via `positionOverride` in `domWidgetStore`. `DomWidgets.vue` checks for overrides and uses the SubgraphNode's coordinates instead of the interior node's. ### Promoted previews New `usePromotedPreviews` composable resolves image/audio/video preview widgets from promoted entries, enabling SubgraphNodes to display previews of interior preview nodes. ### Deleted - `proxyWidget.ts` (257 lines) — Proxy handler, `Overlay`, `newProxyWidget`, `isProxyWidget` - `DisconnectedWidget.ts` (39 lines) — Singleton Proxy target - `useValueTransform.ts` (32 lines) — Replaced by store delegation ### Key architectural changes - `BaseWidget.value` getter/setter delegates to `widgetValueStore` when node ID is set - `LGraph.add()` reordered: `node.graph` assigned before widget `setNodeId` (enables store registration) - `LGraph.clear()` cleans up graph-scoped stores to prevent stale entries across workflow switches - `promotionStore` and `widgetValueStore` state nested under root graph UUID for multi-workflow isolation - `SubgraphNode.serialize()` writes promotions back to `properties.proxyWidgets` for persistence compatibility - Legacy `-1` promotion entries resolved and migrated on first load with dev warning ## Test coverage - **3,700+ lines of new/updated tests** across 36 test files - **Unit**: `promotionStore.test.ts`, `widgetValueStore.test.ts`, `promotedWidgetView.test.ts` (921 lines), `subgraphNodePromotion.test.ts`, `proxyWidgetUtils.test.ts`, `DomWidgets.test.ts`, `PromotedWidgetViewManager.test.ts`, `usePromotedPreviews.test.ts`, `resolvePromotedWidget.test.ts`, `subgraphPseudoWidgetCache.test.ts` - **E2E**: `subgraphPromotion.spec.ts` (622 lines) — promote/demote, manual/auto promotion, paste preservation, seed control augmentation, image preview promotion; `imagePreview.spec.ts` extended with multi-promoted-preview coverage - **Fixtures**: 2 new subgraph workflow fixtures for preview promotion scenarios ## Review focus - Graph-scoped store keying (`rootGraphId`) — verify isolation across workflows/tabs and cleanup on `LGraph.clear()` - `PromotedWidgetView` positional stability — `_arrangeWidgets` writes to `y`/`computedHeight` on cached objects; getter returns fresh array but stable object references - DOM widget position override lifecycle — overrides set on promote, cleared on demote/removal/subgraph navigation - Legacy `-1` entry migration — resolved and written back on first load; unresolvable entries dropped with dev warning - Serialization round-trip — `promotionStore` state → `properties.proxyWidgets` on serialize, hydrated back on configure ## Diff breakdown (excluding lockfile) - 153 files changed, ~7,500 insertions, ~1,900 deletions (excluding pnpm-lock.yaml churn) - ~3,700 lines are tests - ~300 lines deleted (proxyWidget.ts, DisconnectedWidget.ts, useValueTransform.ts) <!-- Fixes #ISSUE_NUMBER --> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8856-feat-synthetic-widgets-getter-for-SubgraphNode-proxy-widget-v2-3076d73d365081c7b517f5ec7cb514f3) 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>
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
<!-- Video Wrapper -->
|
||||
<div
|
||||
ref="videoWrapperEl"
|
||||
class="relative h-full w-full grow overflow-hidden rounded-[5px] bg-node-component-surface"
|
||||
class="relative flex flex-1 overflow-hidden rounded-[5px] bg-node-component-surface"
|
||||
tabindex="0"
|
||||
role="region"
|
||||
:aria-label="$t('g.videoPreview')"
|
||||
@@ -21,7 +21,7 @@
|
||||
<div
|
||||
v-if="videoError"
|
||||
role="alert"
|
||||
class="flex size-full flex-col items-center justify-center bg-muted-background text-center text-base-foreground py-8"
|
||||
class="flex flex-auto flex-col items-center justify-center bg-muted-background text-center text-base-foreground py-8"
|
||||
>
|
||||
<i
|
||||
class="mb-2 icon-[lucide--video-off] h-12 w-12 text-base-foreground"
|
||||
|
||||
19
src/renderer/extensions/vueNodes/components/AudioPreview.vue
Normal file
19
src/renderer/extensions/vueNodes/components/AudioPreview.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div v-if="audioUrls.length > 0" class="flex flex-col gap-2 px-2">
|
||||
<audio
|
||||
v-for="(url, index) in audioUrls"
|
||||
:key="index"
|
||||
:src="url"
|
||||
controls
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface AudioPreviewProps {
|
||||
readonly audioUrls: readonly string[]
|
||||
}
|
||||
|
||||
defineProps<AudioPreviewProps>()
|
||||
</script>
|
||||
@@ -7,7 +7,7 @@
|
||||
<!-- Image Wrapper -->
|
||||
<div
|
||||
ref="imageWrapperEl"
|
||||
class="h-full w-full overflow-hidden rounded-[5px] bg-muted-background relative"
|
||||
class="min-h-0 flex-1 flex w-full overflow-hidden rounded-[5px] bg-muted-background relative"
|
||||
tabindex="0"
|
||||
role="img"
|
||||
:aria-label="$t('g.imagePreview')"
|
||||
@@ -21,7 +21,7 @@
|
||||
<div
|
||||
v-if="imageError"
|
||||
role="alert"
|
||||
class="flex size-full flex-col items-center justify-center bg-muted-background text-center text-base-foreground py-8"
|
||||
class="flex flex-1 self-center size-full flex-col items-center justify-around bg-muted-background text-center text-base-foreground py-8"
|
||||
>
|
||||
<i
|
||||
class="mb-2 icon-[lucide--image-off] h-12 w-12 text-base-foreground"
|
||||
@@ -42,7 +42,7 @@
|
||||
v-if="!imageError"
|
||||
:src="currentImageUrl"
|
||||
:alt="imageAltText"
|
||||
class="block size-full object-contain pointer-events-none contain-size"
|
||||
class="absolute inset-0 block size-full object-contain pointer-events-none"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
@@ -111,8 +111,18 @@
|
||||
|
||||
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
|
||||
|
||||
<div v-if="hasCustomContent" class="min-h-0 flex-1 flex">
|
||||
<NodeContent :node-data="nodeData" :media="nodeMedia" />
|
||||
<div v-if="hasCustomContent" class="min-h-0 flex-1 flex flex-col">
|
||||
<NodeContent
|
||||
v-if="nodeMedia"
|
||||
:node-data="nodeData"
|
||||
:media="nodeMedia"
|
||||
/>
|
||||
<NodeContent
|
||||
v-for="preview in promotedPreviews"
|
||||
:key="`${preview.interiorNodeId}-${preview.widgetName}`"
|
||||
:node-data="nodeData"
|
||||
:media="preview"
|
||||
/>
|
||||
</div>
|
||||
<!-- Live mid-execution preview images -->
|
||||
<LivePreview
|
||||
@@ -256,6 +266,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { hasUnpromotedWidgets } from '@/core/graph/subgraph/unpromotedWidgetUtils'
|
||||
import { st } from '@/i18n'
|
||||
import {
|
||||
LGraphCanvas,
|
||||
@@ -270,6 +281,7 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { usePromotedPreviews } from '@/composables/node/usePromotedPreviews'
|
||||
import NodeBadges from '@/renderer/extensions/vueNodes/components/NodeBadges.vue'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue'
|
||||
@@ -525,7 +537,7 @@ watch(isCollapsed, (collapsed) => {
|
||||
|
||||
// Check if node has custom content (like image/video outputs)
|
||||
const hasCustomContent = computed(() => {
|
||||
// Show custom content if node has media outputs
|
||||
if (promotedPreviews.value.length > 0) return true
|
||||
return !!nodeMedia.value && nodeMedia.value.urls.length > 0
|
||||
})
|
||||
|
||||
@@ -639,15 +651,17 @@ const lgraphNode = computed(() => {
|
||||
return getNodeByLocatorId(app.rootGraph, locatorId)
|
||||
})
|
||||
|
||||
// TODO: Surface subgraph info more cleanly in VueNodeData instead of
|
||||
// reaching through lgraphNode for promoted preview resolution.
|
||||
const { promotedPreviews } = usePromotedPreviews(lgraphNode)
|
||||
|
||||
const showAdvancedInputsButton = computed(() => {
|
||||
const node = lgraphNode.value
|
||||
if (!node) return false
|
||||
|
||||
// For subgraph nodes: check for unpromoted widgets
|
||||
if (node instanceof SubgraphNode) {
|
||||
const interiorNodes = node.subgraph.nodes
|
||||
const allInteriorWidgets = interiorNodes.flatMap((n) => n.widgets ?? [])
|
||||
return allInteriorWidgets.some((w) => !w.computedDisabled && !w.promoted)
|
||||
return hasUnpromotedWidgets(node)
|
||||
}
|
||||
|
||||
// For regular nodes: show button if there are advanced widgets and they're currently hidden
|
||||
|
||||
@@ -2,20 +2,25 @@
|
||||
<div v-if="renderError" class="node-error p-2 text-sm text-red-500">
|
||||
{{ st('nodeErrors.content', 'Node Content Error') }}
|
||||
</div>
|
||||
<div v-else class="lg-node-content flex grow flex-col">
|
||||
<div v-else class="lg-node-content flex grow flex-col flex-auto">
|
||||
<!-- Default slot for custom content -->
|
||||
<slot>
|
||||
<VideoPreview
|
||||
v-if="hasMedia && media?.type === 'video'"
|
||||
:image-urls="media.urls"
|
||||
:node-id="nodeId"
|
||||
class="mt-2"
|
||||
class="mt-2 flex-auto"
|
||||
/>
|
||||
<AudioPreview
|
||||
v-else-if="hasMedia && media?.type === 'audio'"
|
||||
:audio-urls="media.urls"
|
||||
class="mt-2 flex-auto"
|
||||
/>
|
||||
<ImagePreview
|
||||
v-else-if="hasMedia && media?.type === 'image'"
|
||||
:image-urls="media.urls"
|
||||
:node-id="nodeId"
|
||||
class="mt-2"
|
||||
class="mt-2 flex-auto"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
@@ -29,12 +34,13 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { st } from '@/i18n'
|
||||
|
||||
import VideoPreview from '../VideoPreview.vue'
|
||||
import AudioPreview from './AudioPreview.vue'
|
||||
import ImagePreview from './ImagePreview.vue'
|
||||
|
||||
interface NodeContentProps {
|
||||
nodeData?: VueNodeData
|
||||
media?: {
|
||||
type: 'image' | 'video'
|
||||
type: 'image' | 'video' | 'audio'
|
||||
urls: string[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { st } from '@/i18n'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
|
||||
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
|
||||
@@ -101,6 +102,7 @@ import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -115,7 +117,9 @@ const { nodeData } = defineProps<NodeWidgetsProps>()
|
||||
|
||||
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
|
||||
useCanvasInteractions()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { bringNodeToFront } = useNodeZIndex()
|
||||
const promotionStore = usePromotionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
|
||||
function handleWidgetPointerEvent(event: PointerEvent) {
|
||||
@@ -171,6 +175,7 @@ interface ProcessedWidget {
|
||||
const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
if (!nodeData?.widgets) return []
|
||||
const nodeErrors = executionErrorStore.lastNodeErrors?.[nodeData.id ?? '']
|
||||
const graphId = canvasStore.canvas?.graph?.rootGraph.id
|
||||
|
||||
const nodeId = nodeData.id
|
||||
const { widgets } = nodeData
|
||||
@@ -187,7 +192,9 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
|
||||
// Get metadata from store (registered during BaseWidget.setNodeId)
|
||||
const bareWidgetId = stripGraphPrefix(widget.nodeId ?? nodeId)
|
||||
const widgetState = widgetValueStore.getWidget(bareWidgetId, widget.name)
|
||||
const widgetState = graphId
|
||||
? widgetValueStore.getWidget(graphId, bareWidgetId, widget.name)
|
||||
: undefined
|
||||
|
||||
// Get value from store (falls back to undefined if not registered)
|
||||
const value = widgetState?.value as WidgetValue
|
||||
@@ -198,9 +205,11 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
? { ...storeOptions, disabled: true }
|
||||
: storeOptions
|
||||
|
||||
// Derive border style from store metadata
|
||||
const isPromotedView = !!widget.nodeId
|
||||
const borderStyle =
|
||||
widgetState?.promoted && String(widgetState?.nodeId) === String(nodeId)
|
||||
graphId &&
|
||||
!isPromotedView &&
|
||||
promotionStore.isPromotedByAny(graphId, String(bareWidgetId), widget.name)
|
||||
? 'ring ring-component-node-widget-promoted'
|
||||
: widget.options?.advanced
|
||||
? 'ring ring-component-node-widget-advanced'
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import { usePriceBadge } from '@/composables/node/usePriceBadge'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { NodeBadgeProps } from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
@@ -54,10 +55,12 @@ export function usePartitionedBadges(nodeData: VueNodeData) {
|
||||
// Access only the widget values that affect pricing (from widgetValueStore)
|
||||
const relevantNames = relevantPricingWidgets.value
|
||||
const widgetStore = useWidgetValueStore()
|
||||
const graphId = app.canvas?.graph?.rootGraph.id
|
||||
if (relevantNames.length > 0 && nodeData?.id != null) {
|
||||
for (const name of relevantNames) {
|
||||
// Access value from store to create reactive dependency
|
||||
void widgetStore.getWidget(nodeData.id, name)?.value
|
||||
if (!graphId) continue
|
||||
void widgetStore.getWidget(graphId, nodeData.id, name)?.value
|
||||
}
|
||||
}
|
||||
// Access input connections for regular inputs
|
||||
|
||||
@@ -3,7 +3,9 @@ import { whenever } from '@vueuse/core'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { DOMWidget } from '@/scripts/domWidget'
|
||||
import { isDOMWidget } from '@/scripts/domWidget'
|
||||
import { resolveWidgetFromHostNode } from '@/renderer/extensions/vueNodes/widgets/utils/resolvePromotedWidget'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
// Button widgets don't have a v-model value, they trigger actions
|
||||
@@ -17,12 +19,18 @@ const domEl = ref<HTMLElement>()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { canvas } = canvasStore
|
||||
|
||||
function findDOMWidget(): DOMWidget<HTMLElement, object | string> | undefined {
|
||||
const hostNode = canvas?.graph?.getNodeById(props.nodeId) ?? undefined
|
||||
const resolved = resolveWidgetFromHostNode(hostNode, props.widget.name)
|
||||
if (resolved && isDOMWidget(resolved.widget)) return resolved.widget
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function mountWidgetElement() {
|
||||
if (!domEl.value) return
|
||||
const node = canvas?.graph?.getNodeById(props.nodeId) ?? undefined
|
||||
if (!node) return
|
||||
const widget = node.widgets?.find((w) => w.name === props.widget.name)
|
||||
if (!widget || !isDOMWidget(widget)) return
|
||||
const widget = findDOMWidget()
|
||||
if (!widget) return
|
||||
if (domEl.value.contains(widget.element)) return
|
||||
domEl.value.replaceChildren(widget.element)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import { useResizeObserver, whenever } from '@vueuse/core'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
@@ -9,31 +9,46 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { augmentToCanvasPointerEvent } from '@/renderer/extensions/vueNodes/utils/eventUtils'
|
||||
import { resolveWidgetFromHostNode } from '@/renderer/extensions/vueNodes/widgets/utils/resolvePromotedWidget'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<void>
|
||||
nodeId: string
|
||||
}>()
|
||||
|
||||
const canvasEl = ref()
|
||||
const containerHeight = ref(20)
|
||||
|
||||
const canvas: LGraphCanvas = useCanvasStore().canvas as LGraphCanvas
|
||||
const canvasStore = useCanvasStore()
|
||||
const canvas: LGraphCanvas = canvasStore.canvas as LGraphCanvas
|
||||
let node: LGraphNode | undefined
|
||||
let widgetInstance: IBaseWidget | undefined
|
||||
let pointer: CanvasPointer | undefined
|
||||
const scaleFactor = 2
|
||||
|
||||
onMounted(() => {
|
||||
node =
|
||||
canvas?.graph?.getNodeById(
|
||||
canvasEl.value.parentElement.attributes['node-id'].value
|
||||
) ?? undefined
|
||||
if (!node) return
|
||||
widgetInstance = node.widgets?.find((w) => w.name === props.widget.name)
|
||||
if (!widgetInstance) return
|
||||
canvasEl.value.width *= scaleFactor
|
||||
function findLegacyWidget():
|
||||
| {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}
|
||||
| undefined {
|
||||
const hostNode = canvas?.graph?.getNodeById(props.nodeId) ?? undefined
|
||||
return resolveWidgetFromHostNode(hostNode, props.widget.name)
|
||||
}
|
||||
|
||||
function bindWidget() {
|
||||
if (widgetInstance) widgetInstance.triggerDraw = () => {}
|
||||
|
||||
const resolved = findLegacyWidget()
|
||||
if (!resolved) {
|
||||
widgetInstance = undefined
|
||||
node = undefined
|
||||
return
|
||||
}
|
||||
node = resolved.node
|
||||
widgetInstance = resolved.widget
|
||||
if (!widgetInstance.triggerDraw)
|
||||
widgetInstance.callback = useChainCallback(
|
||||
widgetInstance.callback,
|
||||
@@ -42,6 +57,13 @@ onMounted(() => {
|
||||
}
|
||||
)
|
||||
widgetInstance.triggerDraw = draw
|
||||
draw()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
canvasEl.value.width *= scaleFactor
|
||||
bindWidget()
|
||||
if (!widgetInstance) return
|
||||
useResizeObserver(canvasEl.value.parentElement, draw)
|
||||
watch(() => useColorPaletteStore().activePaletteId, draw)
|
||||
pointer = new CanvasPointer(canvasEl.value)
|
||||
@@ -50,6 +72,9 @@ onBeforeUnmount(() => {
|
||||
if (widgetInstance) widgetInstance.triggerDraw = () => {}
|
||||
})
|
||||
|
||||
whenever(() => !canvasStore.linearMode, bindWidget)
|
||||
watch(() => canvasStore.currentGraph, bindWidget)
|
||||
|
||||
function draw() {
|
||||
if (!widgetInstance || !node) return
|
||||
const width = canvasEl.value.parentElement.clientWidth
|
||||
|
||||
@@ -49,6 +49,8 @@ const renderPreview = (
|
||||
shiftY: number,
|
||||
computedHeight: number | undefined
|
||||
) => {
|
||||
if (!node.size) return
|
||||
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
const mouse = canvas.graph_mouse
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
|
||||
import { useValueTransform } from '@/composables/useValueTransform'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
@@ -15,17 +14,14 @@ import { addToComboValues } from '@/utils/litegraphUtil'
|
||||
const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
|
||||
const ACCEPTED_VIDEO_TYPES = 'video/webm,video/mp4'
|
||||
|
||||
type InternalFile = string | ResultItem
|
||||
type InternalValue = InternalFile | InternalFile[]
|
||||
type ExposedValue = string | string[]
|
||||
|
||||
const isImageFile = (file: File) => file.type.startsWith('image/')
|
||||
const isVideoFile = (file: File) => file.type.startsWith('video/')
|
||||
|
||||
const findFileComboWidget = (node: LGraphNode, inputName: string) =>
|
||||
node.widgets!.find((w) => w.name === inputName) as IComboWidget & {
|
||||
value: ExposedValue
|
||||
}
|
||||
const findFileComboWidget = (
|
||||
node: LGraphNode,
|
||||
inputName: string
|
||||
): IComboWidget | undefined =>
|
||||
node.widgets?.find((w): w is IComboWidget => w.name === inputName)
|
||||
|
||||
export const useImageUploadWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructor = (
|
||||
@@ -51,24 +47,11 @@ export const useImageUploadWidget = () => {
|
||||
|
||||
const fileFilter = isVideo ? isVideoFile : isImageFile
|
||||
const fileComboWidget = findFileComboWidget(node, imageInputName)
|
||||
const initialFile = `${fileComboWidget.value}`
|
||||
const formatPath = (value: InternalFile) =>
|
||||
createAnnotatedPath(value, { rootFolder: image_folder })
|
||||
|
||||
const transform = (internalValue: InternalValue): ExposedValue => {
|
||||
if (!internalValue) return initialFile
|
||||
if (Array.isArray(internalValue))
|
||||
return allow_batch
|
||||
? internalValue.map(formatPath)
|
||||
: formatPath(internalValue[0])
|
||||
return formatPath(internalValue)
|
||||
if (!fileComboWidget) {
|
||||
throw new Error(`Widget "${imageInputName}" not found on node`)
|
||||
}
|
||||
|
||||
Object.defineProperty(
|
||||
fileComboWidget,
|
||||
'value',
|
||||
useValueTransform(transform, initialFile)
|
||||
)
|
||||
const formatPath = (value: string) =>
|
||||
createAnnotatedPath(value, { rootFolder: image_folder })
|
||||
|
||||
// Setup file upload handling
|
||||
const { openFileSelection } = useNodeImageUpload(node, {
|
||||
@@ -77,10 +60,12 @@ export const useImageUploadWidget = () => {
|
||||
accept,
|
||||
folder,
|
||||
onUploadComplete: (output) => {
|
||||
output.forEach((path) => addToComboValues(fileComboWidget, path))
|
||||
const annotated = output.map(formatPath)
|
||||
annotated.forEach((path) => {
|
||||
addToComboValues(fileComboWidget, path)
|
||||
})
|
||||
|
||||
// Create a NEW array to ensure Vue reactivity detects the change
|
||||
const newValue = allow_batch ? [...output] : output[0]
|
||||
const newValue = allow_batch ? annotated : annotated[0]
|
||||
|
||||
// @ts-expect-error litegraph combo value type does not support arrays yet
|
||||
fileComboWidget.value = newValue
|
||||
@@ -103,7 +88,7 @@ export const useImageUploadWidget = () => {
|
||||
|
||||
// Add our own callback to the combo widget to render an image when it changes
|
||||
fileComboWidget.callback = function () {
|
||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||
nodeOutputStore.setNodeOutputs(node, String(fileComboWidget.value), {
|
||||
isAnimated
|
||||
})
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
@@ -113,7 +98,7 @@ export const useImageUploadWidget = () => {
|
||||
// The value isn't set immediately so we need to wait a moment
|
||||
// No change callbacks seem to be fired on initial setting of the value
|
||||
requestAnimationFrame(() => {
|
||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||
nodeOutputStore.setNodeOutputs(node, String(fileComboWidget.value), {
|
||||
isAnimated
|
||||
})
|
||||
showPreview({ block: false })
|
||||
|
||||
@@ -7,6 +7,7 @@ import TiptapTableRow from '@tiptap/extension-table-row'
|
||||
import TiptapStarterKit from '@tiptap/starter-kit'
|
||||
import { Markdown as TiptapMarkdown } from 'tiptap-markdown'
|
||||
|
||||
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -48,15 +49,15 @@ function addMarkdownWidget(
|
||||
|
||||
const widget = node.addDOMWidget(name, 'MARKDOWN', inputEl, {
|
||||
getValue(): string {
|
||||
return (
|
||||
(widgetStore.getWidget(node.id, name)?.value as string) ??
|
||||
textarea.value
|
||||
)
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const storedValue = widgetStore.getWidget(graphId, node.id, name)?.value
|
||||
return typeof storedValue === 'string' ? storedValue : textarea.value
|
||||
},
|
||||
setValue(v: string) {
|
||||
textarea.value = v
|
||||
editor.commands.setContent(v)
|
||||
const widgetState = widgetStore.getWidget(node.id, name)
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const widgetState = widgetStore.getWidget(graphId, node.id, name)
|
||||
if (widgetState) widgetState.value = v
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import TextPreviewWidget from '@/components/graph/widgets/TextPreviewWidget.vue'
|
||||
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComponentWidgetStandardProps } from '@/scripts/domWidget'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
@@ -36,9 +38,15 @@ export function useTextPreviewWidget(
|
||||
},
|
||||
options: {
|
||||
getValue: () =>
|
||||
useWidgetValueStore().getWidget(node.id, inputSpec.name)?.value ?? '',
|
||||
useWidgetValueStore().getWidget(
|
||||
resolveNodeRootGraphId(node, app.rootGraph.id),
|
||||
node.id,
|
||||
inputSpec.name
|
||||
)?.value ?? '',
|
||||
setValue: (value: string | object) => {
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const widgetState = useWidgetValueStore().getWidget(
|
||||
graphId,
|
||||
node.id,
|
||||
inputSpec.name
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { isStringInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
@@ -18,19 +19,22 @@ function addMultilineWidget(
|
||||
const widgetStore = useWidgetValueStore()
|
||||
const inputEl = document.createElement('textarea')
|
||||
inputEl.className = 'comfy-multiline-input'
|
||||
inputEl.dataset.testid = 'dom-widget-textarea'
|
||||
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)
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const widgetState = widgetStore.getWidget(graphId, node.id, name)
|
||||
|
||||
return (widgetState?.value as string) ?? inputEl.value
|
||||
},
|
||||
setValue(v: string) {
|
||||
inputEl.value = v
|
||||
const widgetState = widgetStore.getWidget(node.id, name)
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const widgetState = widgetStore.getWidget(graphId, node.id, name)
|
||||
if (widgetState) widgetState.value = v
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { resolveWidgetFromHostNode } from '@/renderer/extensions/vueNodes/widgets/utils/resolvePromotedWidget'
|
||||
|
||||
class TestNode extends LGraphNode {
|
||||
constructor(widgets: IBaseWidget[]) {
|
||||
super('TestNode')
|
||||
this.widgets = widgets
|
||||
}
|
||||
}
|
||||
|
||||
class TestSubgraphNode extends TestNode {
|
||||
constructor(
|
||||
widgets: IBaseWidget[],
|
||||
innerNodesById: Record<string, LGraphNode> = {}
|
||||
) {
|
||||
super(widgets)
|
||||
this.subgraph = {
|
||||
getNodeById: (nodeId: string) => innerNodesById[nodeId]
|
||||
} as SubgraphNode['subgraph']
|
||||
}
|
||||
|
||||
override isSubgraphNode(): this is SubgraphNode {
|
||||
return true
|
||||
}
|
||||
|
||||
readonly subgraph: SubgraphNode['subgraph']
|
||||
}
|
||||
|
||||
type TestPromotedWidget = IBaseWidget &
|
||||
Pick<PromotedWidgetView, 'sourceNodeId' | 'sourceWidgetName'>
|
||||
|
||||
function createWidget(name: string): IBaseWidget {
|
||||
return {
|
||||
name,
|
||||
type: 'text',
|
||||
y: 0,
|
||||
options: {}
|
||||
}
|
||||
}
|
||||
|
||||
function createPromotedWidget(
|
||||
name: string,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string
|
||||
): TestPromotedWidget {
|
||||
return {
|
||||
...createWidget(name),
|
||||
sourceNodeId,
|
||||
sourceWidgetName
|
||||
}
|
||||
}
|
||||
|
||||
function createHostNode(
|
||||
widgets: IBaseWidget[],
|
||||
options: {
|
||||
isSubgraphNode?: boolean
|
||||
innerNodesById?: Record<string, LGraphNode>
|
||||
} = {}
|
||||
): LGraphNode {
|
||||
const { isSubgraphNode = false, innerNodesById = {} } = options
|
||||
return isSubgraphNode
|
||||
? new TestSubgraphNode(widgets, innerNodesById)
|
||||
: new TestNode(widgets)
|
||||
}
|
||||
|
||||
describe('resolveWidgetFromHostNode', () => {
|
||||
it('returns host node widget for non-promoted widgets', () => {
|
||||
const widget = createWidget('text_widget')
|
||||
const hostNode = createHostNode([widget])
|
||||
|
||||
const resolved = resolveWidgetFromHostNode(hostNode, widget.name)
|
||||
|
||||
expect(resolved).toEqual({ node: hostNode, widget })
|
||||
})
|
||||
|
||||
it('resolves promoted widget to the interior node widget', () => {
|
||||
const innerWidget = createWidget('inner_text')
|
||||
const innerNode = createHostNode([innerWidget])
|
||||
const promotedWidget = createPromotedWidget(
|
||||
'promoted_text',
|
||||
'42',
|
||||
'inner_text'
|
||||
)
|
||||
const hostNode = createHostNode([promotedWidget], {
|
||||
isSubgraphNode: true,
|
||||
innerNodesById: { '42': innerNode }
|
||||
})
|
||||
|
||||
const resolved = resolveWidgetFromHostNode(hostNode, promotedWidget.name)
|
||||
|
||||
expect(resolved).toEqual({ node: innerNode, widget: innerWidget })
|
||||
})
|
||||
|
||||
it('returns undefined when promoted interior node is missing', () => {
|
||||
const promotedWidget = createPromotedWidget(
|
||||
'promoted_text',
|
||||
'42',
|
||||
'inner_text'
|
||||
)
|
||||
const hostNode = createHostNode([promotedWidget], {
|
||||
isSubgraphNode: true
|
||||
})
|
||||
|
||||
const resolved = resolveWidgetFromHostNode(hostNode, promotedWidget.name)
|
||||
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when promoted interior widget is missing', () => {
|
||||
const innerNode = createHostNode([])
|
||||
const promotedWidget = createPromotedWidget(
|
||||
'promoted_text',
|
||||
'42',
|
||||
'inner_text'
|
||||
)
|
||||
const hostNode = createHostNode([promotedWidget], {
|
||||
isSubgraphNode: true,
|
||||
innerNodesById: { '42': innerNode }
|
||||
})
|
||||
|
||||
const resolved = resolveWidgetFromHostNode(hostNode, promotedWidget.name)
|
||||
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats promoted-shaped widgets on non-subgraph nodes as local widgets', () => {
|
||||
const widget = createPromotedWidget('promoted_text', '42', 'inner_text')
|
||||
const hostNode = createHostNode([widget], {
|
||||
isSubgraphNode: false
|
||||
})
|
||||
|
||||
const resolved = resolveWidgetFromHostNode(hostNode, widget.name)
|
||||
|
||||
expect(resolved).toEqual({ node: hostNode, widget })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
type ResolvedWidget = {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}
|
||||
|
||||
export function resolveWidgetFromHostNode(
|
||||
hostNode: LGraphNode | undefined,
|
||||
widgetName: string
|
||||
): ResolvedWidget | undefined {
|
||||
if (!hostNode) return undefined
|
||||
|
||||
const widget = hostNode.widgets?.find((entry) => entry.name === widgetName)
|
||||
if (!widget) return undefined
|
||||
|
||||
const sourceWidget = resolvePromotedWidgetSource(hostNode, widget)
|
||||
if (sourceWidget) return sourceWidget
|
||||
|
||||
if (isPromotedWidgetView(widget) && hostNode.isSubgraphNode())
|
||||
return undefined
|
||||
|
||||
return {
|
||||
node: hostNode,
|
||||
widget
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user