mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 14:45:36 +00:00
refactor(appMode): resolve selectedInputs once at the computed boundary
Introduce `useResolvedSelectedInputs` that maps each persisted
`[entityId, displayName, config?]` entry to a discriminated union of
`{ status: 'resolved', node, widget, ... } | { status: 'unknown', ... }`.
Handlers (rename, remove, resize, bounding) now receive widget instances
directly instead of re-walking `rootGraph.nodes` per interaction.
- Delete `src/world/widgetLookup.ts`; the lone remaining caller
(`upgradeAndValidateInput` in `appModeStore.ts`) inlines the lookup as a
module-private function.
- `inlineRenameInput` deleted; the template calls `renameWidget(widget,
node, $event)` directly with the in-scope widget.
- `getWidgetBounding` accepts a `ResolvedSelection`; returns `undefined`
for unresolved entries.
- `useAppModeWidgetResizing.onPointerDown` and `updateInputConfig` accept
a widget instance, matching the existing `removeSelectedInput` shape.
- Persisted `selectedInputs` shape unchanged.
- Unresolved entries continue to render as a removable "unknown widget"
pill in the sidebar so users can clean up dangling selections.
Slot-rename → entityId-drift bug investigated and confirmed not present:
the `renaming-input` handler in `SubgraphNode.ts` mutates `widget.label`
and `input.label`, never `widget.name`, so `entityId` is stable across
renames. Documented as an invariant in `useResolvedSelectedInputs`.
Amp-Thread-ID: https://ampcode.com/threads/T-019e2260-c2ba-70b4-9962-1638be4bf645
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -8,6 +8,9 @@ import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
|
||||
import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import IoItem from '@/components/builder/IoItem.vue'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
|
||||
import type { ResolvedSelection } from '@/components/builder/useResolvedSelectedInputs'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
@@ -27,8 +30,6 @@ import { DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
import { renameWidget } from '@/utils/widgetUtil'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { findWidgetByEntityId } from '@/world/widgetLookup'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
type BoundStyle = { top: string; left: string; width: string; height: string }
|
||||
@@ -47,27 +48,8 @@ const hoveringSelectable = ref(false)
|
||||
|
||||
workflowStore.activeWorkflow?.changeTracker?.reset()
|
||||
|
||||
const inputsWithState = computed(() =>
|
||||
appModeStore.selectedInputs.map(([entityId]) => {
|
||||
const found =
|
||||
typeof entityId === 'string'
|
||||
? findWidgetByEntityId(app.rootGraph, entityId as WidgetEntityId)
|
||||
: undefined
|
||||
if (!found) {
|
||||
return {
|
||||
entityId: entityId as WidgetEntityId,
|
||||
subLabel: t('linearMode.builder.unknownWidget')
|
||||
}
|
||||
}
|
||||
const [node, widget] = found
|
||||
return {
|
||||
entityId: entityId as WidgetEntityId,
|
||||
label: widget.label,
|
||||
subLabel: node.title,
|
||||
canRename: true
|
||||
}
|
||||
})
|
||||
)
|
||||
const resolvedInputs = useResolvedSelectedInputs()
|
||||
|
||||
const outputsWithState = computed<[NodeId, string][]>(() =>
|
||||
appModeStore.selectedOutputs.map((nodeId) => [
|
||||
nodeId,
|
||||
@@ -75,13 +57,6 @@ const outputsWithState = computed<[NodeId, string][]>(() =>
|
||||
])
|
||||
)
|
||||
|
||||
function inlineRenameInput(entityId: WidgetEntityId, newLabel: string) {
|
||||
const found = findWidgetByEntityId(app.rootGraph, entityId)
|
||||
if (!found) return
|
||||
const [node, widget] = found
|
||||
renameWidget(widget, node, newLabel)
|
||||
}
|
||||
|
||||
function getHovered(
|
||||
e: MouseEvent
|
||||
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
|
||||
@@ -116,11 +91,10 @@ function getNodeBounding(nodeId: NodeId) {
|
||||
}
|
||||
}
|
||||
|
||||
function getWidgetBounding(entityId: WidgetEntityId) {
|
||||
function getWidgetBounding(entry: ResolvedSelection): BoundStyle | undefined {
|
||||
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
|
||||
const found = findWidgetByEntityId(app.rootGraph, entityId)
|
||||
if (!found) return
|
||||
const [node, widget] = found
|
||||
if (entry.status !== 'resolved') return undefined
|
||||
const { node, widget } = entry
|
||||
|
||||
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
|
||||
const marginX = margin ?? BaseWidget.margin
|
||||
@@ -136,6 +110,11 @@ function getWidgetBounding(entityId: WidgetEntityId) {
|
||||
}
|
||||
}
|
||||
|
||||
function removeSelectedEntityId(entityId: WidgetEntityId): void {
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
|
||||
if (index !== -1) appModeStore.selectedInputs.splice(index, 1)
|
||||
}
|
||||
|
||||
function handleDown(e: MouseEvent) {
|
||||
const [node] = getHovered(e) ?? []
|
||||
if (!node || e.button > 0) canvasInteractions.forwardEventToCanvas(e)
|
||||
@@ -191,15 +170,13 @@ const renderedOutputs = computed(() => {
|
||||
})
|
||||
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
() =>
|
||||
appModeStore.selectedInputs.flatMap(([entityId]) => {
|
||||
if (typeof entityId !== 'string') return []
|
||||
return [
|
||||
[entityId, getWidgetBounding(entityId as WidgetEntityId)] as [
|
||||
resolvedInputs.value.map(
|
||||
(entry) =>
|
||||
[entry.entityId, getWidgetBounding(entry)] as [
|
||||
string,
|
||||
MaybeRef<BoundStyle> | undefined
|
||||
]
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
@@ -243,21 +220,28 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedInputs"
|
||||
>
|
||||
<IoItem
|
||||
v-for="{ entityId, label, subLabel, canRename } in inputsWithState"
|
||||
:key="entityId"
|
||||
:class="
|
||||
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
|
||||
"
|
||||
:title="label ?? entityId"
|
||||
:sub-title="subLabel"
|
||||
:can-rename="canRename"
|
||||
:remove="
|
||||
() =>
|
||||
remove(appModeStore.selectedInputs, ([id]) => id === entityId)
|
||||
"
|
||||
@rename="inlineRenameInput(entityId, $event)"
|
||||
/>
|
||||
<template v-for="entry in resolvedInputs" :key="entry.entityId">
|
||||
<IoItem
|
||||
v-if="entry.status === 'resolved'"
|
||||
:class="
|
||||
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
|
||||
"
|
||||
:title="entry.widget.label ?? entry.entityId"
|
||||
:sub-title="entry.node.title"
|
||||
can-rename
|
||||
:remove="() => appModeStore.removeSelectedInput(entry.widget)"
|
||||
@rename="renameWidget(entry.widget, entry.node, $event)"
|
||||
/>
|
||||
<IoItem
|
||||
v-else
|
||||
:class="
|
||||
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
|
||||
"
|
||||
:title="entry.entityId"
|
||||
:sub-title="t('linearMode.builder.unknownWidget')"
|
||||
:remove="() => removeSelectedEntityId(entry.entityId)"
|
||||
/>
|
||||
</template>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
<div
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, provide, shallowRef, triggerRef } from 'vue'
|
||||
import { computed, provide } from 'vue'
|
||||
|
||||
import { useAppModeWidgetResizing } from '@/components/builder/useAppModeWidgetResizing'
|
||||
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
@@ -25,12 +25,9 @@ import { parseImageWidgetValue } from '@/utils/imageUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
import { promptRenameWidget } from '@/utils/widgetUtil'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { findWidgetByEntityId } from '@/world/widgetLookup'
|
||||
|
||||
interface WidgetEntry {
|
||||
key: string
|
||||
entityId: WidgetEntityId
|
||||
persistedHeight: number | undefined
|
||||
nodeData: ReturnType<typeof nodeToNodeData> & {
|
||||
widgets: NonNullable<ReturnType<typeof nodeToNodeData>['widgets']>
|
||||
@@ -48,38 +45,24 @@ const executionErrorStore = useExecutionErrorStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const maskEditor = useMaskEditor()
|
||||
|
||||
const { onPointerDown } = useAppModeWidgetResizing((entityId, config) =>
|
||||
appModeStore.updateInputConfig(entityId, config)
|
||||
const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
|
||||
appModeStore.updateInputConfig(widget, config)
|
||||
)
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(OverlayAppendToKey, 'body')
|
||||
|
||||
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
|
||||
useEventListener(
|
||||
app.rootGraph.events,
|
||||
'configured',
|
||||
() => (graphNodes.value = app.rootGraph.nodes)
|
||||
)
|
||||
useEventListener(app.rootGraph.events, 'node:slot-label:changed', () =>
|
||||
triggerRef(graphNodes)
|
||||
)
|
||||
const resolvedInputs = useResolvedSelectedInputs()
|
||||
|
||||
const mappedSelections = computed((): WidgetEntry[] => {
|
||||
void graphNodes.value
|
||||
const nodeDataByNode = new Map<
|
||||
LGraphNode,
|
||||
ReturnType<typeof nodeToNodeData>
|
||||
>()
|
||||
|
||||
return appModeStore.selectedInputs.flatMap(([entityId, , config]) => {
|
||||
if (typeof entityId !== 'string') return []
|
||||
const found = findWidgetByEntityId(
|
||||
app.rootGraph,
|
||||
entityId as WidgetEntityId
|
||||
)
|
||||
if (!found) return []
|
||||
const [node, widget] = found
|
||||
return resolvedInputs.value.flatMap((entry) => {
|
||||
if (entry.status !== 'resolved') return []
|
||||
const { entityId, node, widget, config } = entry
|
||||
if (node.mode !== LGraphEventMode.ALWAYS) return []
|
||||
|
||||
if (!nodeDataByNode.has(node)) {
|
||||
@@ -99,7 +82,6 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
return [
|
||||
{
|
||||
key: entityId,
|
||||
entityId: entityId as WidgetEntityId,
|
||||
persistedHeight: config?.height,
|
||||
nodeData: {
|
||||
...fullNodeData,
|
||||
@@ -166,13 +148,7 @@ defineExpose({ handleDragDrop })
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-for="{
|
||||
key,
|
||||
entityId,
|
||||
persistedHeight,
|
||||
nodeData,
|
||||
action
|
||||
} in mappedSelections"
|
||||
v-for="{ key, persistedHeight, nodeData, action } in mappedSelections"
|
||||
:key
|
||||
:class="
|
||||
cn(
|
||||
@@ -249,7 +225,7 @@ defineExpose({ handleDragDrop })
|
||||
)
|
||||
"
|
||||
:inert="builderMode || undefined"
|
||||
@pointerdown.capture="(e) => onPointerDown(entityId, e)"
|
||||
@pointerdown.capture="(e) => onPointerDown(action.widget, e)"
|
||||
>
|
||||
<DropZone
|
||||
:on-drag-over="nodeData.onDragOver"
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope } from 'vue'
|
||||
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
import { useAppModeWidgetResizing } from './useAppModeWidgetResizing'
|
||||
|
||||
const ENTITY_PROMPT = 'g:1:prompt' as WidgetEntityId
|
||||
const ENTITY_OTHER = 'g:2:other' as WidgetEntityId
|
||||
const ENTITY_IMAGE = 'g:1:image' as WidgetEntityId
|
||||
const WIDGET_PROMPT = { name: 'prompt' } as IBaseWidget
|
||||
const WIDGET_OTHER = { name: 'other' } as IBaseWidget
|
||||
const WIDGET_IMAGE = { name: 'image' } as IBaseWidget
|
||||
|
||||
function setHeight(el: HTMLElement, height: number) {
|
||||
Object.defineProperty(el, 'offsetHeight', {
|
||||
@@ -32,13 +32,13 @@ function wrapWithTextarea(initialHeight = 100): {
|
||||
describe('useAppModeWidgetResizing', () => {
|
||||
function setup() {
|
||||
const onResize =
|
||||
vi.fn<(entityId: WidgetEntityId, config: InputWidgetConfig) => void>()
|
||||
vi.fn<(widget: IBaseWidget, config: InputWidgetConfig) => void>()
|
||||
const { onPointerDown } = useAppModeWidgetResizing(onResize)
|
||||
|
||||
function bind(wrapper: HTMLElement, entityId: WidgetEntityId) {
|
||||
function bind(wrapper: HTMLElement, widget: IBaseWidget) {
|
||||
wrapper.addEventListener(
|
||||
'pointerdown',
|
||||
(e) => onPointerDown(entityId, e as PointerEvent),
|
||||
(e) => onPointerDown(widget, e as PointerEvent),
|
||||
{ capture: true }
|
||||
)
|
||||
}
|
||||
@@ -49,19 +49,19 @@ describe('useAppModeWidgetResizing', () => {
|
||||
it('persists height when textarea is resized via drag', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, ENTITY_PROMPT)
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(textarea, 250)
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledWith(ENTITY_PROMPT, { height: 250 })
|
||||
expect(onResize).toHaveBeenCalledWith(WIDGET_PROMPT, { height: 250 })
|
||||
})
|
||||
|
||||
it('does not persist when no height change occurs (e.g. a click)', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, ENTITY_PROMPT)
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
@@ -72,7 +72,7 @@ describe('useAppModeWidgetResizing', () => {
|
||||
it('persists once per drag gesture; stray pointerup is a no-op', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, ENTITY_PROMPT)
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(textarea, 250)
|
||||
@@ -88,7 +88,7 @@ describe('useAppModeWidgetResizing', () => {
|
||||
const button = document.createElement('button')
|
||||
wrapper.appendChild(button)
|
||||
document.body.appendChild(wrapper)
|
||||
bind(wrapper, ENTITY_PROMPT)
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
|
||||
button.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
@@ -106,21 +106,21 @@ describe('useAppModeWidgetResizing', () => {
|
||||
wrapper.appendChild(indicator)
|
||||
document.body.appendChild(wrapper)
|
||||
setHeight(indicator, 100)
|
||||
bind(wrapper, ENTITY_IMAGE)
|
||||
bind(wrapper, WIDGET_IMAGE)
|
||||
|
||||
inner.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(indicator, 250)
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledWith(ENTITY_IMAGE, { height: 250 })
|
||||
expect(onResize).toHaveBeenCalledWith(WIDGET_IMAGE, { height: 250 })
|
||||
})
|
||||
|
||||
it('drops a stale gesture when a new pointerdown starts before pointerup arrives', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const first = wrapWithTextarea()
|
||||
const second = wrapWithTextarea()
|
||||
bind(first.wrapper, ENTITY_PROMPT)
|
||||
bind(second.wrapper, ENTITY_OTHER)
|
||||
bind(first.wrapper, WIDGET_PROMPT)
|
||||
bind(second.wrapper, WIDGET_OTHER)
|
||||
|
||||
first.textarea.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true })
|
||||
@@ -134,25 +134,25 @@ describe('useAppModeWidgetResizing', () => {
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledTimes(1)
|
||||
expect(onResize).toHaveBeenCalledWith(ENTITY_OTHER, { height: 300 })
|
||||
expect(onResize).toHaveBeenCalledWith(WIDGET_OTHER, { height: 300 })
|
||||
})
|
||||
|
||||
it('treats pointercancel as the end of a gesture and persists the new height', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, ENTITY_PROMPT)
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(textarea, 250)
|
||||
window.dispatchEvent(new PointerEvent('pointercancel'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledWith(ENTITY_PROMPT, { height: 250 })
|
||||
expect(onResize).toHaveBeenCalledWith(WIDGET_PROMPT, { height: 250 })
|
||||
})
|
||||
|
||||
it('after pointercancel, a subsequent stray pointerup is a no-op', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, ENTITY_PROMPT)
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(textarea, 250)
|
||||
@@ -161,12 +161,12 @@ describe('useAppModeWidgetResizing', () => {
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledTimes(1)
|
||||
expect(onResize).toHaveBeenCalledWith(ENTITY_PROMPT, { height: 250 })
|
||||
expect(onResize).toHaveBeenCalledWith(WIDGET_PROMPT, { height: 250 })
|
||||
})
|
||||
|
||||
it('removes global listeners when the owning scope is disposed mid-gesture', () => {
|
||||
const onResize =
|
||||
vi.fn<(entityId: WidgetEntityId, config: InputWidgetConfig) => void>()
|
||||
vi.fn<(widget: IBaseWidget, config: InputWidgetConfig) => void>()
|
||||
const scope = effectScope()
|
||||
const { onPointerDown } = scope.run(() =>
|
||||
useAppModeWidgetResizing(onResize)
|
||||
@@ -174,7 +174,7 @@ describe('useAppModeWidgetResizing', () => {
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
wrapper.addEventListener(
|
||||
'pointerdown',
|
||||
(e) => onPointerDown(ENTITY_PROMPT, e as PointerEvent),
|
||||
(e) => onPointerDown(WIDGET_PROMPT, e as PointerEvent),
|
||||
{ capture: true }
|
||||
)
|
||||
|
||||
@@ -199,7 +199,7 @@ describe('useAppModeWidgetResizing', () => {
|
||||
outerIndicator.appendChild(wrapper)
|
||||
document.body.appendChild(outerIndicator)
|
||||
setHeight(outerIndicator, 100)
|
||||
bind(wrapper, ENTITY_PROMPT)
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
|
||||
inner.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(outerIndicator, 250)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { onScopeDispose } from 'vue'
|
||||
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
const RESIZABLE_SELECTOR = 'textarea, [data-slot="drop-zone-indicator"]'
|
||||
|
||||
export function useAppModeWidgetResizing(
|
||||
onResize: (entityId: WidgetEntityId, config: InputWidgetConfig) => void
|
||||
onResize: (widget: IBaseWidget, config: InputWidgetConfig) => void
|
||||
) {
|
||||
let pendingHandler: (() => void) | null = null
|
||||
|
||||
@@ -19,7 +19,7 @@ export function useAppModeWidgetResizing(
|
||||
|
||||
onScopeDispose(clearPendingHandler)
|
||||
|
||||
function onPointerDown(entityId: WidgetEntityId, event: PointerEvent) {
|
||||
function onPointerDown(widget: IBaseWidget, event: PointerEvent) {
|
||||
const wrapper = event.currentTarget
|
||||
const target = event.target
|
||||
if (!(wrapper instanceof HTMLElement) || !(target instanceof HTMLElement))
|
||||
@@ -36,7 +36,7 @@ export function useAppModeWidgetResizing(
|
||||
pendingHandler = null
|
||||
const height = resizable.offsetHeight
|
||||
if (height === startHeight) return
|
||||
onResize(entityId, { height })
|
||||
onResize(widget, { height })
|
||||
}
|
||||
pendingHandler = handler
|
||||
window.addEventListener('pointerup', handler)
|
||||
|
||||
90
src/components/builder/useResolvedSelectedInputs.ts
Normal file
90
src/components/builder/useResolvedSelectedInputs.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, shallowRef, triggerRef } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { isWidgetEntityId, parseWidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
/**
|
||||
* A `selectedInputs` entry resolved against the live graph. Resolved entries
|
||||
* carry the live `node`/`widget` references; unresolved entries (whose target
|
||||
* node or widget has been removed) are surfaced as `status: 'unknown'` so the
|
||||
* UI can render a "remove dangling selection" affordance instead of silently
|
||||
* dropping the row.
|
||||
*/
|
||||
export type ResolvedSelection =
|
||||
| {
|
||||
status: 'resolved'
|
||||
entityId: WidgetEntityId
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
displayName: string
|
||||
config?: InputWidgetConfig
|
||||
}
|
||||
| {
|
||||
status: 'unknown'
|
||||
entityId: WidgetEntityId
|
||||
displayName: string
|
||||
config?: InputWidgetConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `appModeStore.selectedInputs` to live `(node, widget)` pairs.
|
||||
*
|
||||
* The persisted shape is `[WidgetEntityId, displayName, InputWidgetConfig?]`;
|
||||
* this projection layers the live widget/node references on top of each entry
|
||||
* so handlers (rename, remove, resize, bounding) can act on widget instances
|
||||
* directly without re-walking the graph.
|
||||
*
|
||||
* Reactivity sources:
|
||||
* - `appModeStore.selectedInputs` (deep-reactive ref)
|
||||
* - root graph node list (tracked via shallow ref + `configured` event)
|
||||
* - subgraph slot label changes (tracked via `node:slot-label:changed`)
|
||||
*
|
||||
* NOTE: A `WidgetEntityId` encodes `${graphId}:${nodeId}:${widgetName}`. Slot
|
||||
* label renames mutate `widget.label` but not `widget.name` (see
|
||||
* `SubgraphNode.ts`'s `renaming-input` handler), so the entityId remains stable
|
||||
* across renames. If a future code path mutates `widget.name`, persisted
|
||||
* entries would silently drop here; rewiring would need a new event.
|
||||
* TODO: revisit if such a path is introduced.
|
||||
*/
|
||||
export function useResolvedSelectedInputs() {
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph?.nodes ?? [])
|
||||
useEventListener(
|
||||
() => app.rootGraph?.events,
|
||||
'configured',
|
||||
() => (graphNodes.value = app.rootGraph?.nodes ?? [])
|
||||
)
|
||||
useEventListener(
|
||||
() => app.rootGraph?.events,
|
||||
'node:slot-label:changed',
|
||||
() => triggerRef(graphNodes)
|
||||
)
|
||||
|
||||
return computed<ResolvedSelection[]>(() => {
|
||||
void graphNodes.value
|
||||
const rootGraph = app.rootGraph
|
||||
if (!rootGraph) return []
|
||||
|
||||
return appModeStore.selectedInputs.flatMap(
|
||||
([entityId, displayName, config]): ResolvedSelection[] => {
|
||||
if (!isWidgetEntityId(entityId)) return []
|
||||
const { nodeId, name } = parseWidgetEntityId(entityId)
|
||||
const node = rootGraph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.name === name)
|
||||
if (!node || !widget) {
|
||||
return [{ status: 'unknown', entityId, displayName, config }]
|
||||
}
|
||||
return [
|
||||
{ status: 'resolved', entityId, node, widget, displayName, config }
|
||||
]
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { ComfyWorkflow as ComfyWorkflowClass } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -441,11 +442,13 @@ describe('appModeStore', () => {
|
||||
describe('updateInputConfig', () => {
|
||||
const entity = 'g:1:prompt' as WidgetEntityId
|
||||
const otherEntity = 'g:99:prompt' as WidgetEntityId
|
||||
const widget = fromAny<IBaseWidget, unknown>({ entityId: entity })
|
||||
const otherWidget = fromAny<IBaseWidget, unknown>({ entityId: otherEntity })
|
||||
|
||||
it('sets config on an existing input', () => {
|
||||
store.selectedInputs.push([entity, 'prompt'])
|
||||
|
||||
store.updateInputConfig(entity, { height: 200 })
|
||||
store.updateInputConfig(widget, { height: 200 })
|
||||
|
||||
expect(store.selectedInputs[0][2]).toEqual({ height: 200 })
|
||||
})
|
||||
@@ -453,7 +456,18 @@ describe('appModeStore', () => {
|
||||
it('is a no-op when entry is not found', () => {
|
||||
store.selectedInputs.push([entity, 'prompt'])
|
||||
|
||||
store.updateInputConfig(otherEntity, { height: 200 })
|
||||
store.updateInputConfig(otherWidget, { height: 200 })
|
||||
|
||||
expect(store.selectedInputs[0][2]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('is a no-op when the widget has no entityId', () => {
|
||||
store.selectedInputs.push([entity, 'prompt'])
|
||||
|
||||
store.updateInputConfig(
|
||||
fromAny<IBaseWidget, unknown>({ entityId: undefined }),
|
||||
{ height: 200 }
|
||||
)
|
||||
|
||||
expect(store.selectedInputs[0][2]).toBeUndefined()
|
||||
})
|
||||
@@ -463,7 +477,7 @@ describe('appModeStore', () => {
|
||||
store.selectedInputs.push([entity, 'prompt'])
|
||||
await nextTick()
|
||||
|
||||
store.updateInputConfig(entity, { height: 300 })
|
||||
store.updateInputConfig(widget, { height: 300 })
|
||||
await nextTick()
|
||||
|
||||
expect(app.rootGraph.extra.linearData).toEqual({
|
||||
|
||||
@@ -18,11 +18,31 @@ import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { resolveNode, resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { isWidgetEntityId } from '@/world/entityIds'
|
||||
import { findWidgetByEntityId } from '@/world/widgetLookup'
|
||||
|
||||
/**
|
||||
* Resolve a widget by its canonical {@link WidgetEntityId} within the given
|
||||
* root graph. Identity comparison is purely structural —
|
||||
* `widget.entityId === entityId` — so producers and consumers never disagree
|
||||
* about which widget an id refers to.
|
||||
*
|
||||
* This is the single load-time resolution path; render-time consumers should
|
||||
* project `selectedInputs` through `useResolvedSelectedInputs` instead.
|
||||
*/
|
||||
function findWidgetByEntityId(
|
||||
rootGraph: LGraph,
|
||||
entityId: WidgetEntityId
|
||||
): IBaseWidget | undefined {
|
||||
for (const node of rootGraph.nodes) {
|
||||
const widget = node.widgets?.find((w) => w.entityId === entityId)
|
||||
if (widget) return widget
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function nodeTypeValidForApp(type: string) {
|
||||
return !['Note', 'MarkdownNote'].includes(type)
|
||||
@@ -76,8 +96,8 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
|
||||
// Canonical: storedId is already a WidgetEntityId.
|
||||
if (typeof storedId === 'string' && isWidgetEntityId(storedId)) {
|
||||
const found = findWidgetByEntityId(rootGraph, storedId)
|
||||
return found ? buildEntry(storedId, widgetName, config) : null
|
||||
const widget = findWidgetByEntityId(rootGraph, storedId)
|
||||
return widget ? buildEntry(storedId, widgetName, config) : null
|
||||
}
|
||||
|
||||
// Legacy NodeLocatorId (`graphId:nodeId`) for promoted widgets.
|
||||
@@ -229,11 +249,10 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
if (index !== -1) selectedInputs.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function updateInputConfig(
|
||||
entityId: WidgetEntityId,
|
||||
config: InputWidgetConfig
|
||||
) {
|
||||
const entry = selectedInputs.value.find(([id]) => id === entityId)
|
||||
function updateInputConfig(widget: IBaseWidget, config: InputWidgetConfig) {
|
||||
const targetEntityId = widget.entityId
|
||||
if (!targetEntityId) return
|
||||
const entry = selectedInputs.value.find(([id]) => id === targetEntityId)
|
||||
if (!entry) return
|
||||
entry[2] = { ...entry[2], ...config }
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import type { WidgetEntityId } from './entityIds'
|
||||
|
||||
/**
|
||||
* Resolve a widget by its canonical {@link WidgetEntityId} within a root graph.
|
||||
*
|
||||
* Returns the host node and widget the entity id describes. For promoted
|
||||
* widgets the host is the SubgraphNode and the widget is its `PromotedWidgetView`;
|
||||
* for plain widgets the host is the owning node and the widget is its own
|
||||
* `IBaseWidget` instance.
|
||||
*
|
||||
* Identity comparison is purely structural — `widget.entityId === entityId` —
|
||||
* so producers and consumers can never disagree about which widget an id refers
|
||||
* to.
|
||||
*/
|
||||
export function findWidgetByEntityId(
|
||||
rootGraph: LGraph,
|
||||
entityId: WidgetEntityId
|
||||
): [LGraphNode, IBaseWidget] | undefined {
|
||||
for (const node of rootGraph.nodes) {
|
||||
const widget = node.widgets?.find((w) => w.entityId === entityId)
|
||||
if (widget) return [node, widget]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
Reference in New Issue
Block a user