Merge main (as of 10-06-2025) into rh-test (#5965)

## Summary

Merges latest changes from `main` as of 10-06-2025.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5965-Merge-main-as-of-10-06-2025-into-rh-test-2856d73d3650812cb95fd8917278a770)
by [Unito](https://www.unito.io)

---------

Signed-off-by: Marcel Petrick <mail@marcelpetrick.it>
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Benjamin Lu <benceruleanlu@proton.me>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: snomiao <snomiao@gmail.com>
Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
Co-authored-by: Jake Schroeder <jake.schroeder@isophex.com>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: AustinMroz <4284322+AustinMroz@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: Marcel Petrick <mail@marcelpetrick.it>
Co-authored-by: Alexander Brown <DrJKL0424@gmail.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: JakeSchroeder <jake@axiom.co>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
Co-authored-by: ComfyUI Wiki <contact@comfyui-wiki.com>
This commit is contained in:
Arjan Singh
2025-10-08 19:06:40 -07:00
committed by GitHub
parent 529a4de583
commit 5869b04e57
454 changed files with 32333 additions and 37002 deletions

View File

@@ -0,0 +1,152 @@
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
import type { ConnectingLink } from '@/lib/litegraph/src/interfaces'
import { app } from '@/scripts/app'
// Keep one adapter per graph so rendering and interaction share state.
const adapterByGraph = new WeakMap<LGraph, LinkConnectorAdapter>()
/**
* Rendereragnostic adapter around LiteGraph's LinkConnector.
*
* - Uses layoutStore for hittesting (nodes/reroutes).
* - Exposes minimal, imperative APIs to begin link drags and query drop validity.
* - Preserves existing Vue composable behavior.
*/
export class LinkConnectorAdapter {
readonly linkConnector: LinkConnector
constructor(
/** Network the links belong to (typically `app.canvas.graph`). */
readonly network: LGraph
) {
// No-op legacy setter to avoid side effects when connectors update
const setConnectingLinks: (value: ConnectingLink[]) => void = () => {}
this.linkConnector = new LinkConnector(setConnectingLinks)
}
/**
* The currently rendered/dragged links, typed for consumer use.
* Prefer this over accessing `linkConnector.renderLinks` directly.
*/
get renderLinks(): ReadonlyArray<RenderLink> {
return this.linkConnector.renderLinks
}
// Drag helpers
/**
* Begin dragging from an output slot.
* @param nodeId Output node id
* @param outputIndex Output slot index
* @param opts Optional: moveExisting (shift), fromRerouteId
*/
beginFromOutput(
nodeId: NodeId,
outputIndex: number,
opts?: { moveExisting?: boolean; fromRerouteId?: RerouteId }
): void {
const node = this.network.getNodeById(nodeId)
const output = node?.outputs?.[outputIndex]
if (!node || !output) return
const fromReroute = this.network.getReroute(opts?.fromRerouteId)
if (opts?.moveExisting) {
this.linkConnector.moveOutputLink(this.network, output)
} else {
this.linkConnector.dragNewFromOutput(
this.network,
node,
output,
fromReroute
)
}
}
/**
* Begin dragging from an input slot.
* @param nodeId Input node id
* @param inputIndex Input slot index
* @param opts Optional: moveExisting (when a link/floating exists), fromRerouteId
*/
beginFromInput(
nodeId: NodeId,
inputIndex: number,
opts?: { moveExisting?: boolean; fromRerouteId?: RerouteId }
): void {
const node = this.network.getNodeById(nodeId)
const input = node?.inputs?.[inputIndex]
if (!node || !input) return
const fromReroute = this.network.getReroute(opts?.fromRerouteId)
if (opts?.moveExisting) {
this.linkConnector.moveInputLink(this.network, input)
} else {
this.linkConnector.dragNewFromInput(
this.network,
node,
input,
fromReroute
)
}
}
// Validation helpers
isNodeValidDrop(nodeId: NodeId): boolean {
const node = this.network.getNodeById(nodeId)
if (!node) return false
return this.linkConnector.isNodeValidDrop(node)
}
isInputValidDrop(nodeId: NodeId, inputIndex: number): boolean {
const node = this.network.getNodeById(nodeId)
const input = node?.inputs?.[inputIndex]
if (!node || !input) return false
return this.linkConnector.isInputValidDrop(node, input)
}
isOutputValidDrop(nodeId: NodeId, outputIndex: number): boolean {
const node = this.network.getNodeById(nodeId)
const output = node?.outputs?.[outputIndex]
if (!node || !output) return false
return this.linkConnector.renderLinks.some((link) =>
link.canConnectToOutput(node, output)
)
}
isRerouteValidDrop(rerouteId: RerouteId): boolean {
const reroute = this.network.getReroute(rerouteId)
if (!reroute) return false
return this.linkConnector.isRerouteValidDrop(reroute)
}
// Drop/cancel helpers for future flows
/** Disconnects moving links (drop on canvas/no target). */
disconnectMovingLinks(): void {
this.linkConnector.disconnectLinks()
}
/** Resets connector state and clears any temporary flags. */
reset(): void {
this.linkConnector.reset()
}
}
/** Convenience creator using the current app canvas graph. */
export function createLinkConnectorAdapter(): LinkConnectorAdapter | null {
const graph = app.canvas?.graph as LGraph | undefined
if (!graph) return null
let adapter = adapterByGraph.get(graph)
if (!adapter) {
adapter = new LinkConnectorAdapter(graph)
adapterByGraph.set(graph, adapter)
}
return adapter
}

View File

@@ -1,72 +0,0 @@
import { getActivePinia } from 'pinia'
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type {
SlotDragSource,
SlotDropCandidate
} from '@/renderer/core/canvas/links/slotLinkDragState'
interface CompatibilityResult {
allowable: boolean
targetNode?: LGraphNode
targetSlot?: INodeInputSlot | INodeOutputSlot
}
function resolveNode(nodeId: NodeId) {
const pinia = getActivePinia()
const canvasStore = pinia ? useCanvasStore() : null
const graph = canvasStore?.canvas?.graph
if (!graph) return null
const id = typeof nodeId === 'string' ? Number(nodeId) : nodeId
if (Number.isNaN(id)) return null
return graph.getNodeById(id)
}
export function evaluateCompatibility(
source: SlotDragSource,
candidate: SlotDropCandidate
): CompatibilityResult {
if (candidate.layout.nodeId === source.nodeId) {
return { allowable: false }
}
const isOutputToInput =
source.type === 'output' && candidate.layout.type === 'input'
const isInputToOutput =
source.type === 'input' && candidate.layout.type === 'output'
if (!isOutputToInput && !isInputToOutput) {
return { allowable: false }
}
const sourceNode = resolveNode(source.nodeId)
const targetNode = resolveNode(candidate.layout.nodeId)
if (!sourceNode || !targetNode) {
return { allowable: false }
}
if (isOutputToInput) {
const outputSlot = sourceNode.outputs?.[source.slotIndex]
const inputSlot = targetNode.inputs?.[candidate.layout.index]
if (!outputSlot || !inputSlot) {
return { allowable: false }
}
const allowable = sourceNode.canConnectTo(targetNode, inputSlot, outputSlot)
return { allowable, targetNode, targetSlot: inputSlot }
}
const inputSlot = sourceNode.inputs?.[source.slotIndex]
const outputSlot = targetNode.outputs?.[candidate.layout.index]
if (!inputSlot || !outputSlot) {
return { allowable: false }
}
const allowable = targetNode.canConnectTo(sourceNode, inputSlot, outputSlot)
return { allowable, targetNode, targetSlot: outputSlot }
}

View File

@@ -7,12 +7,14 @@ import type { Point, SlotLayout } from '@/renderer/core/layout/types'
type SlotDragType = 'input' | 'output'
export interface SlotDragSource {
interface SlotDragSource {
nodeId: string
slotIndex: number
type: SlotDragType
direction: LinkDirection
position: Readonly<Point>
linkId?: number
movingExistingOutput?: boolean
}
export interface SlotDropCandidate {

View File

@@ -1,16 +1,13 @@
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type {
INodeInputSlot,
INodeOutputSlot,
ReadOnlyPoint
} from '@/lib/litegraph/src/interfaces'
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
import type { Point } from '@/lib/litegraph/src/interfaces'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { resolveConnectingLinkColor } from '@/lib/litegraph/src/utils/linkColors'
import {
type SlotDragSource,
useSlotLinkDragState
} from '@/renderer/core/canvas/links/slotLinkDragState'
import { createLinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
import { useSlotLinkDragState } from '@/renderer/core/canvas/links/slotLinkDragState'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
function buildContext(canvas: LGraphCanvas): LinkRenderContext {
return {
@@ -39,57 +36,75 @@ export function attachSlotLinkPreviewRenderer(canvas: LGraphCanvas) {
originalOnDrawForeground?.(ctx, area)
const { state } = useSlotLinkDragState()
// If LiteGraph's own connector is active, let it handle rendering to avoid double-draw
if (canvas.linkConnector?.isConnecting) return
if (!state.active || !state.source) return
const { pointer, source } = state
const start = source.position
const sourceSlot = resolveSourceSlot(canvas, source)
const { pointer } = state
const linkRenderer = canvas.linkRenderer
if (!linkRenderer) return
const context = buildContext(canvas)
const from: ReadOnlyPoint = [start.x, start.y]
const to: ReadOnlyPoint = [pointer.canvas.x, pointer.canvas.y]
const startDir = source.direction ?? LinkDirection.RIGHT
const endDir = LinkDirection.CENTER
const colour = resolveConnectingLinkColor(sourceSlot?.type)
const renderLinks = createLinkConnectorAdapter()?.renderLinks
if (!renderLinks || renderLinks.length === 0) return
const to: Readonly<Point> = state.candidate?.compatible
? [state.candidate.layout.position.x, state.candidate.layout.position.y]
: [pointer.canvas.x, pointer.canvas.y]
ctx.save()
for (const link of renderLinks) {
const startDir = link.fromDirection ?? LinkDirection.RIGHT
const endDir = link.dragDirection ?? LinkDirection.CENTER
const colour = resolveConnectingLinkColor(link.fromSlot.type)
linkRenderer.renderDraggingLink(
ctx,
from,
to,
colour,
startDir,
endDir,
context
)
const fromPoint = resolveRenderLinkOrigin(link)
linkRenderer.renderDraggingLink(
ctx,
fromPoint,
to,
colour,
startDir,
endDir,
context
)
}
ctx.restore()
}
canvas.onDrawForeground = patched
}
function resolveSourceSlot(
canvas: LGraphCanvas,
source: SlotDragSource
): INodeInputSlot | INodeOutputSlot | undefined {
const graph = canvas.graph
if (!graph) return undefined
function resolveRenderLinkOrigin(link: RenderLink): Readonly<Point> {
if (link.fromReroute) {
const rerouteLayout = layoutStore.getRerouteLayout(link.fromReroute.id)
if (rerouteLayout) {
return [rerouteLayout.position.x, rerouteLayout.position.y]
}
const nodeId = Number(source.nodeId)
if (!Number.isFinite(nodeId)) return undefined
const [x, y] = link.fromReroute.pos
return [x, y]
}
const node = graph.getNodeById(nodeId)
if (!node) return undefined
const nodeId = getRenderLinkNodeId(link)
if (nodeId != null) {
const isInputFrom = link.toType === 'output'
const key = getSlotKey(String(nodeId), link.fromSlotIndex, isInputFrom)
const layout = layoutStore.getSlotLayout(key)
if (layout) {
return [layout.position.x, layout.position.y]
}
}
return source.type === 'output'
? node.outputs?.[source.slotIndex]
: node.inputs?.[source.slotIndex]
return link.fromPos
}
function getRenderLinkNodeId(link: RenderLink): number | null {
const node = link.node
if (typeof node === 'object' && node !== null && 'id' in node) {
const maybeId = node.id
if (typeof maybeId === 'number') return maybeId
}
return null
}

View File

@@ -8,10 +8,7 @@
*/
import type { LLink } from '@/lib/litegraph/src/LLink'
import type { Reroute } from '@/lib/litegraph/src/Reroute'
import type {
CanvasColour,
ReadOnlyPoint
} from '@/lib/litegraph/src/interfaces'
import type { CanvasColour, Point } from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
LinkDirection,
@@ -24,7 +21,7 @@ import {
type Direction,
type LinkRenderData,
type RenderContext as PathRenderContext,
type Point,
type Point as PointObj,
type RenderMode
} from '@/renderer/core/canvas/pathRenderer'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
@@ -172,7 +169,7 @@ export class LitegraphLinkAdapter {
* Critically: does nothing for CENTER/NONE directions (no case for them)
*/
private applySplineOffset(
point: Point,
point: PointObj,
direction: LinkDirection,
distance: number
): void {
@@ -199,8 +196,8 @@ export class LitegraphLinkAdapter {
*/
renderLinkDirect(
ctx: CanvasRenderingContext2D,
a: ReadOnlyPoint,
b: ReadOnlyPoint,
a: Readonly<Point>,
b: Readonly<Point>,
link: LLink | null,
skip_border: boolean,
flow: number | boolean | null,
@@ -210,8 +207,8 @@ export class LitegraphLinkAdapter {
context: LinkRenderContext,
extras: {
reroute?: Reroute
startControl?: ReadOnlyPoint
endControl?: ReadOnlyPoint
startControl?: Readonly<Point>
endControl?: Readonly<Point>
num_sublines?: number
disabled?: boolean
} = {}
@@ -250,7 +247,7 @@ export class LitegraphLinkAdapter {
)
const factor = 0.25
const cps: Point[] = []
const cps: PointObj[] = []
if (hasStartCtrl && hasEndCtrl) {
// Both provided explicitly
@@ -315,7 +312,7 @@ export class LitegraphLinkAdapter {
// Copy calculated center position back to litegraph object
// This is needed for hit detection and menu interaction
if (linkData.centerPos) {
linkSegment._pos = linkSegment._pos || new Float32Array(2)
linkSegment._pos = linkSegment._pos || [0, 0]
linkSegment._pos[0] = linkData.centerPos.x
linkSegment._pos[1] = linkData.centerPos.y
@@ -329,8 +326,8 @@ export class LitegraphLinkAdapter {
if (this.enableLayoutStoreWrites && link && link.id !== -1) {
// Calculate bounds and center only when writing
const bounds = this.calculateLinkBounds(
[linkData.startPoint.x, linkData.startPoint.y] as ReadOnlyPoint,
[linkData.endPoint.x, linkData.endPoint.y] as ReadOnlyPoint,
[linkData.startPoint.x, linkData.startPoint.y] as Readonly<Point>,
[linkData.endPoint.x, linkData.endPoint.y] as Readonly<Point>,
linkData
)
const centerPos = linkData.centerPos || {
@@ -365,8 +362,8 @@ export class LitegraphLinkAdapter {
renderDraggingLink(
ctx: CanvasRenderingContext2D,
from: ReadOnlyPoint,
to: ReadOnlyPoint,
from: Readonly<Point>,
to: Readonly<Point>,
colour: CanvasColour,
startDir: LinkDirection,
endDir: LinkDirection,
@@ -397,8 +394,8 @@ export class LitegraphLinkAdapter {
* Includes padding for line width and control points
*/
private calculateLinkBounds(
startPos: ReadOnlyPoint,
endPos: ReadOnlyPoint,
startPos: Readonly<Point>,
endPos: Readonly<Point>,
linkData: LinkRenderData
): Bounds {
let minX = Math.min(startPos[0], endPos[0])

View File

@@ -1,5 +1,6 @@
import { computed } from 'vue'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
@@ -30,6 +31,15 @@ export function useCanvasInteractions() {
* when appropriate (e.g., Ctrl+wheel for zoom in standard mode)
*/
const handleWheel = (event: WheelEvent) => {
// Check if the wheel event is from an element that wants to capture wheel events
const target = event.target as HTMLElement
const captureElement = target?.closest('[data-capture-wheel="true"]')
if (captureElement) {
// Element wants to capture wheel events, don't forward to canvas
return
}
// In standard mode, Ctrl+wheel should go to canvas for zoom
if (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) {
forwardEventToCanvas(event)
@@ -50,6 +60,11 @@ export function useCanvasInteractions() {
* be forwarded to canvas (e.g., space+drag for panning)
*/
const handlePointer = (event: PointerEvent) => {
if (isMiddlePointerInput(event)) {
forwardEventToCanvas(event)
return
}
// Check if canvas exists using established pattern
const canvas = getCanvas()
if (!canvas) return
@@ -72,6 +87,15 @@ export function useCanvasInteractions() {
const forwardEventToCanvas = (
event: WheelEvent | PointerEvent | MouseEvent
) => {
// Check if the wheel event is from an element that wants to capture wheel events
const target = event.target as HTMLElement
const captureElement = target?.closest('[data-capture-wheel="true"]')
if (captureElement) {
// Element wants to capture wheel events, don't forward to canvas
return
}
const canvasEl = app.canvas?.canvas
if (!canvasEl) return
event.preventDefault()

View File

@@ -4,7 +4,7 @@ import { computed, ref, toValue } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { LLink } from '@/lib/litegraph/src/LLink'
import { Reroute } from '@/lib/litegraph/src/Reroute'
import type { ReadOnlyPoint } from '@/lib/litegraph/src/interfaces'
import type { Point } from '@/lib/litegraph/src/interfaces'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
@@ -113,7 +113,7 @@ export function useLinkLayoutSync() {
// Special handling for floating input chain
const isFloatingInputChain = !sourceNode && targetNode
const startControl: ReadOnlyPoint = isFloatingInputChain
const startControl: Readonly<Point> = isFloatingInputChain
? [0, 0]
: [dist * reroute.cos, dist * reroute.sin]
@@ -149,7 +149,7 @@ export function useLinkLayoutSync() {
(endPos[1] - lastReroute.pos[1]) ** 2
)
const finalDist = Math.min(Reroute.maxSplineOffset, finalDistance * 0.25)
const finalStartControl: ReadOnlyPoint = [
const finalStartControl: Readonly<Point> = [
finalDist * lastReroute.cos,
finalDist * lastReroute.sin
]

View File

@@ -1,5 +1,9 @@
import type { Bounds, Point, Size } from '@/renderer/core/layout/types'
export function toPoint(x: number, y: number): Point {
return { x, y }
}
export function isPointEqual(a: Point, b: Point): boolean {
return a.x === b.x && a.y === b.y
}

View File

@@ -45,7 +45,7 @@
</Button>
<hr
class="absolute top-5 bg-[#E1DED5] dark-theme:bg-[#262729] h-[1px] border-0"
class="absolute top-5 bg-node-component-border h-px border-0"
:style="{
width: containerStyles.width
}"

View File

@@ -0,0 +1,258 @@
<template>
<div
v-if="imageUrls.length > 0"
class="video-preview relative group flex flex-col items-center"
tabindex="0"
role="region"
:aria-label="$t('g.videoPreview')"
data-capture-node="true"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@keydown="handleKeyDown"
>
<!-- Video Wrapper -->
<div
class="relative rounded-[5px] overflow-hidden w-full max-w-[352px] bg-[#262729]"
>
<!-- Error State -->
<div
v-if="videoError"
class="w-full h-[352px] flex flex-col items-center justify-center text-white text-center bg-gray-800/50"
>
<i-lucide:video-off class="w-12 h-12 mb-2 text-gray-400" />
<p class="text-sm text-gray-300">{{ $t('g.videoFailedToLoad') }}</p>
<p class="text-xs text-gray-400 mt-1">
{{ getVideoFilename(currentVideoUrl) }}
</p>
</div>
<!-- Loading State -->
<Skeleton
v-else-if="isLoading"
class="w-full h-[352px]"
border-radius="5px"
/>
<!-- Main Video -->
<video
v-else
:src="currentVideoUrl"
class="w-full h-[352px] object-contain block"
controls
loop
playsinline
@loadeddata="handleVideoLoad"
@error="handleVideoError"
/>
<!-- Floating Action Buttons (appear on hover) -->
<div v-if="isHovered" class="actions absolute top-2 right-2 flex gap-1">
<!-- Download Button -->
<button
class="action-btn bg-white text-black hover:bg-gray-100 rounded-lg p-2 shadow-sm transition-all duration-200 border-0 cursor-pointer"
:title="$t('g.downloadVideo')"
:aria-label="$t('g.downloadVideo')"
@click="handleDownload"
>
<i-lucide:download class="w-4 h-4" />
</button>
<!-- Close Button -->
<button
class="action-btn bg-white text-black hover:bg-gray-100 rounded-lg p-2 shadow-sm transition-all duration-200 border-0 cursor-pointer"
:title="$t('g.removeVideo')"
:aria-label="$t('g.removeVideo')"
@click="handleRemove"
>
<i-lucide:x class="w-4 h-4" />
</button>
</div>
<!-- Multiple Videos Navigation -->
<div
v-if="hasMultipleVideos"
class="absolute bottom-2 left-2 right-2 flex justify-center gap-1"
>
<button
v-for="(_, index) in imageUrls"
:key="index"
:class="getNavigationDotClass(index)"
:aria-label="
$t('g.viewVideoOfTotal', {
index: index + 1,
total: imageUrls.length
})
"
@click="setCurrentIndex(index)"
/>
</div>
</div>
<div class="relative">
<!-- Video Dimensions -->
<div class="text-white text-xs text-center mt-2">
<span v-if="videoError" class="text-red-400">
{{ $t('g.errorLoadingVideo') }}
</span>
<span v-else-if="isLoading" class="text-gray-400">
{{ $t('g.loading') }}...
</span>
<span v-else>
{{ actualDimensions || $t('g.calculatingDimensions') }}
</span>
</div>
<LODFallback />
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue'
import Skeleton from 'primevue/skeleton'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import LODFallback from './components/LODFallback.vue'
interface VideoPreviewProps {
/** Array of video URLs to display */
readonly imageUrls: readonly string[] // Named imageUrls for consistency with parent components
/** Optional node ID for context-aware actions */
readonly nodeId?: string
}
const props = defineProps<VideoPreviewProps>()
const { t } = useI18n()
const nodeOutputStore = useNodeOutputStore()
// Component state
const currentIndex = ref(0)
const isHovered = ref(false)
const actualDimensions = ref<string | null>(null)
const videoError = ref(false)
const isLoading = ref(false)
// Computed values
const currentVideoUrl = computed(() => props.imageUrls[currentIndex.value])
const hasMultipleVideos = computed(() => props.imageUrls.length > 1)
// Watch for URL changes and reset state
watch(
() => props.imageUrls,
(newUrls) => {
// Reset current index if it's out of bounds
if (currentIndex.value >= newUrls.length) {
currentIndex.value = 0
}
// Reset loading and error states when URLs change
actualDimensions.value = null
videoError.value = false
isLoading.value = false
},
{ deep: true }
)
// Event handlers
const handleVideoLoad = (event: Event) => {
if (!event.target || !(event.target instanceof HTMLVideoElement)) return
const video = event.target
isLoading.value = false
videoError.value = false
if (video.videoWidth && video.videoHeight) {
actualDimensions.value = `${video.videoWidth} x ${video.videoHeight}`
}
}
const handleVideoError = () => {
isLoading.value = false
videoError.value = true
actualDimensions.value = null
}
const handleDownload = () => {
try {
downloadFile(currentVideoUrl.value)
} catch (error) {
useToast().add({
severity: 'error',
summary: 'Error',
detail: t('g.failedToDownloadVideo'),
life: 3000,
group: 'video-preview'
})
}
}
const handleRemove = () => {
if (!props.nodeId) return
nodeOutputStore.removeNodeOutputs(props.nodeId)
}
const setCurrentIndex = (index: number) => {
if (index >= 0 && index < props.imageUrls.length) {
currentIndex.value = index
actualDimensions.value = null
isLoading.value = true
videoError.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',
index === currentIndex.value ? 'bg-white' : 'bg-white/50 hover:bg-white/80'
]
}
const handleKeyDown = (event: KeyboardEvent) => {
if (props.imageUrls.length <= 1) return
switch (event.key) {
case 'ArrowLeft':
event.preventDefault()
setCurrentIndex(
currentIndex.value > 0
? currentIndex.value - 1
: props.imageUrls.length - 1
)
break
case 'ArrowRight':
event.preventDefault()
setCurrentIndex(
currentIndex.value < props.imageUrls.length - 1
? currentIndex.value + 1
: 0
)
break
case 'Home':
event.preventDefault()
setCurrentIndex(0)
break
case 'End':
event.preventDefault()
setCurrentIndex(props.imageUrls.length - 1)
break
}
}
const getVideoFilename = (url: string): string => {
try {
return new URL(url).searchParams.get('filename') || 'Unknown file'
} catch {
return 'Invalid URL'
}
}
</script>

View File

@@ -2,6 +2,7 @@
<div
v-if="imageUrls.length > 0"
class="image-preview relative group flex flex-col items-center"
data-capture-node="true"
tabindex="0"
role="region"
:aria-label="$t('g.imagePreview')"
@@ -20,7 +21,9 @@
>
<i-lucide:image-off class="w-12 h-12 mb-2 text-gray-400" />
<p class="text-sm text-gray-300">{{ $t('g.imageFailedToLoad') }}</p>
<p class="text-xs text-gray-400 mt-1">{{ currentImageUrl }}</p>
<p class="text-xs text-gray-400 mt-1">
{{ getImageFilename(currentImageUrl) }}
</p>
</div>
<!-- Loading State -->
@@ -260,4 +263,12 @@ const handleKeyDown = (event: KeyboardEvent) => {
break
}
}
const getImageFilename = (url: string): string => {
try {
return new URL(url).searchParams.get('filename') || 'Unknown file'
} catch {
return 'Invalid URL'
}
}
</script>

View File

@@ -6,7 +6,7 @@
ref="connectionDotRef"
:color="slotColor"
:class="cn('-translate-x-1/2', errorClassesDot)"
v-on="readonly ? {} : { pointerdown: onPointerDown }"
@pointerdown="onPointerDown"
/>
<!-- Slot Name -->
@@ -27,9 +27,7 @@
<script setup lang="ts">
import {
type ComponentPublicInstance,
type Ref,
computed,
inject,
onErrorCaptured,
ref,
watchEffect
@@ -54,7 +52,6 @@ interface InputSlotProps {
index: number
connected?: boolean
compatible?: boolean
readonly?: boolean
dotOnly?: boolean
}
@@ -74,24 +71,21 @@ const hasSlotError = computed(() => {
const errorClassesDot = computed(() => {
return hasSlotError.value
? 'ring-2 ring-error dark-theme:ring-error ring-offset-0 rounded-full'
? 'ring-2 ring-error ring-offset-0 rounded-full'
: ''
})
const labelClasses = computed(() =>
hasSlotError.value
? 'text-error dark-theme:text-error font-medium'
: 'dark-theme:text-slate-200 text-stone-200'
? 'text-error font-medium'
: 'text-node-component-slot-text'
)
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
const tooltipContainer =
inject<Ref<HTMLElement | undefined>>('tooltipContainer')
const { getInputSlotTooltip, createTooltipConfig } = useNodeTooltips(
props.nodeType || '',
tooltipContainer
props.nodeType || ''
)
const tooltipConfig = computed(() => {
@@ -117,7 +111,7 @@ const slotColor = computed(() => {
const slotWrapperClass = computed(() =>
cn(
'lg-slot lg-slot--input flex items-center group rounded-r-lg h-6',
props.readonly ? 'cursor-default opacity-70' : 'cursor-crosshair',
'cursor-crosshair',
props.dotOnly
? 'lg-slot--dot-only'
: 'pr-6 hover:bg-black/5 hover:dark:bg-white/5',
@@ -148,7 +142,6 @@ useSlotElementTracking({
const { onPointerDown } = useSlotLinkInteraction({
nodeId: props.nodeId ?? '',
index: props.index,
type: 'input',
readonly: props.readonly
type: 'input'
})
</script>

View File

@@ -8,12 +8,12 @@
:data-node-id="nodeData.id"
:class="
cn(
'bg-white dark-theme:bg-charcoal-800',
'lg-node absolute rounded-2xl',
'border-2 border-solid border-sand-100 dark-theme:border-charcoal-600',
'bg-node-component-surface',
'lg-node absolute rounded-2xl touch-none',
'border-1 border-solid border-node-component-border',
// hover (only when node should handle events)
shouldHandleNodePointerEvents &&
'hover:ring-7 ring-gray-500/50 dark-theme:ring-gray-500/20',
'hover:ring-7 ring-node-component-ring',
'outline-transparent -outline-offset-2 outline-2',
borderClass,
outlineClass,
@@ -50,15 +50,7 @@
<SlotConnectionDot multi class="absolute right-0 translate-x-1/2" />
</template>
<NodeHeader
v-memo="[
nodeData.title,
nodeData.color,
nodeData.bgcolor,
isCollapsed,
nodeData.flags?.pinned
]"
:node-data="nodeData"
:readonly="readonly"
:collapsed="isCollapsed"
@collapse="handleCollapse"
@update:title="handleHeaderTitleUpdate"
@@ -100,37 +92,19 @@
:data-testid="`node-body-${nodeData.id}`"
>
<!-- Slots only rendered at full detail -->
<NodeSlots
v-memo="[
nodeData.inputs?.length,
nodeData.outputs?.length,
executionStore.lastNodeErrors
]"
:node-data="nodeData"
:readonly="readonly"
/>
<NodeSlots :node-data="nodeData" />
<!-- Widgets rendered at reduced+ detail -->
<NodeWidgets
v-if="nodeData.widgets?.length"
v-memo="[nodeData.widgets?.length]"
:node-data="nodeData"
:readonly="readonly"
/>
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
<!-- Custom content at reduced+ detail -->
<NodeContent
v-if="hasCustomContent"
:node-data="nodeData"
:readonly="readonly"
:image-urls="nodeImageUrls"
:media="nodeMedia"
/>
<!-- Live preview image -->
<div
v-if="shouldShowPreviewImg"
v-memo="[latestPreviewUrl]"
class="px-4"
>
<div v-if="shouldShowPreviewImg" class="px-4">
<img
:src="latestPreviewUrl"
alt="preview"
@@ -144,7 +118,7 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, inject, onErrorCaptured, onMounted, provide, ref } from 'vue'
import { computed, inject, onErrorCaptured, onMounted, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
@@ -180,16 +154,11 @@ import SlotConnectionDot from './SlotConnectionDot.vue'
// Extended props for main node component
interface LGraphNodeProps {
nodeData: VueNodeData
readonly?: boolean
error?: string | null
zoomLevel?: number
}
const {
nodeData,
error = null,
readonly = false
} = defineProps<LGraphNodeProps>()
const { nodeData, error = null } = defineProps<LGraphNodeProps>()
const {
handleNodeCollapse,
@@ -211,7 +180,8 @@ const isSelected = computed(() => {
})
// Use execution state composable
const { executing, progress } = useNodeExecutionState(() => nodeData.id)
const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
const { executing, progress } = useNodeExecutionState(nodeLocatorId)
// Direct access to execution store for error state
const executionStore = useExecutionStore()
@@ -297,15 +267,14 @@ onMounted(() => {
// Track collapsed state
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
// Check if node has custom content (like image outputs)
// Check if node has custom content (like image/video outputs)
const hasCustomContent = computed(() => {
// Show custom content if node has image outputs
return nodeImageUrls.value.length > 0
// Show custom content if node has media outputs
return !!nodeMedia.value && nodeMedia.value.urls.length > 0
})
// Computed classes and conditions for better reusability
const separatorClasses =
'bg-sand-100 dark-theme:bg-charcoal-600 h-px mx-0 w-full lod-toggle'
const separatorClasses = 'bg-node-component-border h-px mx-0 w-full lod-toggle'
const progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
@@ -317,17 +286,17 @@ const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
const borderClass = computed(() => {
return (
(hasAnyError.value && 'border-error dark-theme:border-error') ||
(executing.value && 'border-blue-500')
(hasAnyError.value && 'border-error') ||
(executing.value && 'border-node-executing')
)
})
const outlineClass = computed(() => {
return (
return cn(
isSelected.value &&
((hasAnyError.value && 'outline-error dark-theme:outline-error') ||
(executing.value && 'outline-blue-500 dark-theme:outline-blue-500') ||
'outline-black dark-theme:outline-white')
((hasAnyError.value && 'outline-error ') ||
(executing.value && 'outline-node-executing') ||
'outline-node-component-outline')
)
})
@@ -362,7 +331,7 @@ const handleEnterSubgraph = () => {
return
}
canvas.openSubgraph(litegraphNode.subgraph)
canvas.openSubgraph(litegraphNode.subgraph, litegraphNode)
}
const nodeOutputs = useNodeOutputStore()
@@ -370,28 +339,35 @@ const nodeOutputs = useNodeOutputStore()
const nodeOutputLocatorId = computed(() =>
nodeData.subgraphId ? `${nodeData.subgraphId}:${nodeData.id}` : nodeData.id
)
const nodeImageUrls = computed(() => {
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]
const lgraphNode = computed(() => {
const locatorId = getLocatorIdFromNodeData(nodeData)
// Use root graph for getNodeByLocatorId since it needs to traverse from root
const rootGraph = app.graph?.rootGraph || app.graph
if (!rootGraph) {
return []
}
if (!rootGraph) return null
return getNodeByLocatorId(rootGraph, locatorId)
})
const node = getNodeByLocatorId(rootGraph, locatorId)
const nodeMedia = computed(() => {
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]
const node = lgraphNode.value
if (node && newOutputs?.images?.length) {
const urls = nodeOutputs.getNodeImageUrls(node)
if (urls) {
return urls
}
}
// Clear URLs if no outputs or no images
return []
if (!node || !newOutputs?.images?.length) return undefined
const urls = nodeOutputs.getNodeImageUrls(node)
if (!urls?.length) return undefined
// Determine media type from previewMediaType or fallback to input slot types
// Note: Despite the field name "images", videos are also included in outputs
// TODO: fix the backend to return videos using the videos key instead of the images key
const hasVideoInput = node.inputs?.some((input) => input.type === 'VIDEO')
const type =
node.previewMediaType === 'video' ||
(!node.previewMediaType && hasVideoInput)
? 'video'
: 'image'
return { type, urls } as const
})
const nodeContainerRef = ref()
provide('tooltipContainer', nodeContainerRef)
</script>

View File

@@ -1,13 +1,11 @@
<template>
<div class="scale-75">
<div
class="bg-white dark-theme:bg-charcoal-800 lg-node absolute rounded-2xl border border-solid border-sand-100 dark-theme:border-charcoal-600 outline-transparent -outline-offset-2 outline-2 pointer-events-none"
class="bg-node-component-surface lg-node absolute rounded-2xl border border-solid border-node-component-border outline-transparent -outline-offset-2 outline-2 pointer-events-none"
>
<NodeHeader :node-data="nodeData" :readonly="readonly" />
<div
class="bg-sand-100 dark-theme:bg-charcoal-600 h-px mx-0 w-full mb-4"
/>
<div class="bg-node-component-border h-px mx-0 w-full mb-4" />
<div class="flex flex-col gap-4 pb-4">
<NodeSlots

View File

@@ -1,5 +1,5 @@
<template>
<div
class="lod-fallback absolute inset-0 w-full h-full bg-zinc-300 dark-theme:bg-zinc-800"
class="lod-fallback absolute inset-0 w-full h-full bg-node-component-widget-skeleton-surface"
></div>
</template>

View File

@@ -5,9 +5,15 @@
<div v-else class="lg-node-content">
<!-- Default slot for custom content -->
<slot>
<VideoPreview
v-if="hasMedia && media?.type === 'video'"
:image-urls="media.urls"
:node-id="nodeId"
class="mt-2"
/>
<ImagePreview
v-if="hasImages"
:image-urls="props.imageUrls || []"
v-else-if="hasMedia && media?.type === 'image'"
:image-urls="media.urls"
:node-id="nodeId"
class="mt-2"
/>
@@ -20,25 +26,24 @@ import { computed, onErrorCaptured, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import VideoPreview from '../VideoPreview.vue'
import ImagePreview from './ImagePreview.vue'
interface NodeContentProps {
node?: LGraphNode // For backwards compatibility
nodeData?: VueNodeData // New clean data structure
readonly?: boolean
imageUrls?: string[]
nodeData?: VueNodeData
media?: {
type: 'image' | 'video'
urls: string[]
}
}
const props = defineProps<NodeContentProps>()
const hasImages = computed(() => props.imageUrls && props.imageUrls.length > 0)
const hasMedia = computed(() => props.media && props.media.urls.length > 0)
// Get node ID from nodeData or node prop
const nodeId = computed(() => {
return props.nodeData?.id?.toString() || props.node?.id?.toString()
})
// Get node ID from nodeData
const nodeId = computed(() => props.nodeData?.id?.toString())
// Error boundary implementation
const renderError = ref<string | null>(null)

View File

@@ -101,9 +101,6 @@ const createMountConfig = () => {
updated: vi.fn(),
unmounted: vi.fn()
}
},
provide: {
tooltipContainer: { value: document.createElement('div') }
}
}
}
@@ -118,7 +115,6 @@ const mountHeader = (
...config,
props: {
nodeData: makeNodeData(),
readonly: false,
collapsed: false,
...props
}
@@ -182,28 +178,14 @@ describe('NodeHeader.vue', () => {
expect(wrapper.get('[data-testid="node-title"]').text()).toContain('KeepMe')
})
it('honors readonly: hides collapse button and prevents editing', async () => {
const wrapper = mountHeader({ readonly: true })
// Collapse button should be hidden
const btn = wrapper.find('[data-testid="node-collapse-button"]')
expect(btn.exists()).toBe(true)
// v-show hides via display:none
expect((btn.element as HTMLButtonElement).style.display).toBe('none')
// In unit test, presence is fine; simulate double click should not create input
await wrapper.get('[data-testid="node-header-1"]').trigger('dblclick')
const input = wrapper.find('[data-testid="node-title-input"]')
expect(input.exists()).toBe(false)
})
it('renders correct chevron icon based on collapsed prop', async () => {
const wrapper = mountHeader({ collapsed: false })
const expandedIcon = wrapper.get('i')
expect(expandedIcon.classes()).toContain('pi-chevron-down')
expect(expandedIcon.classes()).not.toContain('-rotate-90')
await wrapper.setProps({ collapsed: true })
const collapsedIcon = wrapper.get('i')
expect(collapsedIcon.classes()).toContain('pi-chevron-right')
expect(collapsedIcon.classes()).toContain('-rotate-90')
})
describe('Tooltips', () => {
@@ -222,16 +204,6 @@ describe('NodeHeader.vue', () => {
expect(directive).toBeTruthy()
})
it('disables tooltip when in readonly mode', () => {
const wrapper = mountHeader({
readonly: true,
nodeData: makeNodeData({ type: 'KSampler' })
})
const titleElement = wrapper.find('[data-testid="node-title"]')
expect(titleElement.exists()).toBe(true)
})
it('disables tooltip when editing is active', async () => {
const wrapper = mountHeader({
nodeData: makeNodeData({ type: 'KSampler' })

View File

@@ -4,25 +4,37 @@
</div>
<div
v-else
class="lg-node-header p-4 rounded-t-2xl w-full cursor-move"
:class="
cn(
'lg-node-header p-4 rounded-t-2xl cursor-move w-full bg-node-component-header-surface text-node-component-header',
collapsed && 'rounded-2xl'
)
"
:style="headerStyle"
:data-testid="`node-header-${nodeData?.id || ''}`"
@dblclick="handleDoubleClick"
>
<div class="flex items-center justify-between relative">
<div class="flex items-center justify-between gap-2.5 relative">
<!-- Collapse/Expand Button -->
<button
v-show="!readonly"
class="bg-transparent border-transparent flex items-center lod-toggle"
data-testid="node-collapse-button"
@click.stop="handleCollapse"
@dblclick.stop
>
<i
:class="collapsed ? 'pi pi-chevron-right' : 'pi pi-chevron-down'"
class="text-xs leading-none relative top-px text-stone-200 dark-theme:text-slate-300"
></i>
</button>
<div class="flex items-center lod-toggle shrink-0 px-0.5">
<IconButton
size="fit-content"
type="transparent"
data-testid="node-collapse-button"
@click.stop="handleCollapse"
@dblclick.stop
>
<i
:class="
cn(
'icon-[lucide--chevron-down] size-5 transition-transform',
collapsed && '-rotate-90'
)
"
class="text-xs leading-none relative top-px text-node-component-header-icon"
></i>
</IconButton>
</div>
<!-- Node Title -->
<div
@@ -39,33 +51,32 @@
/>
<i-lucide:pin
v-if="isPinned"
class="w-5 h-5 text-stone-200 dark-theme:text-slate-300"
class="size-5 text-node-component-header-icon"
data-testid="node-pin-indicator"
/>
</div>
<div class="flex items-center lod-toggle shrink-0">
<IconButton
v-if="isSubgraphNode"
v-tooltip.top="enterSubgraphTooltipConfig"
size="sm"
type="transparent"
data-testid="subgraph-enter-button"
@click.stop="handleEnterSubgraph"
@dblclick.stop
>
<i
class="icon-[lucide--picture-in-picture] size-5 text-node-component-header-icon"
></i>
</IconButton>
</div>
<LODFallback />
</div>
<!-- Title Buttons -->
<div v-if="!readonly" class="flex items-center lod-toggle">
<IconButton
v-if="isSubgraphNode"
size="sm"
type="transparent"
class="text-stone-200 dark-theme:text-slate-300"
data-testid="subgraph-enter-button"
title="Enter Subgraph"
@click.stop="handleEnterSubgraph"
@dblclick.stop
>
<i class="pi pi-external-link"></i>
</IconButton>
</div>
</div>
</template>
<script setup lang="ts">
import { type Ref, computed, inject, onErrorCaptured, ref, watch } from 'vue'
import { computed, onErrorCaptured, ref, watch } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
import EditableText from '@/components/common/EditableText.vue'
@@ -82,16 +93,16 @@ import {
getLocatorIdFromNodeData,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import LODFallback from './LODFallback.vue'
interface NodeHeaderProps {
nodeData?: VueNodeData
readonly?: boolean
collapsed?: boolean
}
const { nodeData, readonly, collapsed } = defineProps<NodeHeaderProps>()
const { nodeData, collapsed } = defineProps<NodeHeaderProps>()
const emit = defineEmits<{
collapse: []
@@ -112,21 +123,22 @@ onErrorCaptured((error) => {
// Editing state
const isEditing = ref(false)
const tooltipContainer =
inject<Ref<HTMLElement | undefined>>('tooltipContainer')
const { getNodeDescription, createTooltipConfig } = useNodeTooltips(
nodeData?.type || '',
tooltipContainer
nodeData?.type || ''
)
const tooltipConfig = computed(() => {
if (readonly || isEditing.value) {
if (isEditing.value) {
return { value: '', disabled: true }
}
const description = getNodeDescription.value
return createTooltipConfig(description)
})
const enterSubgraphTooltipConfig = computed(() => {
return createTooltipConfig(st('enterSubgraph', 'Enter Subgraph'))
})
const headerStyle = computed(() => {
const colorPaletteStore = useColorPaletteStore()
@@ -191,9 +203,7 @@ const handleCollapse = () => {
}
const handleDoubleClick = () => {
if (!readonly) {
isEditing.value = true
}
isEditing.value = true
}
const handleTitleEdit = (newTitle: string) => {

View File

@@ -91,18 +91,19 @@ const mountSlots = (nodeData: VueNodeData, readonly = false) => {
}
describe('NodeSlots.vue', () => {
it('filters out inputs with widget property and maps indexes correctly', () => {
it('filters out inputs with widget property and maps indexes correctly', (context) => {
context.skip('Filtering not working as expected, needs diagnosis')
// Two inputs without widgets (object and string) and one with widget (filtered)
const inputObjNoWidget = {
const inputObjNoWidget: INodeInputSlot = {
name: 'objNoWidget',
type: 'number',
boundingRect: new Float32Array([0, 0, 0, 0]),
boundingRect: [0, 0, 0, 0],
link: null
}
const inputObjWithWidget = {
const inputObjWithWidget: INodeInputSlot = {
name: 'objWithWidget',
type: 'number',
boundingRect: new Float32Array([0, 0, 0, 0]),
boundingRect: [0, 0, 0, 0],
widget: { name: 'objWithWidget' },
link: null
}
@@ -147,16 +148,16 @@ describe('NodeSlots.vue', () => {
})
it('maps outputs and passes correct indexes', () => {
const outputObj = {
const outputObj: INodeOutputSlot = {
name: 'outA',
type: 'any',
boundingRect: new Float32Array([0, 0, 0, 0]),
boundingRect: [0, 0, 0, 0],
links: []
}
const outputObjB = {
const outputObjB: INodeOutputSlot = {
name: 'outB',
type: 'any',
boundingRect: new Float32Array([0, 0, 0, 0]),
boundingRect: [0, 0, 0, 0],
links: []
}
const outputs: INodeOutputSlot[] = [outputObj, outputObjB]
@@ -186,25 +187,4 @@ describe('NodeSlots.vue', () => {
expect(wrapper.findAll('.stub-input-slot').length).toBe(0)
expect(wrapper.findAll('.stub-output-slot').length).toBe(0)
})
it('passes readonly to child slots', () => {
const wrapper = mountSlots(
makeNodeData({ inputs: [], outputs: [] }),
/* readonly */ true
)
const all = [
...wrapper
.findAll('.stub-input-slot')
.filter((w) => w.element instanceof HTMLElement)
.map((w) => w.element as HTMLElement),
...wrapper
.findAll('.stub-output-slot')
.filter((w) => w.element instanceof HTMLElement)
.map((w) => w.element as HTMLElement)
]
expect(all.length).toBe(2)
for (const el of all) {
expect.soft(el.dataset.readonly).toBe('true')
}
})
})

View File

@@ -11,19 +11,17 @@
:node-type="nodeData?.type || ''"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:index="getActualInputIndex(input, index)"
:readonly="readonly"
/>
</div>
<div v-if="filteredOutputs.length" class="flex flex-col gap-1 ml-auto">
<div v-if="nodeData?.outputs?.length" class="flex flex-col gap-1 ml-auto">
<OutputSlot
v-for="(output, index) in filteredOutputs"
v-for="(output, index) in nodeData.outputs"
:key="`output-${index}`"
:slot-data="output"
:node-type="nodeData?.type || ''"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:index="index"
:readonly="readonly"
/>
</div>
</div>
@@ -42,10 +40,9 @@ import OutputSlot from './OutputSlot.vue'
interface NodeSlotsProps {
nodeData?: VueNodeData
readonly?: boolean
}
const { nodeData = null, readonly } = defineProps<NodeSlotsProps>()
const { nodeData = null } = defineProps<NodeSlotsProps>()
// Filter out input slots that have corresponding widgets
const filteredInputs = computed(() => {
@@ -71,20 +68,6 @@ const filteredInputs = computed(() => {
)
})
// Outputs don't have widgets, so we don't need to filter them
const filteredOutputs = computed(() => {
const outputs = nodeData?.outputs || []
return outputs.map((output) =>
isSlotObject(output)
? output
: ({
name: typeof output === 'string' ? output : '',
type: 'any',
boundingRect: [0, 0, 0, 0] as [number, number, number, number]
} as INodeSlot)
)
})
// Get the actual index of an input slot in the node's inputs array
// (accounting for filtered widget slots)
const getActualInputIndex = (

View File

@@ -34,7 +34,6 @@
}"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:index="getWidgetInputIndex(widget)"
:readonly="readonly"
:dot-only="true"
/>
</div>
@@ -44,7 +43,7 @@
v-tooltip.left="widget.tooltipConfig"
:widget="widget.simplified"
:model-value="widget.value"
:readonly="readonly"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
class="flex-1"
@update:model-value="widget.updateHandler"
/>
@@ -53,7 +52,7 @@
</template>
<script setup lang="ts">
import { type Ref, computed, inject, onErrorCaptured, ref } from 'vue'
import { computed, onErrorCaptured, ref } from 'vue'
import type {
SafeWidgetData,
@@ -75,10 +74,9 @@ import InputSlot from './InputSlot.vue'
interface NodeWidgetsProps {
nodeData?: VueNodeData
readonly?: boolean
}
const { nodeData, readonly } = defineProps<NodeWidgetsProps>()
const { nodeData } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
@@ -100,11 +98,8 @@ onErrorCaptured((error) => {
})
const nodeType = computed(() => nodeData?.type || '')
const tooltipContainer =
inject<Ref<HTMLElement | undefined>>('tooltipContainer')
const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
nodeType.value,
tooltipContainer
nodeType.value
)
interface ProcessedWidget {

View File

@@ -5,7 +5,7 @@
<!-- Slot Name -->
<span
v-if="!dotOnly"
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200 lod-toggle"
class="whitespace-nowrap text-sm font-normal text-node-component-slot-text lod-toggle"
>
{{ slotData.localized_name || slotData.name || `Output ${index}` }}
</span>
@@ -16,7 +16,7 @@
ref="connectionDotRef"
:color="slotColor"
class="translate-x-1/2"
v-on="readonly ? {} : { pointerdown: onPointerDown }"
@pointerdown="onPointerDown"
/>
</div>
</template>
@@ -24,9 +24,7 @@
<script setup lang="ts">
import {
type ComponentPublicInstance,
type Ref,
computed,
inject,
onErrorCaptured,
ref,
watchEffect
@@ -50,7 +48,6 @@ interface OutputSlotProps {
index: number
connected?: boolean
compatible?: boolean
readonly?: boolean
dotOnly?: boolean
}
@@ -61,11 +58,8 @@ const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
const tooltipContainer =
inject<Ref<HTMLElement | undefined>>('tooltipContainer')
const { getOutputSlotTooltip, createTooltipConfig } = useNodeTooltips(
props.nodeType || '',
tooltipContainer
props.nodeType || ''
)
const tooltipConfig = computed(() => {
@@ -87,7 +81,7 @@ const slotColor = computed(() => getSlotColor(props.slotData.type))
const slotWrapperClass = computed(() =>
cn(
'lg-slot lg-slot--output flex items-center justify-end group rounded-l-lg h-6',
props.readonly ? 'cursor-default opacity-70' : 'cursor-crosshair',
'cursor-crosshair',
props.dotOnly
? 'lg-slot--dot-only justify-center'
: 'pl-6 hover:bg-black/5 hover:dark:bg-white/5',
@@ -120,7 +114,6 @@ useSlotElementTracking({
const { onPointerDown } = useSlotLinkInteraction({
nodeId: props.nodeId ?? '',
index: props.index,
type: 'output',
readonly: props.readonly
type: 'output'
})
</script>

View File

@@ -28,11 +28,11 @@ defineExpose({
:style="{ backgroundColor: color }"
:class="
cn(
'bg-[#5B5E7D] rounded-full',
'bg-slate-300 rounded-full',
'transition-all duration-150',
'cursor-crosshair',
'border border-solid border-black/5 dark-theme:border-white/10',
'group-hover/slot:border-black/20 dark-theme:group-hover/slot:border-white/50 group-hover/slot:scale-125',
'border border-solid border-node-component-slot-dot-outline',
'group-hover/slot:[--node-component-slot-dot-outline-opacity-mult:5] group-hover/slot:scale-125',
multi ? 'w-3 h-6' : 'size-3'
)
"

View File

@@ -0,0 +1,45 @@
import type { SlotLayout } from '@/renderer/core/layout/types'
interface PendingMoveData {
clientX: number
clientY: number
target: EventTarget | null
}
interface SlotLinkDragSession {
compatCache: Map<string, boolean>
nodePreferred: Map<
number,
{ index: number; key: string; layout: SlotLayout } | null
>
lastHoverSlotKey: string | null
lastHoverNodeId: number | null
lastCandidateKey: string | null
pendingMove: PendingMoveData | null
reset: () => void
dispose: () => void
}
export function createSlotLinkDragSession(): SlotLinkDragSession {
const state: SlotLinkDragSession = {
compatCache: new Map(),
nodePreferred: new Map(),
lastHoverSlotKey: null,
lastHoverNodeId: null,
lastCandidateKey: null,
pendingMove: null,
reset: () => {
state.compatCache = new Map()
state.nodePreferred = new Map()
state.lastHoverSlotKey = null
state.lastHoverNodeId = null
state.lastCandidateKey = null
state.pendingMove = null
},
dispose: () => {
state.reset()
}
}
return state
}

View File

@@ -4,10 +4,12 @@ import { nextTick, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
const forwardEventToCanvasMock = vi.fn()
// Mock the dependencies
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
useCanvasInteractions: () => ({
forwardEventToCanvas: vi.fn(),
forwardEventToCanvas: forwardEventToCanvasMock,
shouldHandleNodePointerEvents: ref(true)
})
}))
@@ -69,6 +71,7 @@ const createMouseEvent = (
describe('useNodePointerInteractions', () => {
beforeEach(() => {
vi.clearAllMocks()
forwardEventToCanvasMock.mockClear()
})
it('should only start drag on left-click', async () => {
@@ -100,6 +103,34 @@ describe('useNodePointerInteractions', () => {
)
})
it('forwards middle mouse interactions to the canvas', () => {
const mockNodeData = createMockVueNodeData()
const mockOnPointerUp = vi.fn()
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnPointerUp
)
const middlePointerDown = createPointerEvent('pointerdown', { button: 1 })
pointerHandlers.onPointerdown(middlePointerDown)
expect(forwardEventToCanvasMock).toHaveBeenCalledWith(middlePointerDown)
forwardEventToCanvasMock.mockClear()
const middlePointerMove = createPointerEvent('pointermove', { buttons: 4 })
pointerHandlers.onPointermove(middlePointerMove)
expect(forwardEventToCanvasMock).toHaveBeenCalledWith(middlePointerMove)
forwardEventToCanvasMock.mockClear()
const middlePointerUp = createPointerEvent('pointerup', { button: 1 })
pointerHandlers.onPointerup(middlePointerUp)
expect(forwardEventToCanvasMock).toHaveBeenCalledWith(middlePointerUp)
expect(mockOnPointerUp).not.toHaveBeenCalled()
})
it('should distinguish drag from click based on distance threshold', async () => {
const mockNodeData = createMockVueNodeData()
const mockOnPointerUp = vi.fn()

View File

@@ -1,5 +1,6 @@
import { type MaybeRefOrGetter, computed, onUnmounted, ref, toValue } from 'vue'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
@@ -34,6 +35,12 @@ export function useNodePointerInteractions(
const { forwardEventToCanvas, shouldHandleNodePointerEvents } =
useCanvasInteractions()
const forwardMiddlePointerIfNeeded = (event: PointerEvent) => {
if (!isMiddlePointerInput(event)) return false
forwardEventToCanvas(event)
return true
}
// Drag state for styling
const isDragging = ref(false)
const dragStyle = computed(() => {
@@ -52,6 +59,19 @@ export function useNodePointerInteractions(
return
}
if (forwardMiddlePointerIfNeeded(event)) return
const stopNodeDragTarget =
event.target instanceof HTMLElement
? event.target.closest('[data-capture-node="true"]')
: null
if (stopNodeDragTarget) {
if (!shouldHandleNodePointerEvents.value) {
forwardEventToCanvas(event)
}
return
}
// Only start drag on left-click (button 0)
if (event.button !== 0) {
return
@@ -79,6 +99,8 @@ export function useNodePointerInteractions(
}
const handlePointerMove = (event: PointerEvent) => {
if (forwardMiddlePointerIfNeeded(event)) return
if (isDragging.value) {
void handleDrag(event)
}
@@ -118,6 +140,8 @@ export function useNodePointerInteractions(
}
const handlePointerUp = (event: PointerEvent) => {
if (forwardMiddlePointerIfNeeded(event)) return
if (isDragging.value) {
handleDragTermination(event, 'drag end')
}

View File

@@ -1,23 +1,92 @@
import type { TooltipDirectivePassThroughOptions } from 'primevue'
import { type MaybeRef, type Ref, computed, unref } from 'vue'
import type {
TooltipDirectivePassThroughOptions,
TooltipPassThroughMethodOptions
} from 'primevue/tooltip'
import { type MaybeRef, computed, ref, unref } from 'vue'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import { st } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
/**
* Hide all visible tooltips by dispatching mouseleave events
*
*
* IMPORTANT: this escape is needed for many reason due to primevue's directive tooltip system.
* We cannot use PT to conditionally render the tooltips because the entire PT object only run
* once during the initialization of the directive not every mount/unmount.
* Once the directive is constructed its no longer reactive in the traditional sense.
* We have to use something non destructive like mouseevents to dismiss the tooltip.
*
* TODO: use a better tooltip component like RekaUI for vue nodes specifically.
*/
const tooltipsTemporarilyDisabled = ref(false)
const hideTooltipsGlobally = () => {
// Get all visible tooltip elements
const tooltips = document.querySelectorAll('.p-tooltip')
// Early return if no tooltips are visible
if (tooltips.length === 0) return
tooltips.forEach((tooltipEl) => {
const tooltipId = tooltipEl.id
if (!tooltipId) return
// Find the target element that owns this tooltip
const targetElements = document.querySelectorAll('[data-pd-tooltip="true"]')
for (const targetEl of targetElements) {
if ((targetEl as any).$_ptooltipId === tooltipId) {
;(targetEl as HTMLElement).dispatchEvent(
new MouseEvent('mouseleave', { bubbles: true })
)
break
}
}
})
// Disable tooltips temporarily after hiding (for drag operations)
tooltipsTemporarilyDisabled.value = true
}
/**
* Re-enable tooltips after pointer interaction ends
*/
const handlePointerUp = () => {
tooltipsTemporarilyDisabled.value = false
}
// Global tooltip hiding system
const globalTooltipState = { listenersSetup: false }
function setupGlobalTooltipHiding() {
if (globalTooltipState.listenersSetup) return
document.addEventListener('pointerdown', hideTooltipsGlobally)
document.addEventListener('pointerup', handlePointerUp)
window.addEventListener('wheel', hideTooltipsGlobally, {
capture: true, //Need this to bypass the event layer from Litegraph
passive: true
})
globalTooltipState.listenersSetup = true
}
/**
* Composable for managing Vue node tooltips
* Provides tooltip text for node headers, slots, and widgets
*/
export function useNodeTooltips(
nodeType: MaybeRef<string>,
containerRef?: Ref<HTMLElement | undefined>
) {
export function useNodeTooltips(nodeType: MaybeRef<string>) {
const nodeDefStore = useNodeDefStore()
const settingsStore = useSettingStore()
// Setup global pointerdown listener once
setupGlobalTooltipHiding()
// Check if tooltips are globally enabled
const tooltipsEnabled = computed(() =>
settingsStore.get('Comfy.EnableTooltips')
@@ -76,38 +145,36 @@ export function useNodeTooltips(
/**
* Create tooltip configuration object for v-tooltip directive
* Components wrap this in computed() for reactivity
*/
const createTooltipConfig = (text: string) => {
const tooltipDelay = settingsStore.get('LiteGraph.Node.TooltipDelay')
const tooltipText = text || ''
const config: {
value: string
showDelay: number
disabled: boolean
appendTo?: HTMLElement
pt?: TooltipDirectivePassThroughOptions
} = {
return {
value: tooltipText,
showDelay: tooltipDelay as number,
disabled: !tooltipsEnabled.value || !tooltipText,
hideDelay: 0, // Immediate hiding
disabled:
!tooltipsEnabled.value ||
!tooltipText ||
tooltipsTemporarilyDisabled.value, // this reactive value works but only on next mount,
// so if the tooltip is already visible changing this will not hide it
pt: {
text: {
class:
'bg-pure-white dark-theme:bg-charcoal-800 border dark-theme:border-slate-300 rounded-md px-4 py-2 text-charcoal-700 dark-theme:text-pure-white text-sm font-normal leading-tight max-w-75 shadow-none'
'border-node-component-tooltip-border bg-node-component-tooltip-surface border rounded-md px-4 py-2 text-node-component-tooltip text-sm font-normal leading-tight max-w-75 shadow-none'
},
arrow: {
class: 'before:border-slate-300'
}
}
arrow: ({ context }: TooltipPassThroughMethodOptions) => ({
class: cn(
context.top && 'border-t-node-component-tooltip-border',
context.bottom && 'border-b-node-component-tooltip-border',
context.left && 'border-l-node-component-tooltip-border ',
context.right && 'border-r-node-component-tooltip-border'
)
})
} as TooltipDirectivePassThroughOptions
}
// If we have a container reference, append tooltips to it
if (containerRef?.value) {
config.appendTo = containerRef.value
}
return config
}
return {

View File

@@ -17,19 +17,17 @@ import {
isSizeEqual
} from '@/renderer/core/layout/utils/geometry'
import { useNodeSlotRegistryStore } from '@/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore'
import { createRafBatch } from '@/utils/rafBatch'
// RAF batching
const pendingNodes = new Set<string>()
let rafId: number | null = null
const raf = createRafBatch(() => {
flushScheduledSlotLayoutSync()
})
function scheduleSlotLayoutSync(nodeId: string) {
pendingNodes.add(nodeId)
if (rafId == null) {
rafId = requestAnimationFrame(() => {
rafId = null
flushScheduledSlotLayoutSync()
})
}
raf.schedule()
}
function flushScheduledSlotLayoutSync() {

View File

@@ -2,22 +2,34 @@ import { type Fn, useEventListener } from '@vueuse/core'
import { onBeforeUnmount } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import type { Reroute } from '@/lib/litegraph/src/Reroute'
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { evaluateCompatibility } from '@/renderer/core/canvas/links/slotLinkCompatibility'
import { createLinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
import type { LinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
import {
type SlotDropCandidate,
useSlotLinkDragState
} from '@/renderer/core/canvas/links/slotLinkDragState'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { SlotLayout } from '@/renderer/core/layout/types'
import type { Point } from '@/renderer/core/layout/types'
import { toPoint } from '@/renderer/core/layout/utils/geometry'
import { createSlotLinkDragSession } from '@/renderer/extensions/vueNodes/composables/slotLinkDragSession'
import { app } from '@/scripts/app'
import { createRafBatch } from '@/utils/rafBatch'
interface SlotInteractionOptions {
nodeId: string
index: number
type: 'input' | 'output'
readonly?: boolean
}
interface SlotInteractionHandlers {
@@ -67,23 +79,23 @@ function createPointerSession(): PointerSession {
export function useSlotLinkInteraction({
nodeId,
index,
type,
readonly
type
}: SlotInteractionOptions): SlotInteractionHandlers {
if (readonly) {
return {
onPointerDown: () => {}
}
}
const { state, beginDrag, endDrag, updatePointerPosition } =
const { state, beginDrag, endDrag, updatePointerPosition, setCandidate } =
useSlotLinkDragState()
const conversion = useSharedCanvasPositionConversion()
const pointerSession = createPointerSession()
let activeAdapter: LinkConnectorAdapter | null = null
// Per-drag drag-state cache
const dragSession = createSlotLinkDragSession()
function candidateFromTarget(
target: EventTarget | null
): SlotDropCandidate | null {
if (!(target instanceof HTMLElement)) return null
const key = target.dataset['slotKey']
const elWithKey = target.closest<HTMLElement>('[data-slot-key]')
const key = elWithKey?.dataset['slotKey']
if (!key) return null
const layout = layoutStore.getSlotLayout(key)
@@ -91,23 +103,218 @@ export function useSlotLinkInteraction({
const candidate: SlotDropCandidate = { layout, compatible: false }
if (state.source) {
candidate.compatible = evaluateCompatibility(
state.source,
candidate
).allowable
const graph = app.canvas?.graph
const adapter = ensureActiveAdapter()
if (graph && adapter) {
const cached = dragSession.compatCache.get(key)
if (cached != null) {
candidate.compatible = cached
} else {
const compatible =
layout.type === 'input'
? adapter.isInputValidDrop(layout.nodeId, layout.index)
: adapter.isOutputValidDrop(layout.nodeId, layout.index)
dragSession.compatCache.set(key, compatible)
candidate.compatible = compatible
}
}
return candidate
}
const conversion = useSharedCanvasPositionConversion()
function candidateFromNodeTarget(
target: EventTarget | null
): SlotDropCandidate | null {
if (!(target instanceof HTMLElement)) return null
const elWithNode = target.closest<HTMLElement>('[data-node-id]')
const nodeIdStr = elWithNode?.dataset['nodeId']
if (!nodeIdStr) return null
const pointerSession = createPointerSession()
const adapter = ensureActiveAdapter()
const graph = app.canvas?.graph
if (!adapter || !graph) return null
const nodeId = Number(nodeIdStr)
// Cached preferred slot for this node within this drag
const cachedPreferred = dragSession.nodePreferred.get(nodeId)
if (cachedPreferred !== undefined) {
return cachedPreferred
? { layout: cachedPreferred.layout, compatible: true }
: null
}
const node = graph.getNodeById(nodeId)
if (!node) return null
const firstLink = adapter.renderLinks[0]
if (!firstLink) return null
const connectingTo = adapter.linkConnector.state.connectingTo
if (connectingTo !== 'input' && connectingTo !== 'output') return null
const isInput = connectingTo === 'input'
const slotType = firstLink.fromSlot.type
const res = isInput
? node.findInputByType(slotType)
: node.findOutputByType(slotType)
const index = res?.index
if (index == null) return null
const key = getSlotKey(String(nodeId), index, isInput)
const layout = layoutStore.getSlotLayout(key)
if (!layout) return null
const compatible = isInput
? adapter.isInputValidDrop(nodeId, index)
: adapter.isOutputValidDrop(nodeId, index)
if (compatible) {
dragSession.compatCache.set(key, true)
const preferred = { index, key, layout }
dragSession.nodePreferred.set(nodeId, preferred)
return { layout, compatible: true }
} else {
dragSession.compatCache.set(key, false)
dragSession.nodePreferred.set(nodeId, null)
return null
}
}
const ensureActiveAdapter = (): LinkConnectorAdapter | null => {
if (!activeAdapter) activeAdapter = createLinkConnectorAdapter()
return activeAdapter
}
function hasCanConnectToReroute(
link: RenderLink
): link is RenderLink & { canConnectToReroute: (r: Reroute) => boolean } {
return 'canConnectToReroute' in link
}
type ToInputLink = RenderLink & { toType: 'input' }
type ToOutputLink = RenderLink & { toType: 'output' }
const isToInputLink = (link: RenderLink): link is ToInputLink =>
link.toType === 'input'
const isToOutputLink = (link: RenderLink): link is ToOutputLink =>
link.toType === 'output'
function connectLinksToInput(
links: ReadonlyArray<RenderLink>,
node: LGraphNode,
inputSlot: INodeInputSlot
): boolean {
const validCandidates = links
.filter(isToInputLink)
.filter((link) => link.canConnectToInput(node, inputSlot))
for (const link of validCandidates) {
link.connectToInput(node, inputSlot, activeAdapter?.linkConnector.events)
}
return validCandidates.length > 0
}
function connectLinksToOutput(
links: ReadonlyArray<RenderLink>,
node: LGraphNode,
outputSlot: INodeOutputSlot
): boolean {
const validCandidates = links
.filter(isToOutputLink)
.filter((link) => link.canConnectToOutput(node, outputSlot))
for (const link of validCandidates) {
link.connectToOutput(
node,
outputSlot,
activeAdapter?.linkConnector.events
)
}
return validCandidates.length > 0
}
const resolveLinkOrigin = (
link: LLink | undefined
): { position: Point; direction: LinkDirection } | null => {
if (!link) return null
const slotKey = getSlotKey(String(link.origin_id), link.origin_slot, false)
const layout = layoutStore.getSlotLayout(slotKey)
if (!layout) return null
return { position: { ...layout.position }, direction: LinkDirection.NONE }
}
const resolveExistingInputLinkAnchor = (
graph: LGraph,
inputSlot: INodeInputSlot | undefined
): { position: Point; direction: LinkDirection } | null => {
if (!inputSlot) return null
const directLink = graph.getLink(inputSlot.link)
if (directLink) {
const reroutes = LLink.getReroutes(graph, directLink)
const lastReroute = reroutes.at(-1)
if (lastReroute) {
const rerouteLayout = layoutStore.getRerouteLayout(lastReroute.id)
if (rerouteLayout) {
return {
position: { ...rerouteLayout.position },
direction: LinkDirection.NONE
}
}
const pos = lastReroute.pos
if (pos) {
return {
position: toPoint(pos[0], pos[1]),
direction: LinkDirection.NONE
}
}
}
const directAnchor = resolveLinkOrigin(directLink)
if (directAnchor) return directAnchor
}
const floatingLinkIterator = inputSlot._floatingLinks?.values()
const floatingLink = floatingLinkIterator
? floatingLinkIterator.next().value
: undefined
if (!floatingLink) return null
if (floatingLink.parentId != null) {
const rerouteLayout = layoutStore.getRerouteLayout(floatingLink.parentId)
if (rerouteLayout) {
return {
position: { ...rerouteLayout.position },
direction: LinkDirection.NONE
}
}
const reroute = graph.getReroute(floatingLink.parentId)
if (reroute) {
return {
position: toPoint(reroute.pos[0], reroute.pos[1]),
direction: LinkDirection.NONE
}
}
}
return null
}
const cleanupInteraction = () => {
activeAdapter?.reset()
pointerSession.clear()
endDrag()
activeAdapter = null
raf.cancel()
dragSession.dispose()
}
const updatePointerState = (event: PointerEvent) => {
@@ -121,50 +328,195 @@ export function useSlotLinkInteraction({
updatePointerPosition(clientX, clientY, canvasX, canvasY)
}
const handlePointerMove = (event: PointerEvent) => {
if (!pointerSession.matches(event)) return
updatePointerState(event)
const processPointerMoveFrame = () => {
const data = dragSession.pendingMove
if (!data) return
dragSession.pendingMove = null
const [canvasX, canvasY] = conversion.clientPosToCanvasPos([
data.clientX,
data.clientY
])
updatePointerPosition(data.clientX, data.clientY, canvasX, canvasY)
let hoveredSlotKey: string | null = null
let hoveredNodeId: number | null = null
const target = data.target
if (target instanceof HTMLElement) {
hoveredSlotKey =
target.closest<HTMLElement>('[data-slot-key]')?.dataset['slotKey'] ??
null
if (!hoveredSlotKey) {
const nodeIdStr =
target.closest<HTMLElement>('[data-node-id]')?.dataset['nodeId']
hoveredNodeId = nodeIdStr != null ? Number(nodeIdStr) : null
}
}
const hoverChanged =
hoveredSlotKey !== dragSession.lastHoverSlotKey ||
hoveredNodeId !== dragSession.lastHoverNodeId
let candidate: SlotDropCandidate | null = state.candidate
if (hoverChanged) {
const slotCandidate = candidateFromTarget(target)
const nodeCandidate = slotCandidate
? null
: candidateFromNodeTarget(target)
candidate = slotCandidate ?? nodeCandidate
dragSession.lastHoverSlotKey = hoveredSlotKey
dragSession.lastHoverNodeId = hoveredNodeId
}
const newCandidate = candidate?.compatible ? candidate : null
const newCandidateKey = newCandidate
? getSlotKey(
newCandidate.layout.nodeId,
newCandidate.layout.index,
newCandidate.layout.type === 'input'
)
: null
if (newCandidateKey !== dragSession.lastCandidateKey) {
setCandidate(newCandidate)
dragSession.lastCandidateKey = newCandidateKey
}
app.canvas?.setDirty(true)
}
const raf = createRafBatch(processPointerMoveFrame)
const connectSlots = (slotLayout: SlotLayout) => {
const canvas = app.canvas
const graph = canvas?.graph
const source = state.source
if (!canvas || !graph || !source) return
const handlePointerMove = (event: PointerEvent) => {
if (!pointerSession.matches(event)) return
dragSession.pendingMove = {
clientX: event.clientX,
clientY: event.clientY,
target: event.target
}
raf.schedule()
}
const sourceNode = graph.getNodeById(Number(source.nodeId))
const targetNode = graph.getNodeById(Number(slotLayout.nodeId))
if (!sourceNode || !targetNode) return
// Attempt to finalize by connecting to a DOM slot candidate
const tryConnectToCandidate = (
candidate: SlotDropCandidate | null
): boolean => {
if (!candidate?.compatible) return false
const graph = app.canvas?.graph
const adapter = ensureActiveAdapter()
if (!graph || !adapter) return false
if (source.type === 'output' && slotLayout.type === 'input') {
const outputSlot = sourceNode.outputs?.[source.slotIndex]
const inputSlot = targetNode.inputs?.[slotLayout.index]
if (!outputSlot || !inputSlot) return
graph.beforeChange()
sourceNode.connectSlots(outputSlot, targetNode, inputSlot, undefined)
return
const nodeId = Number(candidate.layout.nodeId)
const targetNode = graph.getNodeById(nodeId)
if (!targetNode) return false
if (candidate.layout.type === 'input') {
const inputSlot = targetNode.inputs?.[candidate.layout.index]
return (
!!inputSlot &&
connectLinksToInput(adapter.renderLinks, targetNode, inputSlot)
)
}
if (source.type === 'input' && slotLayout.type === 'output') {
const inputSlot = sourceNode.inputs?.[source.slotIndex]
const outputSlot = targetNode.outputs?.[slotLayout.index]
if (!inputSlot || !outputSlot) return
graph.beforeChange()
sourceNode.disconnectInput(source.slotIndex, true)
targetNode.connectSlots(outputSlot, sourceNode, inputSlot, undefined)
if (candidate.layout.type === 'output') {
const outputSlot = targetNode.outputs?.[candidate.layout.index]
return (
!!outputSlot &&
connectLinksToOutput(adapter.renderLinks, targetNode, outputSlot)
)
}
return false
}
// Attempt to finalize by dropping on a reroute under the pointer
const tryConnectViaRerouteAtPointer = (): boolean => {
const rerouteLayout = layoutStore.queryRerouteAtPoint({
x: state.pointer.canvas.x,
y: state.pointer.canvas.y
})
const graph = app.canvas?.graph
const adapter = ensureActiveAdapter()
if (!rerouteLayout || !graph || !adapter) return false
const reroute = graph.getReroute(rerouteLayout.id)
if (!reroute || !adapter.isRerouteValidDrop(reroute.id)) return false
let didConnect = false
const results = reroute.findTargetInputs() ?? []
const maybeReroutes = reroute.getReroutes()
if (results.length && maybeReroutes !== null) {
const originalReroutes = maybeReroutes.slice(0, -1).reverse()
for (const link of adapter.renderLinks) {
if (!isToInputLink(link)) continue
for (const result of results) {
link.connectToRerouteInput(
reroute,
result,
adapter.linkConnector.events,
originalReroutes
)
didConnect = true
}
}
}
const sourceOutput = reroute.findSourceOutput()
if (sourceOutput) {
const { node, output } = sourceOutput
for (const link of adapter.renderLinks) {
if (!isToOutputLink(link)) continue
if (hasCanConnectToReroute(link) && !link.canConnectToReroute(reroute))
continue
link.connectToRerouteOutput(
reroute,
node,
output,
adapter.linkConnector.events
)
didConnect = true
}
}
return didConnect
}
const finishInteraction = (event: PointerEvent) => {
if (!pointerSession.matches(event)) return
event.preventDefault()
if (state.source) {
const candidate = candidateFromTarget(event.target)
if (candidate?.compatible) {
connectSlots(candidate.layout)
}
raf.flush()
if (!state.source) {
cleanupInteraction()
app.canvas?.setDirty(true)
return
}
// Prefer using the snapped candidate captured during hover for perf + consistency
const snappedCandidate = state.candidate?.compatible
? state.candidate
: null
let connected = tryConnectToCandidate(snappedCandidate)
// Fallback to DOM slot under pointer (if any), then node fallback, then reroute
if (!connected) {
const domCandidate = candidateFromTarget(event.target)
connected = tryConnectToCandidate(domCandidate)
}
if (!connected) {
const nodeCandidate = candidateFromNodeTarget(event.target)
connected = tryConnectToCandidate(nodeCandidate)
}
if (!connected) connected = tryConnectViaRerouteAtPointer() || connected
// Drop on canvas: disconnect moving input link(s)
if (!connected && !snappedCandidate && state.source.type === 'input') {
ensureActiveAdapter()?.disconnectMovingLinks()
}
cleanupInteraction()
@@ -177,6 +529,8 @@ export function useSlotLinkInteraction({
const handlePointerCancel = (event: PointerEvent) => {
if (!pointerSession.matches(event)) return
raf.flush()
cleanupInteraction()
app.canvas?.setDirty(true)
}
@@ -190,19 +544,82 @@ export function useSlotLinkInteraction({
const graph = canvas?.graph
if (!canvas || !graph) return
ensureActiveAdapter()
raf.cancel()
dragSession.reset()
const layout = layoutStore.getSlotLayout(
getSlotKey(nodeId, index, type === 'input')
)
if (!layout) return
const resolvedNode = graph.getNodeById(Number(nodeId))
const slot =
type === 'input'
? resolvedNode?.inputs?.[index]
: resolvedNode?.outputs?.[index]
const numericNodeId = Number(nodeId)
const isInputSlot = type === 'input'
const isOutputSlot = type === 'output'
const direction =
slot?.dir ?? (type === 'input' ? LinkDirection.LEFT : LinkDirection.RIGHT)
const resolvedNode = graph.getNodeById(numericNodeId)
const inputSlot = isInputSlot ? resolvedNode?.inputs?.[index] : undefined
const outputSlot = isOutputSlot ? resolvedNode?.outputs?.[index] : undefined
const ctrlOrMeta = event.ctrlKey || event.metaKey
const inputLinkId = inputSlot?.link ?? null
const inputFloatingCount = inputSlot?._floatingLinks?.size ?? 0
const hasExistingInputLink = inputLinkId != null || inputFloatingCount > 0
const outputLinkCount = outputSlot?.links?.length ?? 0
const outputFloatingCount = outputSlot?._floatingLinks?.size ?? 0
const hasExistingOutputLink = outputLinkCount > 0 || outputFloatingCount > 0
const shouldBreakExistingInputLink =
isInputSlot &&
hasExistingInputLink &&
ctrlOrMeta &&
event.altKey &&
!event.shiftKey
const existingInputLink =
isInputSlot && inputLinkId != null
? graph.getLink(inputLinkId)
: undefined
if (shouldBreakExistingInputLink && resolvedNode) {
resolvedNode.disconnectInput(index, true)
}
const baseDirection = isInputSlot
? inputSlot?.dir ?? LinkDirection.LEFT
: outputSlot?.dir ?? LinkDirection.RIGHT
const existingAnchor =
isInputSlot && !shouldBreakExistingInputLink
? resolveExistingInputLinkAnchor(graph, inputSlot)
: null
const shouldMoveExistingOutput =
isOutputSlot && event.shiftKey && hasExistingOutputLink
const shouldMoveExistingInput =
isInputSlot && !shouldBreakExistingInputLink && hasExistingInputLink
const adapter = ensureActiveAdapter()
if (adapter) {
if (isOutputSlot) {
adapter.beginFromOutput(numericNodeId, index, {
moveExisting: shouldMoveExistingOutput
})
} else {
adapter.beginFromInput(numericNodeId, index, {
moveExisting: shouldMoveExistingInput
})
}
}
const direction = existingAnchor?.direction ?? baseDirection
const startPosition = existingAnchor?.position ?? {
x: layout.position.x,
y: layout.position.y
}
beginDrag(
{
@@ -210,7 +627,11 @@ export function useSlotLinkInteraction({
slotIndex: index,
type,
direction,
position: layout.position
position: startPosition,
linkId: !shouldBreakExistingInputLink
? existingInputLink?.id
: undefined,
movingExistingOutput: shouldMoveExistingOutput
},
event.pointerId
)

View File

@@ -9,27 +9,27 @@ import { useExecutionStore } from '@/stores/executionStore'
* Provides reactive access to execution state and progress for a specific node
* by injecting execution data from the parent GraphCanvas provider.
*
* @param nodeIdMaybe - The ID of the node to track execution state for
* @param nodeLocatorIdMaybe - Locator ID (root or subgraph scoped) of the node to track
* @returns Object containing reactive execution state and progress
*/
export const useNodeExecutionState = (
nodeIdMaybe: MaybeRefOrGetter<string>
nodeLocatorIdMaybe: MaybeRefOrGetter<string | undefined>
) => {
const nodeId = toValue(nodeIdMaybe)
const { uniqueExecutingNodeIdStrings, nodeProgressStates } =
storeToRefs(useExecutionStore())
const locatorId = computed(() => toValue(nodeLocatorIdMaybe) ?? '')
const { nodeLocationProgressStates } = storeToRefs(useExecutionStore())
const executing = computed(() => {
return uniqueExecutingNodeIdStrings.value.has(nodeId)
const progressState = computed(() => {
const id = locatorId.value
return id ? nodeLocationProgressStates.value[id] : undefined
})
const executing = computed(() => progressState.value?.state === 'running')
const progress = computed(() => {
const state = nodeProgressStates.value[nodeId]
return state?.max > 0 ? state.value / state.max : undefined
const state = progressState.value
return state && state.max > 0 ? state.value / state.max : undefined
})
const progressState = computed(() => nodeProgressStates.value[nodeId])
const progressPercentage = computed(() => {
const prog = progress.value
return prog !== undefined ? Math.round(prog * 100) : undefined

View File

@@ -50,16 +50,6 @@ describe('WidgetButton Interactions', () => {
expect(mockCallback).toHaveBeenCalledTimes(1)
})
it('does not call callback when button is readonly', async () => {
const mockCallback = vi.fn()
const widget = createMockWidget({}, mockCallback)
const wrapper = mountComponent(widget, true)
await clickButton(wrapper)
expect(mockCallback).not.toHaveBeenCalled()
})
it('handles missing callback gracefully', async () => {
const widget = createMockWidget({}, undefined)
const wrapper = mountComponent(widget)
@@ -75,7 +65,6 @@ describe('WidgetButton Interactions', () => {
const numClicks = 8
await clickButton(wrapper)
for (let i = 0; i < numClicks; i++) {
await clickButton(wrapper)
}
@@ -134,26 +123,6 @@ describe('WidgetButton Interactions', () => {
})
})
describe('Readonly Mode', () => {
it('disables button when readonly', () => {
const widget = createMockWidget()
const wrapper = mountComponent(widget, true)
// Test the actual DOM button element instead of the Vue component props
const buttonElement = wrapper.find('button')
expect(buttonElement.element.disabled).toBe(true)
})
it('enables button when not readonly', () => {
const widget = createMockWidget()
const wrapper = mountComponent(widget, false)
// Test the actual DOM button element instead of the Vue component props
const buttonElement = wrapper.find('button')
expect(buttonElement.element.disabled).toBe(false)
})
})
describe('Widget Options', () => {
it('handles button with text only', () => {
const widget = createMockWidget({ label: 'Click Me' })

View File

@@ -3,12 +3,7 @@
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<Button
v-bind="filteredProps"
:disabled="readonly"
size="small"
@click="handleClick"
/>
<Button v-bind="filteredProps" size="small" @click="handleClick" />
</div>
</template>
@@ -25,7 +20,6 @@ import {
// Button widgets don't have a v-model value, they trigger actions
const props = defineProps<{
widget: SimplifiedWidget<void>
readonly?: boolean
}>()
// Button specific excluded props
@@ -36,7 +30,7 @@ const filteredProps = computed(() =>
)
const handleClick = () => {
if (!props.readonly && props.widget.callback) {
if (props.widget.callback) {
props.widget.callback()
}
}

View File

@@ -22,7 +22,6 @@ const value = defineModel<ChartData>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<ChartData, ChartWidgetOptions>
readonly?: boolean
}>()
const chartType = computed(() => props.widget.options?.type ?? 'line')

View File

@@ -93,7 +93,8 @@ describe('WidgetColorPicker Value Binding', () => {
expect(emitted![0]).toContain('#00ff00')
})
it('normalizes rgb() strings to #hex on emit', async () => {
it('normalizes rgb() strings to #hex on emit', async (context) => {
context.skip('needs diagnosis')
const widget = createMockWidget('#000000')
const wrapper = mountComponent(widget, '#000000')
@@ -186,24 +187,6 @@ describe('WidgetColorPicker Value Binding', () => {
})
})
describe('Readonly Mode', () => {
it('disables color picker when readonly', () => {
const widget = createMockWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000', true)
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
expect(colorPicker.props('disabled')).toBe(true)
})
it('enables color picker when not readonly', () => {
const widget = createMockWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000', false)
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
expect(colorPicker.props('disabled')).toBe(false)
})
})
describe('Color Formats', () => {
it('handles valid hex colors', async () => {
const validHexColors = [

View File

@@ -9,7 +9,6 @@
<ColorPicker
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="w-8 h-4 !rounded-full overflow-hidden border-none"
:pt="{
preview: '!w-full !h-full !border-none'
@@ -48,7 +47,6 @@ type WidgetOptions = { format?: ColorFormat } & Record<string, unknown>
const props = defineProps<{
widget: SimplifiedWidget<string, WidgetOptions>
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{

View File

@@ -209,23 +209,6 @@ describe('WidgetFileUpload File Handling', () => {
expect(editIcon.exists()).toBe(true)
expect(deleteIcon.exists()).toBe(true)
})
it('hides control buttons in readonly mode', () => {
const imageFile = createMockFile('test.jpg', 'image/jpeg')
const widget = createMockWidget<File[] | null>(
[imageFile],
{},
undefined,
{
name: 'test_file_upload',
type: 'file'
}
)
const wrapper = mountComponent(widget, [imageFile], true)
const controlButtons = wrapper.find('.absolute.top-2.right-2')
expect(controlButtons.exists()).toBe(false)
})
})
describe('Audio File Display', () => {
@@ -427,80 +410,6 @@ describe('WidgetFileUpload File Handling', () => {
})
})
describe('Readonly Mode', () => {
it('disables browse button in readonly mode', () => {
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
name: 'test_file_upload',
type: 'file'
})
const wrapper = mountComponent(widget, null, true)
const browseButton = wrapper.find('button')
expect(browseButton.element.disabled).toBe(true)
})
it('disables file input in readonly mode', () => {
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
name: 'test_file_upload',
type: 'file'
})
const wrapper = mountComponent(widget, null, true)
const fileInput = wrapper.find('input[type="file"]')
const inputElement = fileInput.element
if (!(inputElement instanceof HTMLInputElement)) {
throw new Error('Expected HTMLInputElement')
}
expect(inputElement.disabled).toBe(true)
})
it('disables folder button for images in readonly mode', () => {
const imageFile = createMockFile('test.jpg', 'image/jpeg')
const widget = createMockWidget<File[] | null>(
[imageFile],
{},
undefined,
{
name: 'test_file_upload',
type: 'file'
}
)
const wrapper = mountComponent(widget, [imageFile], true)
const buttons = wrapper.findAll('button')
const folderButton = buttons.find((button) =>
button.element.innerHTML.includes('pi-folder')
)
if (!folderButton) {
throw new Error('Folder button not found')
}
expect(folderButton.element.disabled).toBe(true)
})
it('does not handle file changes in readonly mode', async () => {
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
name: 'test_file_upload',
type: 'file'
})
const wrapper = mountComponent(widget, null, true)
const file = createMockFile('test.jpg', 'image/jpeg')
const fileInput = wrapper.find('input[type="file"]')
Object.defineProperty(fileInput.element, 'files', {
value: [file],
writable: false
})
await fileInput.trigger('change')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeUndefined()
})
})
describe('Edge Cases', () => {
it('handles empty file selection gracefully', async () => {
const widget = createMockWidget<File[] | null>(null, {}, undefined, {

View File

@@ -31,7 +31,6 @@
icon="pi pi-folder"
size="small"
class="!w-8 !h-8"
:disabled="readonly"
@click="triggerFileInput"
/>
</div>
@@ -47,7 +46,6 @@
/>
<!-- Control buttons in top right on hover -->
<div
v-if="!readonly"
class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
>
<!-- Edit button -->
@@ -100,7 +98,6 @@
icon="pi pi-folder"
size="small"
class="!w-8 !h-8"
:disabled="readonly"
@click="triggerFileInput"
/>
</div>
@@ -128,7 +125,7 @@
</div>
<!-- Control buttons -->
<div v-if="!readonly" class="flex gap-1">
<div class="flex gap-1">
<!-- Delete button -->
<button
class="w-8 h-8 rounded flex items-center justify-center transition-all duration-150 focus:outline-none border-none hover:bg-[#262729]"
@@ -148,7 +145,7 @@
:style="{ borderColor: '#262729' }"
>
<div
class="border border-dashed p-1 rounded-md transition-colors duration-200 hover:border-[#5B5E7D]"
class="border border-dashed p-1 rounded-md transition-colors duration-200 hover:border-slate-300"
:style="{ borderColor: '#262729' }"
>
<div class="flex flex-col items-center gap-2 w-full py-4">
@@ -159,7 +156,6 @@
size="small"
severity="secondary"
class="text-xs"
:disabled="readonly"
@click="triggerFileInput"
/>
</div>
@@ -173,7 +169,6 @@
class="hidden"
:accept="widget.options?.accept"
:multiple="false"
:disabled="readonly"
@change="handleFileChange"
/>
</template>
@@ -187,14 +182,9 @@ import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const {
widget,
modelValue,
readonly = false
} = defineProps<{
const { widget, modelValue } = defineProps<{
widget: SimplifiedWidget<File[] | null>
modelValue: File[] | null
readonly?: boolean
}>()
const emit = defineEmits<{
@@ -284,7 +274,7 @@ const triggerFileInput = () => {
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
if (!readonly && target.files && target.files.length > 0) {
if (target.files && target.files.length > 0) {
// Since we only support single file, take the first one
const file = target.files[0]

View File

@@ -61,8 +61,7 @@ function createMockWidget(
function mountComponent(
widget: SimplifiedWidget<GalleryValue>,
modelValue: GalleryValue,
readonly = false
modelValue: GalleryValue
) {
return mount(WidgetGalleria, {
global: {
@@ -71,7 +70,6 @@ function mountComponent(
},
props: {
widget,
readonly,
modelValue
}
})
@@ -87,11 +85,10 @@ function createImageStrings(count: number): string[] {
// Factory function that takes images, creates widget internally, returns wrapper
function createGalleriaWrapper(
images: GalleryValue,
options: Partial<GalleriaProps> = {},
readonly = false
options: Partial<GalleriaProps> = {}
) {
const widget = createMockWidget(images, options)
return mountComponent(widget, images, readonly)
return mountComponent(widget, images)
}
describe('WidgetGalleria Image Display', () => {
@@ -249,25 +246,6 @@ describe('WidgetGalleria Image Display', () => {
})
})
describe('Readonly Mode', () => {
it('passes readonly state to galleria when readonly', () => {
const images = createImageStrings(3)
const widget = createMockWidget(images)
const wrapper = mountComponent(widget, images, true)
// Galleria component should receive readonly state (though it may not support disabled)
expect(wrapper.props('readonly')).toBe(true)
})
it('passes readonly state to galleria when not readonly', () => {
const images = createImageStrings(3)
const widget = createMockWidget(images)
const wrapper = mountComponent(widget, images, false)
expect(wrapper.props('readonly')).toBe(false)
})
})
describe('Widget Options Handling', () => {
it('passes through valid widget options', () => {
const images = createImageStrings(2)

View File

@@ -72,7 +72,6 @@ const value = defineModel<GalleryValue>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<GalleryValue>
readonly?: boolean
}>()
const activeIndex = ref(0)

View File

@@ -41,7 +41,6 @@ export interface ImageCompareValue {
// Image compare widgets typically don't have v-model, they display comparison
const props = defineProps<{
widget: SimplifiedWidget<ImageCompareValue | string>
readonly?: boolean
}>()
const beforeImage = computed(() => {

View File

@@ -6,7 +6,6 @@ import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
defineProps<{
widget: SimplifiedWidget<number>
readonly?: boolean
}>()
const modelValue = defineModel<number>({ default: 0 })
@@ -21,7 +20,6 @@ const modelValue = defineModel<number>({ default: 0 })
"
v-model="modelValue"
:widget="widget"
:readonly="readonly"
v-bind="$attrs"
/>
</template>

View File

@@ -22,11 +22,7 @@ function createMockWidget(
}
}
function mountComponent(
widget: SimplifiedWidget<number>,
modelValue: number,
readonly = false
) {
function mountComponent(widget: SimplifiedWidget<number>, modelValue: number) {
return mount(WidgetInputNumberInput, {
global: {
plugins: [PrimeVue],
@@ -34,8 +30,7 @@ function mountComponent(
},
props: {
widget,
modelValue,
readonly
modelValue
}
})
}
@@ -93,14 +88,6 @@ describe('WidgetInputNumberInput Component Rendering', () => {
expect(inputNumber.props('showButtons')).toBe(true)
})
it('disables input when readonly', () => {
const widget = createMockWidget(5, 'int', {}, undefined)
const wrapper = mountComponent(widget, 5, true)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('disabled')).toBe(true)
})
it('sets button layout to horizontal', () => {
const widget = createMockWidget(5, 'int')
const wrapper = mountComponent(widget, 5)
@@ -244,7 +231,8 @@ describe('WidgetInputNumberInput Large Integer Precision Handling', () => {
expect(inputNumber.props('showButtons')).toBe(false)
})
it('shows tooltip for disabled buttons due to precision limits', () => {
it('shows tooltip for disabled buttons due to precision limits', (context) => {
context.skip('needs diagnosis')
const widget = createMockWidget(UNSAFE_LARGE_INTEGER, 'int')
const wrapper = mountComponent(widget, UNSAFE_LARGE_INTEGER)
@@ -279,16 +267,9 @@ describe('WidgetInputNumberInput Large Integer Precision Handling', () => {
expect(Number.isSafeInteger(-SAFE_INTEGER_MAX - 1)).toBe(false)
})
it('maintains readonly behavior even for unsafe values', () => {
const widget = createMockWidget(UNSAFE_LARGE_INTEGER, 'int')
const wrapper = mountComponent(widget, UNSAFE_LARGE_INTEGER, true)
it('handles floating point values correctly', (context) => {
context.skip('needs diagnosis')
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('disabled')).toBe(true)
expect(inputNumber.props('showButtons')).toBe(false) // Still hidden due to unsafe value
})
it('handles floating point values correctly', () => {
const safeFloat = 1000.5
const widget = createMockWidget(safeFloat, 'float')
const wrapper = mountComponent(widget, safeFloat)
@@ -297,7 +278,9 @@ describe('WidgetInputNumberInput Large Integer Precision Handling', () => {
expect(inputNumber.props('showButtons')).toBe(true)
})
it('hides buttons for unsafe floating point values', () => {
it('hides buttons for unsafe floating point values', (context) => {
context.skip('needs diagnosis')
const unsafeFloat = UNSAFE_LARGE_INTEGER + 0.5
const widget = createMockWidget(unsafeFloat, 'float')
const wrapper = mountComponent(widget, unsafeFloat)
@@ -326,7 +309,8 @@ describe('WidgetInputNumberInput Edge Cases for Precision Handling', () => {
expect(inputNumber.props('showButtons')).toBe(true) // Should default to safe behavior
})
it('handles NaN values gracefully', () => {
it('handles NaN values gracefully', (context) => {
context.skip('needs diagnosis')
const widget = createMockWidget(NaN, 'int')
const wrapper = mountComponent(widget, NaN)

View File

@@ -16,7 +16,6 @@ import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<number>
modelValue: number
readonly?: boolean
}>()
const emit = defineEmits<{
@@ -72,7 +71,6 @@ const buttonsDisabled = computed(() => {
// Tooltip message for disabled buttons
const buttonTooltip = computed(() => {
if (props.readonly) return null
if (buttonsDisabled.value) {
return 'Increment/decrement disabled: value exceeds JavaScript precision limit (±2^53)'
}
@@ -89,7 +87,6 @@ const buttonTooltip = computed(() => {
:show-buttons="!buttonsDisabled"
button-layout="horizontal"
size="small"
:disabled="readonly"
:step="stepValue"
:use-grouping="useGrouping"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"

View File

@@ -86,7 +86,6 @@ describe('WidgetInputNumberSlider Value Binding', () => {
it('renders input field', () => {
const widget = createMockWidget(5)
const wrapper = mountComponent(widget, 5)
console.log(wrapper.html())
expect(wrapper.find('input[inputmode="numeric"]').exists()).toBe(true)
})
@@ -98,17 +97,6 @@ describe('WidgetInputNumberSlider Value Binding', () => {
const input = getNumberInput(wrapper)
expect(input.value).toBe('42')
})
it('disables components in readonly mode', () => {
const widget = createMockWidget(5)
const wrapper = mountComponent(widget, 5, true)
const slider = wrapper.findComponent({ name: 'Slider' })
expect(slider.props('disabled')).toBe(true)
const input = getNumberInput(wrapper)
expect(input.disabled).toBe(true)
})
})
describe('Widget Options', () => {

View File

@@ -8,7 +8,6 @@
<Slider
:model-value="[localValue]"
v-bind="filteredProps"
:disabled="readonly"
class="flex-grow text-xs"
:step="stepValue"
@update:model-value="updateLocalValue"
@@ -17,7 +16,6 @@
:key="timesEmptied"
:model-value="localValue"
v-bind="filteredProps"
:disabled="readonly"
:step="stepValue"
:min-fraction-digits="precision"
:max-fraction-digits="precision"
@@ -46,10 +44,9 @@ import {
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const { widget, modelValue, readonly } = defineProps<{
const { widget, modelValue } = defineProps<{
widget: SimplifiedWidget<number>
modelValue: number
readonly?: boolean
}>()
const emit = defineEmits<{

View File

@@ -137,19 +137,6 @@ describe('WidgetInputText Value Binding', () => {
})
})
describe('Readonly Mode', () => {
it('disables input when readonly', () => {
const widget = createMockWidget('readonly test')
const wrapper = mountComponent(widget, 'readonly test', true)
const input = wrapper.find('input[type="text"]')
if (!(input.element instanceof HTMLInputElement)) {
throw new Error('Input element not found or is not an HTMLInputElement')
}
expect(input.element.disabled).toBe(true)
})
})
describe('Component Rendering', () => {
it('always renders InputText component', () => {
const widget = createMockWidget('test value')

View File

@@ -3,7 +3,6 @@
<InputText
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
:class="cn(WidgetInputBaseClass, 'w-full text-xs py-2 px-4')"
size="small"
@update:model-value="onChange"
@@ -29,7 +28,6 @@ import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{

View File

@@ -81,7 +81,10 @@ describe('WidgetMarkdown Dual Mode Display', () => {
expect(displayDiv.html()).toContain('<em>italic</em>')
})
it('starts in display mode by default', () => {
it('starts in display mode by default', (context) => {
context.skip(
'Something in the logic in these tests is definitely off. needs diagnosis'
)
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
@@ -89,19 +92,6 @@ describe('WidgetMarkdown Dual Mode Display', () => {
expect(wrapper.find('textarea').exists()).toBe(false)
})
it('applies styling classes to display container', () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
const displayDiv = wrapper.find('.comfy-markdown-content')
expect(displayDiv.classes()).toContain('text-xs')
expect(displayDiv.classes()).toContain('min-h-[60px]')
expect(displayDiv.classes()).toContain('rounded-lg')
expect(displayDiv.classes()).toContain('px-4')
expect(displayDiv.classes()).toContain('py-2')
expect(displayDiv.classes()).toContain('overflow-y-auto')
})
it('handles empty markdown content', () => {
const widget = createMockWidget('')
const wrapper = mountComponent(widget, '')
@@ -113,7 +103,8 @@ describe('WidgetMarkdown Dual Mode Display', () => {
})
describe('Edit Mode Toggle', () => {
it('switches to edit mode when clicked', async () => {
it('switches to edit mode when clicked', async (context) => {
context.skip('markdown editor not disappearing. needs diagnosis')
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
@@ -125,16 +116,6 @@ describe('WidgetMarkdown Dual Mode Display', () => {
expect(wrapper.find('textarea').exists()).toBe(true)
})
it('does not switch to edit mode when readonly', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test', true)
await clickToEdit(wrapper)
expect(wrapper.find('.comfy-markdown-content').exists()).toBe(true)
expect(wrapper.find('textarea').exists()).toBe(false)
})
it('does not switch to edit mode when already editing', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
@@ -148,7 +129,8 @@ describe('WidgetMarkdown Dual Mode Display', () => {
expect(wrapper.find('textarea').exists()).toBe(true)
})
it('switches back to display mode on textarea blur', async () => {
it('switches back to display mode on textarea blur', async (context) => {
context.skip('textarea not disappearing. needs diagnosis')
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
@@ -174,7 +156,10 @@ describe('WidgetMarkdown Dual Mode Display', () => {
expect(textarea.element.value).toBe('# Original Content')
})
it('applies styling and configuration to textarea', async () => {
it('applies styling and configuration to textarea', async (context) => {
context.skip(
'Props or styling are not as described in the test. needs diagnosis'
)
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
@@ -189,15 +174,6 @@ describe('WidgetMarkdown Dual Mode Display', () => {
expect(textarea.classes()).toContain('w-full')
})
it('disables textarea when readonly', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test', true)
// Readonly should prevent entering edit mode
await clickToEdit(wrapper)
expect(wrapper.find('textarea').exists()).toBe(false)
})
it('stops click and keydown event propagation in edit mode', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')

View File

@@ -15,7 +15,6 @@
v-show="isEditing"
ref="textareaRef"
v-model="localValue"
:disabled="readonly"
class="w-full min-h-[60px] absolute inset-0 resize-none"
:pt="{
root: {
@@ -23,6 +22,7 @@
onBlur: handleBlur
}
}"
data-capture-wheel="true"
@update:model-value="onChange"
@click.stop
@keydown.stop
@@ -44,7 +44,6 @@ import LODFallback from '../../components/LODFallback.vue'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{
@@ -69,7 +68,7 @@ const renderedHtml = computed(() => {
// Methods
const startEditing = async () => {
if (props.readonly || isEditing.value) return
if (isEditing.value) return
isEditing.value = true
await nextTick()

View File

@@ -176,33 +176,6 @@ describe('WidgetMultiSelect Value Binding', () => {
})
})
describe('Readonly Mode', () => {
it('disables multiselect when readonly', () => {
const widget = createMockWidget(['selected'], {
values: ['selected', 'other']
})
const wrapper = mountComponent(widget, ['selected'], true)
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
expect(multiselect.props('disabled')).toBe(true)
})
it('disables interaction but allows programmatic changes', async () => {
const widget = createMockWidget(['initial'], {
values: ['initial', 'other']
})
const wrapper = mountComponent(widget, ['initial'], true)
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
// The MultiSelect should be disabled, preventing user interaction
expect(multiselect.props('disabled')).toBe(true)
// But programmatic changes (like from external updates) should still work
// This is the expected behavior - readonly prevents UI interaction, not programmatic updates
})
})
describe('Widget Options Handling', () => {
it('passes through valid widget options', () => {
const widget = createMockWidget([], {

View File

@@ -4,7 +4,6 @@
v-model="localValue"
:options="multiSelectOptions"
v-bind="combinedProps"
:disabled="readonly"
class="w-full text-xs"
size="small"
display="chip"
@@ -33,7 +32,6 @@ import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<T[]>
modelValue: T[]
readonly?: boolean
}>()
const emit = defineEmits<{

View File

@@ -1,3 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Select from 'primevue/select'
@@ -43,7 +44,7 @@ describe('WidgetSelect Value Binding', () => {
readonly
},
global: {
plugins: [PrimeVue],
plugins: [PrimeVue, createTestingPinia()],
components: { Select }
}
})
@@ -113,16 +114,6 @@ describe('WidgetSelect Value Binding', () => {
})
})
describe('Readonly Mode', () => {
it('disables the select component when readonly', async () => {
const widget = createMockWidget('option1')
const wrapper = mountComponent(widget, 'option1', true)
const select = wrapper.findComponent({ name: 'Select' })
expect(select.props('disabled')).toBe(true)
})
})
describe('Option Handling', () => {
it('handles empty options array', async () => {
const widget = createMockWidget('', { values: [] })
@@ -204,7 +195,8 @@ describe('WidgetSelect Value Binding', () => {
expect(wrapper.findComponent(WidgetSelectDefault).exists()).toBe(false)
})
it('uses dropdown variant for audio uploads', () => {
it('uses dropdown variant for audio uploads', (context) => {
context.skip('allowUpload is not false, should it be? needs diagnosis')
const spec: ComboInputSpec = {
type: 'COMBO',
name: 'test_select',

View File

@@ -31,7 +31,6 @@ import WidgetSelectDropdown from './WidgetSelectDropdown.vue'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
modelValue: string | number | undefined
readonly?: boolean
}>()
const emit = defineEmits<{
@@ -84,6 +83,7 @@ const specDescriptor = computed<{
const allowUpload =
image_upload === true ||
animated_image_upload === true ||
video_upload === true ||
audio_upload === true
return {
kind,

View File

@@ -125,7 +125,9 @@ describe('WidgetSelectButton Button Selection', () => {
})
})
it('updates selection when modelValue changes', async () => {
it('updates selection when modelValue changes', async (context) => {
context.skip('Classes not updating, needs diagnosis')
const options = ['first', 'second', 'third']
const widget = createMockWidget('first', { values: options })
const wrapper = mountComponent(widget, 'first')
@@ -155,7 +157,8 @@ describe('WidgetSelectButton Button Selection', () => {
expect(emitted?.[0]).toEqual(['second'])
})
it('handles callback execution when provided', async () => {
it('handles callback execution when provided', async (context) => {
context.skip('Callback is not being called, needs diagnosis')
const mockCallback = vi.fn()
const options = ['option1', 'option2']
const widget = createMockWidget(
@@ -196,48 +199,6 @@ describe('WidgetSelectButton Button Selection', () => {
})
})
describe('Readonly Mode', () => {
it('disables all buttons when readonly', () => {
const options = ['option1', 'option2', 'option3']
const widget = createMockWidget('option1', { values: options })
const wrapper = mountComponent(widget, 'option1', true)
const formSelectButton = wrapper.findComponent({
name: 'FormSelectButton'
})
expect(formSelectButton.props('disabled')).toBe(true)
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.element.disabled).toBe(true)
expect(button.classes()).toContain('cursor-not-allowed')
expect(button.classes()).toContain('opacity-50')
})
})
it('does not emit changes in readonly mode', async () => {
const options = ['option1', 'option2']
const widget = createMockWidget('option1', { values: options })
const wrapper = mountComponent(widget, 'option1', true)
await clickSelectButton(wrapper, 'option2')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeUndefined()
})
it('does not change visual state in readonly mode', () => {
const options = ['option1', 'option2']
const widget = createMockWidget('option1', { values: options })
const wrapper = mountComponent(widget, 'option1', true)
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.classes()).not.toContain('hover:bg-zinc-200/50')
})
})
})
describe('Option Types', () => {
it('handles string options', () => {
const options = ['apple', 'banana', 'cherry']
@@ -385,19 +346,6 @@ describe('WidgetSelectButton Button Selection', () => {
})
})
it('applies container styling', () => {
const options = ['option1', 'option2']
const widget = createMockWidget('option1', { values: options })
const wrapper = mountComponent(widget, 'option1')
const container = wrapper.find('div').element
expect(container.className).toContain('p-1')
expect(container.className).toContain('inline-flex')
expect(container.className).toContain('justify-center')
expect(container.className).toContain('items-center')
expect(container.className).toContain('gap-1')
})
it('applies hover effects for non-selected options', () => {
const options = ['option1', 'option2']
const widget = createMockWidget('option1', { values: options })

View File

@@ -3,7 +3,6 @@
<FormSelectButton
v-model="localValue"
:options="widget.options?.values || []"
:disabled="readonly"
class="w-full"
@update:model-value="onChange"
/>
@@ -20,7 +19,6 @@ import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{

View File

@@ -4,12 +4,12 @@
v-model="localValue"
:options="selectOptions"
v-bind="combinedProps"
:disabled="readonly"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
size="small"
:pt="{
option: 'text-xs'
}"
data-capture-wheel="true"
@update:model-value="onChange"
/>
</WidgetLayoutField>
@@ -34,7 +34,6 @@ import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
modelValue: string | number | undefined
readonly?: boolean
}>()
const emit = defineEmits<{

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, provide, ref, watch } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
@@ -7,6 +7,7 @@ import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useQueueStore } from '@/stores/queueStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { AssetKind } from '@/types/widgetTypes'
import {
@@ -15,22 +16,27 @@ import {
} from '@/utils/widgetPropFilter'
import FormDropdown from './form/dropdown/FormDropdown.vue'
import type {
DropdownItem,
FilterOption,
SelectedKey
import {
AssetKindKey,
type DropdownItem,
type FilterOption,
type SelectedKey
} from './form/dropdown/types'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
modelValue: string | number | undefined
readonly?: boolean
assetKind?: AssetKind
allowUpload?: boolean
uploadFolder?: ResultItemType
}>()
provide(
AssetKindKey,
computed(() => props.assetKind)
)
const emit = defineEmits<{
'update:modelValue': [value: string | number | undefined]
}>()
@@ -43,6 +49,7 @@ const { localValue, onChange } = useWidgetValue({
})
const toastStore = useToastStore()
const queueStore = useQueueStore()
const transformCompatProps = useTransformCompatOverlayProps()
@@ -51,8 +58,15 @@ const combinedProps = computed(() => ({
...transformCompatProps.value
}))
const filterSelected = ref('all')
const filterOptions = ref<FilterOption[]>([
{ id: 'all', name: 'All' },
{ id: 'inputs', name: 'Inputs' },
{ id: 'outputs', name: 'Outputs' }
])
const selectedSet = ref<Set<SelectedKey>>(new Set())
const dropdownItems = computed<DropdownItem[]>(() => {
const inputItems = computed<DropdownItem[]>(() => {
const values = props.widget.options?.values || []
if (!Array.isArray(values)) {
@@ -60,12 +74,57 @@ const dropdownItems = computed<DropdownItem[]>(() => {
}
return values.map((value: string, index: number) => ({
id: index,
imageSrc: getMediaUrl(value),
id: `input-${index}`,
mediaSrc: getMediaUrl(value, 'input'),
name: value,
metadata: ''
}))
})
const outputItems = computed<DropdownItem[]>(() => {
if (!['image', 'video'].includes(props.assetKind ?? '')) return []
const outputs = new Set<string>()
// Extract output images/videos from queue history
queueStore.historyTasks.forEach((task) => {
task.flatOutputs.forEach((output) => {
const isTargetType =
(props.assetKind === 'image' && output.mediaType === 'images') ||
(props.assetKind === 'video' && output.mediaType === 'video')
if (output.type === 'output' && isTargetType) {
const path = output.subfolder
? `${output.subfolder}/${output.filename}`
: output.filename
// Add [output] annotation so the preview component knows the type
const annotatedPath = `${path} [output]`
outputs.add(annotatedPath)
}
})
})
return Array.from(outputs).map((output, index) => ({
id: `output-${index}`,
mediaSrc: getMediaUrl(output.replace(' [output]', ''), 'output'),
name: output,
metadata: ''
}))
})
const allItems = computed<DropdownItem[]>(() => {
return [...inputItems.value, ...outputItems.value]
})
const dropdownItems = computed<DropdownItem[]>(() => {
switch (filterSelected.value) {
case 'inputs':
return inputItems.value
case 'outputs':
return outputItems.value
case 'all':
default:
return allItems.value
}
})
const mediaPlaceholder = computed(() => {
const options = props.widget.options
@@ -92,6 +151,21 @@ const mediaPlaceholder = computed(() => {
const uploadable = computed(() => props.allowUpload === true)
const acceptTypes = computed(() => {
// Be permissive with accept types because backend uses libraries
// that can handle a wide range of formats
switch (props.assetKind) {
case 'image':
return 'image/*'
case 'video':
return 'video/*'
case 'audio':
return 'audio/*'
default:
return undefined // model or unknown
}
})
watch(
localValue,
(currentValue) => {
@@ -198,19 +272,13 @@ async function handleFilesUpdate(files: File[]) {
}
}
function getMediaUrl(filename: string): string {
if (props.assetKind !== 'image') return ''
// TODO: This needs to be adapted based on actual ComfyUI API structure
return `/api/view?filename=${encodeURIComponent(filename)}&type=input`
function getMediaUrl(
filename: string,
type: 'input' | 'output' = 'input'
): string {
if (!['image', 'video'].includes(props.assetKind ?? '')) return ''
return `/api/view?filename=${encodeURIComponent(filename)}&type=${type}`
}
// TODO handle filter logic
const filterSelected = ref('all')
const filterOptions = ref<FilterOption[]>([
{ id: 'all', name: 'All' },
{ id: 'image', name: 'Inputs' },
{ id: 'video', name: 'Outputs' }
])
</script>
<template>
@@ -222,7 +290,7 @@ const filterOptions = ref<FilterOption[]>([
:placeholder="mediaPlaceholder"
:multiple="false"
:uploadable="uploadable"
:disabled="readonly"
:accept="acceptTypes"
:filter-options="filterOptions"
v-bind="combinedProps"
class="w-full"

View File

@@ -153,21 +153,6 @@ describe('WidgetTextarea Value Binding', () => {
})
})
describe('Readonly Mode', () => {
it('disables textarea when readonly', () => {
const widget = createMockWidget('readonly test')
const wrapper = mountComponent(widget, 'readonly test', true)
const textarea = wrapper.find('textarea')
if (!(textarea.element instanceof HTMLTextAreaElement)) {
throw new Error(
'Textarea element not found or is not an HTMLTextAreaElement'
)
}
expect(textarea.element.disabled).toBe(true)
})
})
describe('Component Rendering', () => {
it('renders textarea component', () => {
const widget = createMockWidget('test value')

View File

@@ -3,11 +3,11 @@
<Textarea
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
:class="cn(WidgetInputBaseClass, 'w-full text-xs lod-toggle')"
:placeholder="placeholder || widget.name || ''"
size="small"
rows="3"
data-capture-wheel="true"
@update:model-value="onChange"
/>
<LODFallback />
@@ -32,7 +32,6 @@ import { WidgetInputBaseClass } from './layout'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
placeholder?: string
}>()

View File

@@ -106,14 +106,6 @@ describe('WidgetToggleSwitch Value Binding', () => {
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
expect(toggle.props('modelValue')).toBe(true)
})
it('disables component in readonly mode', () => {
const widget = createMockWidget(false)
const wrapper = mountComponent(widget, false, true)
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
expect(toggle.props('disabled')).toBe(true)
})
})
describe('Multiple Value Changes', () => {

View File

@@ -3,7 +3,6 @@
<ToggleSwitch
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
@update:model-value="onChange"
/>
</WidgetLayoutField>
@@ -25,7 +24,6 @@ import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<boolean>
modelValue: boolean
readonly?: boolean
}>()
const emit = defineEmits<{

View File

@@ -334,30 +334,6 @@ describe('WidgetTreeSelect Tree Navigation', () => {
})
})
describe('Readonly Mode', () => {
it('disables treeselect when readonly', () => {
const options = createTreeData()
const widget = createMockWidget(null, { options })
const wrapper = mountComponent(widget, null, true)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('disabled')).toBe(true)
})
it('does not emit changes in readonly mode', async () => {
const options = createTreeData()
const widget = createMockWidget(null, { options })
const wrapper = mountComponent(widget, null, true)
// Try to emit a change (though the component should prevent it)
await setTreeSelectValueAndEmit(wrapper, { key: '0-0-0', label: 'Test' })
// The component will still emit the event, but the disabled prop should prevent interaction
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined() // The event is emitted but the TreeSelect should be disabled
})
})
describe('Widget Options Handling', () => {
it('passes through valid widget options', () => {
const options = createTreeData()

View File

@@ -3,7 +3,6 @@
<TreeSelect
v-model="localValue"
v-bind="combinedProps"
:disabled="readonly"
class="w-full text-xs"
size="small"
@update:model-value="onChange"
@@ -37,7 +36,6 @@ export type TreeNode = {
const props = defineProps<{
widget: SimplifiedWidget<any>
modelValue: any
readonly?: boolean
}>()
const emit = defineEmits<{

View File

@@ -29,6 +29,7 @@ interface Props {
uploadable?: boolean
disabled?: boolean
accept?: string
filterOptions?: FilterOption[]
sortOptions?: SortOption[]
isSelected?: (
@@ -89,7 +90,7 @@ watch(searchQuery, (value) => {
})
watch(
debouncedSearchQuery,
[debouncedSearchQuery, () => props.items],
(_, __, onCleanup) => {
let isCleanup = false
let cleanupFn: undefined | (() => void)
@@ -195,6 +196,7 @@ function handleSelection(item: DropdownItem, index: number) {
:selected="selected"
:uploadable="uploadable"
:disabled="disabled"
:accept="accept"
@select-click="toggleDropdown"
@file-change="handleFileChange"
/>

View File

@@ -15,6 +15,7 @@ interface Props {
maxSelectable: number
uploadable: boolean
disabled: boolean
accept?: string
}
const props = withDefaults(defineProps<Props>(), {
@@ -37,14 +38,13 @@ const chevronClass = computed(() =>
})
)
const theButtonStyle = computed(() => [
'bg-transparent border-0 outline-none text-zinc-400',
{
'hover:bg-zinc-500/30 hover:text-black hover:dark-theme:text-white cursor-pointer':
const theButtonStyle = computed(() =>
cn('bg-transparent border-0 outline-none text-zinc-400', {
'hover:bg-node-component-widget-input-surface/30 cursor-pointer':
!props.disabled,
'cursor-not-allowed': props.disabled
}
])
})
)
</script>
<template>
@@ -92,6 +92,7 @@ const theButtonStyle = computed(() => [
class="opacity-0 absolute inset-0 -z-1"
:multiple="maxSelectable > 1"
:disabled="disabled"
:accept="accept"
@change="emit('file-change', $event)"
/>
</label>

View File

@@ -36,7 +36,7 @@ const searchQuery = defineModel<string>('searchQuery')
<template>
<div
class="w-103 h-[640px] pt-4 bg-white dark-theme:bg-charcoal-800 rounded-lg outline outline-offset-[-1px] outline-sand-100 dark-theme:outline-zinc-800 flex flex-col"
class="w-103 max-h-[640px] pt-4 bg-node-component-surface rounded-lg outline outline-offset-[-1px] outline-node-component-border flex flex-col"
>
<!-- Filter -->
<FormDropdownMenuFilter
@@ -67,7 +67,7 @@ const searchQuery = defineModel<string>('searchQuery')
"
>
<div
class="absolute top-0 inset-x-3 h-5 bg-gradient-to-b from-white dark-theme:from-neutral-900 to-transparent pointer-events-none z-10"
class="absolute top-0 inset-x-3 h-5 bg-gradient-to-b from-backdrop to-transparent pointer-events-none z-10"
/>
<div
v-if="items.length === 0"
@@ -84,7 +84,7 @@ const searchQuery = defineModel<string>('searchQuery')
:key="item.id"
:index="index"
:selected="isSelected(item, index)"
:image-src="item.imageSrc"
:media-src="item.mediaSrc"
:name="item.name"
:metadata="item.metadata"
:layout="layoutMode"

View File

@@ -1,14 +1,15 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, inject, ref } from 'vue'
import LazyImage from '@/components/common/LazyImage.vue'
import { cn } from '@/utils/tailwindUtil'
import type { LayoutMode } from './types'
import { AssetKindKey, type LayoutMode } from './types'
interface Props {
index: number
selected: boolean
imageSrc: string
mediaSrc: string
name: string
metadata?: string
layout?: LayoutMode
@@ -18,23 +19,36 @@ const props = defineProps<Props>()
const emit = defineEmits<{
click: [index: number]
imageLoad: [event: Event]
mediaLoad: [event: Event]
}>()
const actualDimensions = ref<string | null>(null)
const assetKind = inject(AssetKindKey)
const isVideo = computed(() => assetKind?.value === 'video')
function handleClick() {
emit('click', props.index)
}
function handleImageLoad(event: Event) {
emit('imageLoad', event)
emit('mediaLoad', event)
if (!event.target || !(event.target instanceof HTMLImageElement)) return
const img = event.target
if (img.naturalWidth && img.naturalHeight) {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
}
}
function handleVideoLoad(event: Event) {
emit('mediaLoad', event)
if (!event.target || !(event.target instanceof HTMLVideoElement)) return
const video = event.target
if (video.videoWidth && video.videoHeight) {
actualDimensions.value = `${video.videoWidth} x ${video.videoHeight}`
}
}
</script>
<template>
@@ -81,10 +95,19 @@ function handleImageLoad(event: Event) {
>
<i-lucide:check class="size-3 text-white -translate-y-[0.5px]" />
</div>
<img
v-if="imageSrc"
:src="imageSrc"
<video
v-if="mediaSrc && isVideo"
:src="mediaSrc"
class="size-full object-cover"
preload="metadata"
muted
@loadeddata="handleVideoLoad"
/>
<LazyImage
v-else-if="mediaSrc"
:src="mediaSrc"
:alt="name"
image-class="size-full object-cover"
@load="handleImageLoad"
/>
<div

View File

@@ -1,9 +1,13 @@
import type { ComputedRef, InjectionKey } from 'vue'
import type { AssetKind } from '@/types/widgetTypes'
export type OptionId = string | number | symbol
export type SelectedKey = OptionId
export interface DropdownItem {
id: SelectedKey
imageSrc: string
mediaSrc: string // URL for image, video, or other media
name: string
metadata: string
}
@@ -19,3 +23,6 @@ export interface FilterOption {
}
export type LayoutMode = 'list' | 'grid' | 'list-small'
export const AssetKindKey: InjectionKey<ComputedRef<AssetKind | undefined>> =
Symbol('assetKind')

View File

@@ -17,7 +17,7 @@ defineProps<{
<div class="relative h-6 flex items-center mr-4">
<p
v-if="widget.name"
class="text-sm text-stone-200 dark-theme:text-slate-200 font-normal flex-1 truncate w-20 lod-toggle"
class="text-sm text-node-component-slot-text font-normal flex-1 truncate w-20 lod-toggle"
>
{{ widget.label || widget.name }}
</p>

View File

@@ -2,15 +2,13 @@ import { cn } from '@/utils/tailwindUtil'
export const WidgetInputBaseClass = cn([
// Background
'bg-zinc-500/10',
'bg-node-component-widget-input-surface',
'text-node-component-widget-input',
// Outline
'border-none',
'outline',
'outline-1',
'outline-offset-[-1px]',
'outline-zinc-300/10',
'outline outline-offset-[-1px] outline-zinc-300/10',
// Rounded
'!rounded-lg',
'rounded-lg',
// Hover
'hover:outline-blue-500/80'
])

View File

@@ -7,6 +7,10 @@ import { isAssetWidget, isComboWidget } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import {
assetFilenameSchema,
assetItemSchema
} from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { useSettingStore } from '@/platform/settings/settingStore'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
@@ -355,13 +359,9 @@ const addComboWidget = (
): IBaseWidget => {
const settingStore = useSettingStore()
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
const isEligible = assetService.isAssetBrowserEligible(
inputSpec.name,
node.comfyClass || ''
)
const isEligible = assetService.isAssetBrowserEligible(node.comfyClass)
if (isUsingAssetAPI && isEligible) {
// Get the default value for the button text (currently selected model)
const currentValue = getDefaultValue(inputSpec)
const displayLabel = currentValue ?? t('widgets.selectModel')
@@ -379,11 +379,40 @@ const addComboWidget = (
nodeType: node.comfyClass || '',
inputName: inputSpec.name,
currentValue: widget.value,
onAssetSelected: (filename: string) => {
onAssetSelected: (asset) => {
const validatedAsset = assetItemSchema.safeParse(asset)
if (!validatedAsset.success) {
console.error(
'Invalid asset item:',
validatedAsset.error.errors,
'Received:',
asset
)
return
}
const filename = validatedAsset.data.user_metadata?.filename
const validatedFilename = assetFilenameSchema.safeParse(filename)
if (!validatedFilename.success) {
console.error(
'Invalid asset filename:',
validatedFilename.error.errors,
'for asset:',
validatedAsset.data.id
)
return
}
const oldValue = widget.value
widget.value = filename
// Using onWidgetChanged prevents a callback race where asset selection could reopen the dialog
node.onWidgetChanged?.(widget.name, filename, oldValue, widget)
widget.value = validatedFilename.data
node.onWidgetChanged?.(
widget.name,
validatedFilename.data,
oldValue,
widget
)
}
})
}

View File

@@ -19,7 +19,8 @@ import { is_all_same_aspect_ratio } from '@/utils/imageUtil'
const renderPreview = (
ctx: CanvasRenderingContext2D,
node: LGraphNode,
shiftY: number
shiftY: number,
computedHeight: number | undefined
) => {
const canvas = useCanvasStore().getCanvas()
const mouse = canvas.graph_mouse
@@ -46,7 +47,7 @@ const renderPreview = (
const allowImageSizeDraw = settingStore.get('Comfy.Node.AllowImageSizeDraw')
const IMAGE_TEXT_SIZE_TEXT_HEIGHT = allowImageSizeDraw ? 15 : 0
const dw = node.size[0]
const dh = node.size[1] - shiftY - IMAGE_TEXT_SIZE_TEXT_HEIGHT
const dh = computedHeight ? computedHeight - IMAGE_TEXT_SIZE_TEXT_HEIGHT : 0
if (imageIndex == null) {
// No image selected; draw thumbnails of all
@@ -260,7 +261,7 @@ class ImagePreviewWidget extends BaseWidget {
}
override drawWidget(ctx: CanvasRenderingContext2D): void {
renderPreview(ctx, this.node, this.y)
renderPreview(ctx, this.node, this.y, this.computedHeight)
}
override onPointerDown(pointer: CanvasPointer, node: LGraphNode): boolean {

View File

@@ -10,7 +10,7 @@ import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import { fileNameMappingService } from '@/services/fileNameMappingService'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { isImageUploadInput } from '@/types/nodeDefAugmentation'
import { createAnnotatedPath } from '@/utils/formatUtil'
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
import { addToComboValues } from '@/utils/litegraphUtil'
const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
@@ -117,7 +117,8 @@ export const useImageUploadWidget = () => {
'image',
() => openFileSelection(),
{
serialize: false
serialize: false,
canvasOnly: true
}
)
uploadWidget.label = t('g.choose_file_to_upload')
@@ -131,7 +132,7 @@ export const useImageUploadWidget = () => {
}
// On load if we have a value then render the image
// The value isnt set immediately so we need to wait a moment
// The value isn't set immediately so we need to wait a moment
// No change callbacks seem to be fired on initial setting of the value
requestAnimationFrame(() => {
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {

View File

@@ -231,7 +231,9 @@ export function useRemoteWidget<
* Add a refresh button to the node that, when clicked, will force the widget to refresh
*/
function addRefreshButton() {
node.addWidget('button', 'refresh', 'refresh', widget.refresh)
node.addWidget('button', 'refresh', 'refresh', widget.refresh, {
canvasOnly: true
})
}
/**
@@ -256,7 +258,8 @@ export function useRemoteWidget<
autoRefreshEnabled = value
},
{
serialize: false
serialize: false,
canvasOnly: true
}
)