feat: add visual indicators for widget slot drag hover and connected state

Add ring highlight when dragging a compatible link over a widget's input
slot, and a left border when a widget has an active link connected.
Drag hover indicator takes priority over connected border to avoid
visual conflict.

- Add --component-node-widget-linked design token (azure-400/600)
- Import useSlotLinkDragUIState in NodeWidgets.vue
- Precompute isDragHoverTarget in processedWidgets computed
- Add 6 tests covering drag hover and connected state indicators

Amp-Thread-ID: https://ampcode.com/threads/T-019c7956-9ecf-722c-89f0-1151094e5851
This commit is contained in:
bymyself
2026-02-19 20:39:18 -08:00
parent 38edba7024
commit 010b7eefd8
3 changed files with 207 additions and 2 deletions

View File

@@ -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);

View File

@@ -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,144 @@ 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')
})
})
})

View File

@@ -27,7 +27,16 @@
>
<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 -->
@@ -86,6 +95,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 +127,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 +181,7 @@ interface ProcessedWidget {
hasLayoutSize: boolean
hasError: boolean
hidden: boolean
isDragHoverTarget: boolean
name: string
simplified: SimplifiedWidget
tooltipConfig: TooltipOptions
@@ -237,6 +261,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,