Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
fix: image preview a11y
  • Loading branch information
simula-r committed Dec 11, 2025
commit 8beb899df6ead98983e6b50ec320b9ece47a38bf
30 changes: 21 additions & 9 deletions src/renderer/extensions/vueNodes/components/ImagePreview.vue
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
<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"
class="h-full w-full overflow-hidden rounded-[5px] bg-muted-background relative"
tabindex="0"
role="img"
:aria-label="$t('g.imagePreview')"
:aria-busy="isLoading"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<!-- 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 @@ -96,6 +97,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 +114,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 Down Expand Up @@ -159,6 +162,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
23 changes: 21 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 @@ -17,7 +18,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 @@ -49,6 +50,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 @@ -131,7 +133,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 @@ -198,6 +209,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 Expand Up @@ -266,6 +284,7 @@ const { onPointerdown, ...remainingPointerHandlers } = pointerHandlers
const { startDrag } = useNodeDrag()

async function nodeOnPointerdown(event: PointerEvent) {
nodeContainerRef.value?.focus()
if (event.altKey && lgraphNode.value) {
const result = LGraphCanvas.cloneNodes([lgraphNode.value])
if (result?.created?.length) {
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,23 @@ 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 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 +152,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 +166,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 +185,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