Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 42 additions & 11 deletions src/renderer/extensions/vueNodes/components/ImagePreview.vue
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
<template>
<div
v-if="imageUrls.length > 0"
class="image-preview outline-none group relative flex size-full min-h-16 min-w-16 flex-col px-2 justify-center"
tabindex="0"
role="region"
:aria-label="$t('g.imagePreview')"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@keydown="handleKeyDown"
class="image-preview group relative flex size-full min-h-16 min-w-16 flex-col px-2 justify-center"
>
<!-- Image Wrapper -->
<div
class="h-full w-full overflow-hidden rounded-[5px] bg-node-component-surface relative"
ref="imageWrapperEl"
class="h-full w-full overflow-hidden rounded-[5px] bg-muted-background relative"
tabindex="0"
role="img"
:aria-label="$t('g.imagePreview')"
:aria-busy="showLoader"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@focusin="handleFocusIn"
@focusout="handleFocusOut"
>
<!-- Error State -->
<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"
>
<i
Expand Down Expand Up @@ -43,8 +47,11 @@
@error="handleImageError"
/>

<!-- Floating Action Buttons (appear on hover) -->
<div v-if="isHovered" class="actions absolute top-2 right-2 flex gap-2.5">
<!-- Floating Action Buttons (appear on hover and focus) -->
<div
v-if="isHovered || isFocused"
class="actions absolute top-2 right-2 flex gap-2.5"
>
<!-- Mask/Edit Button -->
<button
v-if="!hasMultipleImages"
Expand Down Expand Up @@ -96,6 +103,7 @@
v-for="(_, index) in imageUrls"
:key="index"
:class="getNavigationDotClass(index)"
:aria-current="index === currentIndex ? 'true' : undefined"
:aria-label="
$t('g.viewImageOfTotal', {
index: index + 1,
Expand All @@ -112,7 +120,8 @@
import { useTimeoutFn } from '@vueuse/core'
import { useToast } from 'primevue'
import Skeleton from 'primevue/skeleton'
import { computed, ref, watch } from 'vue'
import type { ShallowRef } from 'vue'
import { computed, inject, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'

import { downloadFile } from '@/base/common/downloadUtil'
Expand All @@ -139,11 +148,13 @@ const actionButtonClass =
// Component state
const currentIndex = ref(0)
const isHovered = ref(false)
const isFocused = ref(false)
const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
const showLoader = ref(false)

const currentImageEl = ref<HTMLImageElement>()
const imageWrapperEl = ref<HTMLDivElement>()

const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
() => {
Expand All @@ -159,6 +170,15 @@ const currentImageUrl = computed(() => props.imageUrls[currentIndex.value])
const hasMultipleImages = computed(() => props.imageUrls.length > 1)
const imageAltText = computed(() => `Node output ${currentIndex.value + 1}`)

const keyEvent = inject<ShallowRef<KeyboardEvent | null>>('keyEvent')

if (keyEvent) {
watch(keyEvent, (e) => {
if (!e) return
handleKeyDown(e)
})
}

// Watch for URL changes and reset state
watch(
() => props.imageUrls,
Expand Down Expand Up @@ -247,6 +267,17 @@ const handleMouseLeave = () => {
isHovered.value = false
}

const handleFocusIn = () => {
isFocused.value = true
}

const handleFocusOut = (event: FocusEvent) => {
// Only unfocus if focus is leaving the wrapper entirely
if (!imageWrapperEl.value?.contains(event.relatedTarget as Node)) {
isFocused.value = false
}
}

const getNavigationDotClass = (index: number) => {
return [
'w-2 h-2 rounded-full transition-all duration-200 border-0 cursor-pointer p-0',
Expand Down
22 changes: 20 additions & 2 deletions src/renderer/extensions/vueNodes/components/LGraphNode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<div
v-else
ref="nodeContainerRef"
tabindex="0"
:data-node-id="nodeData.id"
:class="
cn(
Expand All @@ -16,7 +17,7 @@
// hover (only when node should handle events)
shouldHandleNodePointerEvents &&
'hover:ring-7 ring-node-component-ring',
'outline-transparent outline-2',
'outline-transparent outline-2 focus-visible:outline-node-component-outline',
borderClass,
outlineClass,
cursorClass,
Expand Down Expand Up @@ -48,6 +49,7 @@
@dragover.prevent="handleDragOver"
@dragleave="handleDragLeave"
@drop.stop.prevent="handleDrop"
@keydown="handleNodeKeydown"
>
Comment on lines +52 to 53
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard node-level key forwarding so arrow keys in inputs/widgets don’t trigger gallery navigation.
Because keydown bubbles, typing/caret navigation inside inputs within the node can still set keyEvent, which descendants may treat as “navigate preview”.

Concrete guard (also reduces pointless updates for keys nobody consumes):

 const handleNodeKeydown = (event: KeyboardEvent) => {
-  keyEvent.value = event
+  // Don’t steal cursor keys from inputs / editable content inside the node
+  const target = event.target as HTMLElement | null
+  if (
+    target?.closest(
+      'input, textarea, select, [contenteditable="true"], [role="textbox"]'
+    )
+  ) {
+    return
+  }
+
+  // Optional: only forward keys preview cares about
+  // if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return
+
+  keyEvent.value = event
 }

Based on learnings, this still preserves the intended “node-focused navigation” behavior while avoiding accidental interception.

Also applies to: 214-216

🤖 Prompt for AI Agents
In src/renderer/extensions/vueNodes/components/LGraphNode.vue around lines 52-53
(also apply same change to lines 214-216), the node-level keydown handler
forwards all key events and unintentionally treats input/caret navigation inside
descendant inputs/widgets as navigation; modify handleNodeKeydown so it first
inspects event.target (or the first non-shadow composedPath entry) and returns
early if the target is an editable element (input, textarea, select,
[contenteditable="true"], or role="textbox") or if event.defaultPrevented; then
only set/forward keyEvent for non-editable targets (and still restrict to the
navigation keys you care about) to avoid needless updates and prevent arrow keys
inside inputs from triggering gallery navigation.

<div class="flex flex-col justify-center items-center relative">
<template v-if="isCollapsed">
Expand Down Expand Up @@ -130,7 +132,16 @@

<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, nextTick, onErrorCaptured, onMounted, ref, watch } from 'vue'
import {
computed,
nextTick,
onErrorCaptured,
onMounted,
provide,
ref,
shallowRef,
watch
} from 'vue'
import { useI18n } from 'vue-i18n'

import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
Expand Down Expand Up @@ -197,6 +208,13 @@ const isSelected = computed(() => {
return selectedNodeIds.value.has(nodeData.id)
})

const keyEvent = shallowRef<KeyboardEvent | null>(null)
provide('keyEvent', keyEvent)

const handleNodeKeydown = (event: KeyboardEvent) => {
keyEvent.value = event
}

const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
const { executing, progress } = useNodeExecutionState(nodeLocatorId)
const executionStore = useExecutionStore()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,9 @@ describe('ImagePreview', () => {
// Initially buttons should not be visible
expect(wrapper.find('.actions').exists()).toBe(false)

// Trigger hover
await wrapper.trigger('mouseenter')
// Trigger hover on the image wrapper (the element with role="img" has the hover handlers)
const imageWrapper = wrapper.find('[role="img"]')
await imageWrapper.trigger('mouseenter')
await nextTick()

// Action buttons should now be visible
Expand All @@ -123,22 +124,53 @@ describe('ImagePreview', () => {

it('hides action buttons when not hovering', async () => {
const wrapper = mountImagePreview()
const imageWrapper = wrapper.find('[role="img"]')

// Trigger hover
await wrapper.trigger('mouseenter')
await imageWrapper.trigger('mouseenter')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(true)

// Trigger mouse leave
await wrapper.trigger('mouseleave')
await imageWrapper.trigger('mouseleave')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(false)
})

it('shows action buttons on focus', async () => {
const wrapper = mountImagePreview()

// Initially buttons should not be visible
expect(wrapper.find('.actions').exists()).toBe(false)

// Trigger focusin on the image wrapper (useFocusWithin listens to focusin/focusout)
const imageWrapper = wrapper.find('[role="img"]')
await imageWrapper.trigger('focusin')
await nextTick()

// Action buttons should now be visible
expect(wrapper.find('.actions').exists()).toBe(true)
})

it('hides action buttons on blur', async () => {
const wrapper = mountImagePreview()
const imageWrapper = wrapper.find('[role="img"]')

// Trigger focus
await imageWrapper.trigger('focusin')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(true)

// Trigger focusout
await imageWrapper.trigger('focusout')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(false)
})

it('shows mask/edit button only for single images', async () => {
// Multiple images - should not show mask button
const multipleImagesWrapper = mountImagePreview()
await multipleImagesWrapper.trigger('mouseenter')
await multipleImagesWrapper.find('[role="img"]').trigger('mouseenter')
await nextTick()

const maskButtonMultiple = multipleImagesWrapper.find(
Expand All @@ -150,7 +182,7 @@ describe('ImagePreview', () => {
const singleImageWrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
await singleImageWrapper.trigger('mouseenter')
await singleImageWrapper.find('[role="img"]').trigger('mouseenter')
await nextTick()

const maskButtonSingle = singleImageWrapper.find(
Expand All @@ -164,7 +196,7 @@ describe('ImagePreview', () => {
imageUrls: [defaultProps.imageUrls[0]]
})

await wrapper.trigger('mouseenter')
await wrapper.find('[role="img"]').trigger('mouseenter')
await nextTick()

// Test Edit/Mask button - just verify it can be clicked without errors
Expand All @@ -183,7 +215,7 @@ describe('ImagePreview', () => {
imageUrls: [defaultProps.imageUrls[0]]
})

await wrapper.trigger('mouseenter')
await wrapper.find('[role="img"]').trigger('mouseenter')
await nextTick()

// Test Download button
Expand Down