Compare commits

...

2 Commits

Author SHA1 Message Date
bymyself
cc168c00d2 fix: enable widget slot visibility and pointer-events during link drag
The widget input slot dots were invisible (opacity-0) and the widget
container had pointer-events-none during drag, preventing
elementFromPoint() from finding slots for hit-testing.

- Force pointer-events-auto on widget container when dragState.active
- Show slot dots (opacity-100) when drag is active or slot is linked
- Add tests for slot dot visibility and pointer-events during drag

Amp-Thread-ID: https://ampcode.com/threads/T-019c796d-1b1d-70c3-84da-dbc482d1e342
2026-02-20 02:22:14 -08:00
bymyself
010b7eefd8 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
2026-02-19 20:39:18 -08:00
3 changed files with 264 additions and 5 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,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')
})
})
})

View File

@@ -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,