fix: nodes themselves to have focus and indication, made the image node keyboard acessible and have better aria labels, added simply cache for loading so we only show loading state for non cached images

This commit is contained in:
--list
2025-12-05 21:58:56 -08:00
parent f74c176423
commit ad820ef2af
2 changed files with 60 additions and 41 deletions

View File

@@ -1,21 +1,20 @@
<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"
tabindex="0"
role="img"
:aria-label="$t('g.imagePreview')"
:aria-busy="isLoading"
class="h-full w-full overflow-hidden rounded-[5px] bg-muted-background relative"
>
<!-- 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
@@ -48,11 +47,12 @@
)
"
@load="handleImageLoad"
@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 or focus) -->
<div
class="actions absolute top-2 right-2 flex gap-2.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity"
>
<!-- Mask/Edit Button -->
<button
v-if="!hasMultipleImages"
@@ -104,6 +104,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,
@@ -117,9 +118,11 @@
</template>
<script setup lang="ts">
import { useImage } 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'
@@ -146,18 +149,34 @@ const actionButtonClass =
// Component state
const currentIndex = ref(0)
const isHovered = ref(false)
const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
const isLoading = ref(false)
const currentImageEl = ref<HTMLImageElement>()
const loadedUrls = new Set<string>()
// Computed values
const currentImageUrl = computed(() => props.imageUrls[currentIndex.value])
const hasMultipleImages = computed(() => props.imageUrls.length > 1)
const imageAltText = computed(() => `Node output ${currentIndex.value + 1}`)
const { isLoading: imageIsLoading, error: imageError } = useImage(
computed(() => ({ src: currentImageUrl.value }))
)
// Only show loading if image is loading AND not already loaded in this batch
const isLoading = computed(
() => imageIsLoading.value && !loadedUrls.has(currentImageUrl.value)
)
// Listen for keydown events from parent node
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,
@@ -166,11 +185,8 @@ watch(
if (currentIndex.value >= newUrls.length) {
currentIndex.value = 0
}
// Reset loading and error states when URLs change
actualDimensions.value = null
imageError.value = false
isLoading.value = newUrls.length > 0
loadedUrls.clear()
},
{ deep: true }
)
@@ -179,19 +195,12 @@ watch(
const handleImageLoad = (event: Event) => {
if (!event.target || !(event.target instanceof HTMLImageElement)) return
const img = event.target
isLoading.value = false
imageError.value = false
loadedUrls.add(currentImageUrl.value)
if (img.naturalWidth && img.naturalHeight) {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
}
}
const handleImageError = () => {
isLoading.value = false
imageError.value = true
actualDimensions.value = null
}
// In vueNodes mode, we need to set them manually before opening the mask editor.
const setupNodeForMaskEditor = () => {
if (!props.nodeId || !currentImageEl.value) return
@@ -230,20 +239,9 @@ const setCurrentIndex = (index: number) => {
if (currentIndex.value === index) return
if (index >= 0 && index < props.imageUrls.length) {
currentIndex.value = index
actualDimensions.value = null
isLoading.value = true
imageError.value = false
}
}
const handleMouseEnter = () => {
isHovered.value = true
}
const handleMouseLeave = () => {
isHovered.value = false
}
const getNavigationDotClass = (index: number) => {
return [
'w-2 h-2 rounded-full transition-all duration-200 border-0 cursor-pointer p-0',

View File

@@ -5,6 +5,7 @@
<div
v-else
ref="nodeContainerRef"
tabindex="0"
:data-node-id="nodeData.id"
:class="
cn(
@@ -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,
@@ -48,6 +49,7 @@
@dragover.prevent="handleDragOver"
@dragleave="handleDragLeave"
@drop.stop.prevent="handleDrop"
@keydown="handleNodeKeydown"
>
<div class="flex flex-col justify-center items-center relative">
<template v-if="isCollapsed">
@@ -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'
@@ -186,6 +197,14 @@ const { nodeData, error = null } = defineProps<LGraphNodeProps>()
const { t } = useI18n()
// Provide keydown events to child components (ImagePreview, VideoPreview, etc.)
const keyEvent = shallowRef<KeyboardEvent | null>(null)
provide('keyEvent', keyEvent)
const handleNodeKeydown = (event: KeyboardEvent) => {
keyEvent.value = event
}
const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =
useNodeEventHandlers()
const { bringNodeToFront } = useNodeZIndex()
@@ -265,6 +284,8 @@ 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) {