mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
Compare commits
2 Commits
version-bu
...
vue-nodes/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc168c00d2 | ||
|
|
010b7eefd8 |
@@ -286,6 +286,7 @@
|
||||
--component-node-widget-background-highlighted: var(--color-ash-500);
|
||||
--component-node-widget-promoted: var(--color-purple-700);
|
||||
--component-node-widget-advanced: var(--color-azure-400);
|
||||
--component-node-widget-linked: var(--color-azure-400);
|
||||
|
||||
/* Default UI element color palette variables */
|
||||
--palette-contrast-mix-color: #fff;
|
||||
@@ -450,6 +451,7 @@
|
||||
--component-node-widget-background-highlighted: var(--color-graphite-400);
|
||||
--component-node-widget-promoted: var(--color-purple-700);
|
||||
--component-node-widget-advanced: var(--color-azure-600);
|
||||
--component-node-widget-linked: var(--color-azure-600);
|
||||
|
||||
--modal-card-background: var(--secondary-background);
|
||||
--modal-card-background-hovered: var(--secondary-background-hover);
|
||||
@@ -573,6 +575,7 @@
|
||||
);
|
||||
--color-component-node-widget-promoted: var(--component-node-widget-promoted);
|
||||
--color-component-node-widget-advanced: var(--component-node-widget-advanced);
|
||||
--color-component-node-widget-linked: var(--component-node-widget-linked);
|
||||
|
||||
/* Semantic tokens */
|
||||
--color-base-foreground: var(--base-foreground);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { reactive } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
SafeWidgetData,
|
||||
@@ -9,6 +10,32 @@ import type {
|
||||
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
|
||||
const mockDragState = reactive({
|
||||
active: false,
|
||||
pointerId: null as number | null,
|
||||
source: null,
|
||||
pointer: { client: { x: 0, y: 0 }, canvas: { x: 0, y: 0 } },
|
||||
candidate: null as {
|
||||
layout: { nodeId: string; index: number; type: string }
|
||||
compatible: boolean
|
||||
} | null,
|
||||
compatible: new Map<string, boolean>()
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/canvas/links/slotLinkDragUIState', () => ({
|
||||
useSlotLinkDragUIState: () => ({
|
||||
state: mockDragState,
|
||||
beginDrag: vi.fn(),
|
||||
endDrag: vi.fn(),
|
||||
updatePointerPosition: vi.fn(),
|
||||
setCandidate: vi.fn(),
|
||||
getSlotLayout: vi.fn(),
|
||||
setCompatibleMap: vi.fn(),
|
||||
setCompatibleForKey: vi.fn(),
|
||||
clearCompatible: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
describe('NodeWidgets', () => {
|
||||
const createMockWidget = (
|
||||
overrides: Partial<SafeWidgetData> = {}
|
||||
@@ -39,6 +66,16 @@ describe('NodeWidgets', () => {
|
||||
outputs: []
|
||||
})
|
||||
|
||||
function resetDragState() {
|
||||
mockDragState.active = false
|
||||
mockDragState.candidate = null
|
||||
mockDragState.compatible.clear()
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetDragState()
|
||||
})
|
||||
|
||||
const mountComponent = (nodeData?: VueNodeData) => {
|
||||
return mount(NodeWidgets, {
|
||||
props: {
|
||||
@@ -117,4 +154,196 @@ describe('NodeWidgets', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('drag hover indicator', () => {
|
||||
it('applies ring class when dragging a compatible link over a widget slot', () => {
|
||||
const widget = createMockWidget({
|
||||
slotMetadata: { index: 0, linked: false }
|
||||
})
|
||||
const nodeData = createMockNodeData('TestNode', [widget])
|
||||
|
||||
mockDragState.active = true
|
||||
mockDragState.candidate = {
|
||||
layout: { nodeId: '1', index: 0, type: 'input' },
|
||||
compatible: true
|
||||
}
|
||||
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const widgetEl = wrapper.find('.lg-node-widget')
|
||||
expect(widgetEl.classes()).toContain('ring')
|
||||
expect(widgetEl.classes()).toContain('ring-component-node-widget-linked')
|
||||
})
|
||||
|
||||
it('does not apply ring class when candidate is incompatible', () => {
|
||||
const widget = createMockWidget({
|
||||
slotMetadata: { index: 0, linked: false }
|
||||
})
|
||||
const nodeData = createMockNodeData('TestNode', [widget])
|
||||
|
||||
mockDragState.active = true
|
||||
mockDragState.candidate = {
|
||||
layout: { nodeId: '1', index: 0, type: 'input' },
|
||||
compatible: false
|
||||
}
|
||||
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const widgetEl = wrapper.find('.lg-node-widget')
|
||||
expect(widgetEl.classes()).not.toContain('ring')
|
||||
})
|
||||
|
||||
it('does not apply ring class when candidate targets a different node', () => {
|
||||
const widget = createMockWidget({
|
||||
slotMetadata: { index: 0, linked: false }
|
||||
})
|
||||
const nodeData = createMockNodeData('TestNode', [widget])
|
||||
|
||||
mockDragState.active = true
|
||||
mockDragState.candidate = {
|
||||
layout: { nodeId: '99', index: 0, type: 'input' },
|
||||
compatible: true
|
||||
}
|
||||
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const widgetEl = wrapper.find('.lg-node-widget')
|
||||
expect(widgetEl.classes()).not.toContain('ring')
|
||||
})
|
||||
|
||||
it('does not apply ring class when no drag is active', () => {
|
||||
const widget = createMockWidget({
|
||||
slotMetadata: { index: 0, linked: false }
|
||||
})
|
||||
const nodeData = createMockNodeData('TestNode', [widget])
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const widgetEl = wrapper.find('.lg-node-widget')
|
||||
expect(widgetEl.classes()).not.toContain('ring')
|
||||
})
|
||||
|
||||
it('does not apply ring class when widget has no slotMetadata', () => {
|
||||
const widget = createMockWidget({ slotMetadata: undefined })
|
||||
const nodeData = createMockNodeData('TestNode', [widget])
|
||||
|
||||
mockDragState.active = true
|
||||
mockDragState.candidate = {
|
||||
layout: { nodeId: '1', index: 0, type: 'input' },
|
||||
compatible: true
|
||||
}
|
||||
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const widgetEl = wrapper.find('.lg-node-widget')
|
||||
expect(widgetEl.classes()).not.toContain('ring')
|
||||
expect(widgetEl.classes()).not.toContain('border-l-2')
|
||||
})
|
||||
|
||||
it('does not apply ring class when candidate targets an output slot', () => {
|
||||
const widget = createMockWidget({
|
||||
slotMetadata: { index: 0, linked: false }
|
||||
})
|
||||
const nodeData = createMockNodeData('TestNode', [widget])
|
||||
|
||||
mockDragState.active = true
|
||||
mockDragState.candidate = {
|
||||
layout: { nodeId: '1', index: 0, type: 'output' },
|
||||
compatible: true
|
||||
}
|
||||
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const widgetEl = wrapper.find('.lg-node-widget')
|
||||
expect(widgetEl.classes()).not.toContain('ring')
|
||||
})
|
||||
})
|
||||
|
||||
describe('connected state indicator', () => {
|
||||
it('applies border class when widget slot is linked', () => {
|
||||
const widget = createMockWidget({
|
||||
slotMetadata: { index: 0, linked: true }
|
||||
})
|
||||
const nodeData = createMockNodeData('TestNode', [widget])
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const widgetEl = wrapper.find('.lg-node-widget')
|
||||
expect(widgetEl.classes()).toContain('border-l-2')
|
||||
expect(widgetEl.classes()).toContain(
|
||||
'border-component-node-widget-linked'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not apply border class when widget slot is not linked', () => {
|
||||
const widget = createMockWidget({
|
||||
slotMetadata: { index: 0, linked: false }
|
||||
})
|
||||
const nodeData = createMockNodeData('TestNode', [widget])
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const widgetEl = wrapper.find('.lg-node-widget')
|
||||
expect(widgetEl.classes()).not.toContain('border-l-2')
|
||||
})
|
||||
|
||||
it('prefers drag hover ring over connected border when both apply', () => {
|
||||
const widget = createMockWidget({
|
||||
slotMetadata: { index: 0, linked: true }
|
||||
})
|
||||
const nodeData = createMockNodeData('TestNode', [widget])
|
||||
|
||||
mockDragState.active = true
|
||||
mockDragState.candidate = {
|
||||
layout: { nodeId: '1', index: 0, type: 'input' },
|
||||
compatible: true
|
||||
}
|
||||
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const widgetEl = wrapper.find('.lg-node-widget')
|
||||
expect(widgetEl.classes()).toContain('ring')
|
||||
expect(widgetEl.classes()).not.toContain('border-l-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('slot dot visibility during drag', () => {
|
||||
it('shows slot dots when drag is active', () => {
|
||||
const widget = createMockWidget({
|
||||
slotMetadata: { index: 0, linked: false }
|
||||
})
|
||||
const nodeData = createMockNodeData('TestNode', [widget])
|
||||
|
||||
mockDragState.active = true
|
||||
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const dotContainer = wrapper.find('.lg-node-widget > div:first-child')
|
||||
expect(dotContainer.classes()).toContain('opacity-100')
|
||||
expect(dotContainer.classes()).not.toContain('opacity-0')
|
||||
})
|
||||
|
||||
it('hides slot dots when no drag is active and not linked', () => {
|
||||
const widget = createMockWidget({
|
||||
slotMetadata: { index: 0, linked: false }
|
||||
})
|
||||
const nodeData = createMockNodeData('TestNode', [widget])
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const dotContainer = wrapper.find('.lg-node-widget > div:first-child')
|
||||
expect(dotContainer.classes()).toContain('opacity-0')
|
||||
})
|
||||
|
||||
it('shows slot dots when linked even without drag', () => {
|
||||
const widget = createMockWidget({
|
||||
slotMetadata: { index: 0, linked: true }
|
||||
})
|
||||
const nodeData = createMockNodeData('TestNode', [widget])
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const dotContainer = wrapper.find('.lg-node-widget > div:first-child')
|
||||
expect(dotContainer.classes()).toContain('opacity-100')
|
||||
expect(dotContainer.classes()).not.toContain('opacity-0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointer-events during drag', () => {
|
||||
it('enables pointer-events on widget container during drag', () => {
|
||||
const widget = createMockWidget({
|
||||
slotMetadata: { index: 0, linked: false }
|
||||
})
|
||||
const nodeData = createMockNodeData('TestNode', [widget])
|
||||
|
||||
mockDragState.active = true
|
||||
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const container = wrapper.find('.lg-node-widgets')
|
||||
expect(container.classes()).toContain('pointer-events-auto')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
:class="
|
||||
cn(
|
||||
'lg-node-widgets grid grid-cols-[min-content_minmax(80px,min-content)_minmax(125px,1fr)] gap-y-1 pr-3',
|
||||
shouldHandleNodePointerEvents
|
||||
shouldHandleNodePointerEvents || dragState.active
|
||||
? 'pointer-events-auto'
|
||||
: 'pointer-events-none'
|
||||
)
|
||||
@@ -27,15 +27,26 @@
|
||||
>
|
||||
<div
|
||||
v-if="!widget.hidden && (!widget.advanced || showAdvanced)"
|
||||
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
|
||||
:class="
|
||||
cn(
|
||||
'lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch',
|
||||
widget.isDragHoverTarget &&
|
||||
'ring ring-component-node-widget-linked',
|
||||
!widget.isDragHoverTarget &&
|
||||
widget.slotMetadata?.linked &&
|
||||
'border-l-2 border-component-node-widget-linked'
|
||||
)
|
||||
"
|
||||
:data-widget-name="widget.name"
|
||||
>
|
||||
<!-- Widget Input Slot Dot -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'z-10 w-3 opacity-0 transition-opacity duration-150 group-hover:opacity-100 flex items-stretch',
|
||||
widget.slotMetadata?.linked && 'opacity-100'
|
||||
'z-10 w-3 transition-opacity duration-150 flex items-stretch',
|
||||
dragState.active || widget.slotMetadata?.linked
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100'
|
||||
)
|
||||
"
|
||||
>
|
||||
@@ -86,6 +97,7 @@ import type {
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { st } from '@/i18n'
|
||||
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
|
||||
@@ -117,6 +129,19 @@ const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
|
||||
useCanvasInteractions()
|
||||
const { bringNodeToFront } = useNodeZIndex()
|
||||
const executionStore = useExecutionStore()
|
||||
const { state: dragState } = useSlotLinkDragUIState()
|
||||
|
||||
function isDragHoverTarget(slotMetadata?: WidgetSlotMetadata): boolean {
|
||||
if (!dragState.active || !dragState.candidate?.compatible || !slotMetadata)
|
||||
return false
|
||||
if (nodeData?.id == null) return false
|
||||
const { layout } = dragState.candidate
|
||||
return (
|
||||
layout.type === 'input' &&
|
||||
layout.nodeId === String(nodeData.id) &&
|
||||
layout.index === slotMetadata.index
|
||||
)
|
||||
}
|
||||
|
||||
function handleWidgetPointerEvent(event: PointerEvent) {
|
||||
if (shouldHandleNodePointerEvents.value) return
|
||||
@@ -158,6 +183,7 @@ interface ProcessedWidget {
|
||||
hasLayoutSize: boolean
|
||||
hasError: boolean
|
||||
hidden: boolean
|
||||
isDragHoverTarget: boolean
|
||||
name: string
|
||||
simplified: SimplifiedWidget
|
||||
tooltipConfig: TooltipOptions
|
||||
@@ -237,6 +263,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
(error) => error.extra_info?.input_name === widget.name
|
||||
) ?? false,
|
||||
hidden: widget.options?.hidden ?? false,
|
||||
isDragHoverTarget: isDragHoverTarget(slotMetadata),
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
vueComponent,
|
||||
|
||||
Reference in New Issue
Block a user